diff options
Diffstat (limited to 'src/features')
212 files changed, 28830 insertions, 1286 deletions
diff --git a/src/features/account-usage/index.ts b/src/features/account-usage/index.ts new file mode 100644 index 0000000..76204dc --- /dev/null +++ b/src/features/account-usage/index.ts @@ -0,0 +1,41 @@ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageResult, + AccountUsageSettingsResult, + AccountUsageState, + LoadAccountUsage, + SaveAccountUsageSettings, + UpdateAccountUsageSettingsRequest, +} from "./logic/types"; +export type { + BarTone, + DailyCreditsView, + ProviderCardView, + UsageBarView, +} from "./logic/view-model"; +export { + anchorFromTimeOfDay, + elapsedFraction, + formatAge, + formatDuration, + normalizeAccountUsage, + normalizeAccountUsageReport, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewProviderCards, + viewUsageBar, +} from "./logic/view-model"; +export { default as AccountUsageView } from "./ui/AccountUsageView.svelte"; +export { default as DailyCreditsSettings } from "./ui/DailyCreditsSettings.svelte"; +export { default as UsageBar } from "./ui/UsageBar.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "account-usage", + description: "Claude + Codex + Hyper subscription usage bars with on-track pace markers", +} as const; diff --git a/src/features/account-usage/logic/types.ts b/src/features/account-usage/logic/types.ts new file mode 100644 index 0000000..ff92590 --- /dev/null +++ b/src/features/account-usage/logic/types.ts @@ -0,0 +1,48 @@ +import type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +} from "@dispatch/transport-contract"; + +/** + * Pure core types for the account-usage feature — zero DOM, zero effects. + * + * The backend (`account-usage` extension, transport-contract 0.25.0) polls the + * Claude + Codex subscription usage endpoints and caches them (5 min TTL, + * failure cooldowns, last-good retention). The FE reads `GET /usage`; a manual + * refresh sends `?refresh=1`, which the server still rate-limits. + */ +export type { + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, + UpdateAccountUsageSettingsRequest, +}; + +/** Outcome of `GET /usage`. `unavailable` = the backend extension isn't loaded (503). */ +export type AccountUsageResult = + | { readonly ok: true; readonly usage: AccountUsageResponse } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +/** Injected port: load the account usage (`refresh` asks the server to bypass its TTL). */ +export type LoadAccountUsage = (opts?: { + readonly refresh?: boolean; +}) => Promise<AccountUsageResult>; + +/** Outcome of `PUT /usage/:provider/settings` — the provider's refreshed report. */ +export type AccountUsageSettingsResult = + | { readonly ok: true; readonly report: AccountUsageReport } + | { readonly ok: false; readonly error: string }; + +/** Injected port: update a provider's usage settings (today only `hyper`). */ +export type SaveAccountUsageSettings = ( + provider: AccountUsageProviderId, + settings: UpdateAccountUsageSettingsRequest, +) => Promise<AccountUsageSettingsResult>; diff --git a/src/features/account-usage/logic/view-model.test.ts b/src/features/account-usage/logic/view-model.test.ts new file mode 100644 index 0000000..78390cf --- /dev/null +++ b/src/features/account-usage/logic/view-model.test.ts @@ -0,0 +1,277 @@ +import type { AccountUsageLimit, AccountUsageReport } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { + anchorFromTimeOfDay, + elapsedFraction, + formatDuration, + normalizeAccountUsage, + parseAllowanceInput, + toLocalTimeOfDay, + viewProviderCard, + viewUsageBar, +} from "./view-model"; + +const HOUR = 3_600_000; +const DAY = 24 * HOUR; +const NOW = 1_760_000_000_000; + +function limit(over: Partial<AccountUsageLimit> = {}): AccountUsageLimit { + return { + id: "claude:5h", + label: "5 Hour", + kind: "window", + windowId: "5h", + shared: true, + tier: null, + durationMs: 5 * HOUR, + resetsAt: NOW + 2 * HOUR, + unit: "percent", + used: 40, + limit: 100, + remaining: 60, + usedFraction: 0.4, + remainingFraction: 0.6, + status: "ok", + ...over, + }; +} + +function report(over: Partial<AccountUsageReport> = {}): AccountUsageReport { + return { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "[email protected]", planType: "max" }, + limits: [limit()], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: NOW - 3 * 60_000, + lastAttemptAt: NOW - 3 * 60_000, + nextRefreshAt: NOW + 2 * 60_000, + error: null, + ...over, + }; +} + +describe("pace marker (elapsedFraction)", () => { + it("is halfway two weeks into a four-week window", () => { + const resetsAt = NOW + 14 * DAY; + expect(elapsedFraction(28 * DAY, resetsAt, NOW)).toBeCloseTo(0.5); + }); + + it("clamps to 0..1 and is null when the window is unknown", () => { + expect(elapsedFraction(5 * HOUR, NOW + 10 * HOUR, NOW)).toBe(0); + expect(elapsedFraction(5 * HOUR, NOW - HOUR, NOW)).toBe(1); + expect(elapsedFraction(null, NOW, NOW)).toBeNull(); + expect(elapsedFraction(5 * HOUR, null, NOW)).toBeNull(); + }); +}); + +describe("viewUsageBar", () => { + it("places the pace marker at elapsed % and labels ahead/under pace", () => { + // 5h window resetting in 2h ⇒ 3h elapsed ⇒ 60% pace; used 40% ⇒ 20% under. + const bar = viewUsageBar(limit(), NOW); + expect(bar.percent).toBe(40); + expect(bar.pacePercent).toBeCloseTo(60); + expect(bar.paceLabel).toBe("20% under pace"); + expect(bar.aheadOfPace).toBe(false); + expect(bar.resetLabel).toBe("resets in 2h"); + expect(bar.valueLabel).toBe("40%"); + expect(bar.tone).toBe("success"); + + const ahead = viewUsageBar(limit({ used: 80, usedFraction: 0.8 }), NOW); + expect(ahead.paceLabel).toBe("20% ahead of pace"); + expect(ahead.aheadOfPace).toBe(true); + expect(ahead.tone).toBe("warning"); + }); + + it("has no pace marker for spend meters; formats dollars", () => { + const spend = viewUsageBar( + limit({ + id: "claude:extra", + kind: "spend", + unit: "usd", + durationMs: null, + resetsAt: null, + used: 12.5, + limit: 50, + usedFraction: 0.25, + }), + NOW, + ); + expect(spend.pacePercent).toBeNull(); + expect(spend.valueLabel).toBe("$12.50 / $50.00"); + const uncapped = viewUsageBar( + limit({ + unit: "usd", + kind: "spend", + limit: null, + usedFraction: null, + used: 3, + durationMs: null, + }), + NOW, + ); + expect(uncapped.valueLabel).toBe("$3.00 (no cap)"); + expect(uncapped.percent).toBe(0); + }); + + it("exhausted → error tone", () => { + expect(viewUsageBar(limit({ used: 100, usedFraction: 1, status: "exhausted" }), NOW).tone).toBe( + "error", + ); + }); +}); + +describe("viewProviderCard", () => { + it("fresh card with subtitle and age", () => { + const c = viewProviderCard(report(), NOW); + expect(c.subtitle).toBe("Max · [email protected]"); + expect(c.stateBadge).toBeNull(); + expect(c.message).toBeNull(); + expect(c.ageLabel).toBe("3m ago"); + expect(c.bars).toHaveLength(1); + }); + + it("stale / not-configured / unsupported states", () => { + const stale = viewProviderCard(report({ state: "stale", error: "HTTP 429" }), NOW); + expect(stale.stateBadge).toBe("stale"); + expect(stale.message).toContain("HTTP 429"); + const nc = viewProviderCard( + report({ + provider: "codex", + label: "Codex", + state: "not-configured", + limits: [], + account: null, + }), + NOW, + ); + expect(nc.stateBadge).toBe("not configured"); + expect(nc.message).toContain("Codex"); + expect(nc.ageLabel).toBeNull(); + const un = viewProviderCard( + report({ state: "unsupported", error: "api key", limits: [] }), + NOW, + ); + expect(un.message).toBe("api key"); + }); + + it("credit overage note", () => { + const c = viewProviderCard( + report({ creditOverage: true, limitReached: true, canServe: true }), + NOW, + ); + expect(c.creditNote).toContain("credits"); + }); +}); + +describe("normalizeAccountUsage", () => { + it("coerces a well-formed body and drops malformed rows", () => { + const n = normalizeAccountUsage( + { + servedAt: 5, + providers: [ + { ...report(), limits: [limit(), { id: "bad" }] }, + { provider: "gemini" }, + "junk", + ], + }, + 0, + ); + expect(n.servedAt).toBe(5); + expect(n.providers).toHaveLength(1); + expect(n.providers[0]?.limits).toHaveLength(1); + }); + + it("malformed body → empty", () => { + expect(normalizeAccountUsage(null, 9)).toEqual({ providers: [], servedAt: 9 }); + }); +}); + +describe("formatDuration", () => { + it("formats", () => { + expect(formatDuration(30_000)).toBe("<1m"); + expect(formatDuration(45 * 60_000)).toBe("45m"); + expect(formatDuration(3 * HOUR + 12 * 60_000)).toBe("3h 12m"); + expect(formatDuration(2 * DAY + 4 * HOUR)).toBe("2d 4h"); + }); +}); + +describe("hyper daily credits", () => { + const hyperLimit = limit({ + id: "hyper:daily", + label: "Daily credits", + windowId: "1d", + unit: "credits", + durationMs: DAY, + resetsAt: NOW + 12 * HOUR, + used: 150, + limit: 250, + remaining: 100, + usedFraction: 0.6, + }); + + it("formats credits and places the pace marker halfway through the day", () => { + const bar = viewUsageBar(hyperLimit, NOW); + expect(bar.valueLabel).toBe("150 / 250"); + expect(bar.pacePercent).toBeCloseTo(50); + expect(bar.paceLabel).toBe("10% ahead of pace"); + }); + + it("card exposes balance + reset source", () => { + const c = viewProviderCard( + report({ + provider: "hyper", + label: "Hyper", + account: { accountId: null, email: null, planType: null }, + limits: [hyperLimit], + dailyCredits: { balance: 300, allowance: 250, resetAnchor: null, resetSource: null }, + }), + NOW, + ); + expect(c.daily).toMatchObject({ + balanceLabel: "300 credits left (incl. bundles)", + allowance: 250, + resetTime: "", + resetSource: null, + }); + expect(c.daily?.resetSourceLabel).toContain("unknown"); + }); + + it("round-trips a local time of day into a past anchor", () => { + const now = new Date(2026, 9, 14, 15, 30).getTime(); + const a = anchorFromTimeOfDay("09:00", now); + expect(a).toBe(new Date(2026, 9, 14, 9, 0).getTime()); + expect(anchorFromTimeOfDay("17:00", now)).toBe(new Date(2026, 9, 13, 17, 0).getTime()); + expect(toLocalTimeOfDay(a ?? 0)).toBe("09:00"); + expect(anchorFromTimeOfDay("25:00", now)).toBeNull(); + expect(anchorFromTimeOfDay("noon", now)).toBeNull(); + }); + + it("parses the allowance input", () => { + expect(parseAllowanceInput(" 250 ")).toBe(250); + expect(parseAllowanceInput("0")).toBeNull(); + expect(parseAllowanceInput("2.5")).toBeNull(); + }); + + it("normalizes hyper reports + credit units", () => { + const n = normalizeAccountUsage( + { + providers: [ + { + ...report({ provider: "hyper" }), + limits: [hyperLimit], + dailyCredits: { balance: 1, allowance: 250, resetAnchor: 5, resetSource: "manual" }, + }, + ], + }, + 0, + ); + expect(n.providers[0]?.limits[0]?.unit).toBe("credits"); + expect(n.providers[0]?.dailyCredits?.resetSource).toBe("manual"); + }); +}); diff --git a/src/features/account-usage/logic/view-model.ts b/src/features/account-usage/logic/view-model.ts new file mode 100644 index 0000000..17d37a6 --- /dev/null +++ b/src/features/account-usage/logic/view-model.ts @@ -0,0 +1,403 @@ +import type { + AccountUsageCredits, + AccountUsageDailyCredits, + AccountUsageLimit, + AccountUsageLimitStatus, + AccountUsageProviderId, + AccountUsageReport, + AccountUsageResponse, + AccountUsageState, +} from "@dispatch/transport-contract"; + +/** + * Pure view-models for the account-usage feature — zero DOM, zero effects, + * zero Svelte. Coerces the untyped `GET /usage` JSON, and shapes each limit + * into a bar: fill %, colour, and the PACE MARKER — where usage "should" be + * to use the whole allowance exactly by the reset (elapsed fraction of the + * window: 2 weeks into a 4-week window ⇒ 50%). + */ + +// ── Normalization (network seam) ────────────────────────────────────────────── + +function isRecord(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null && !Array.isArray(v); +} +function num(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} +function str(v: unknown): string | null { + return typeof v === "string" ? v : null; +} +function bool(v: unknown): boolean | null { + return typeof v === "boolean" ? v : null; +} + +const STATES: readonly AccountUsageState[] = [ + "fresh", + "stale", + "unavailable", + "unsupported", + "not-configured", +]; +const STATUSES: readonly AccountUsageLimitStatus[] = ["ok", "warning", "exhausted", "unknown"]; + +function normalizeLimit(r: Record<string, unknown>): AccountUsageLimit | null { + const id = str(r.id); + const used = num(r.used); + if (id === null || used === null) return null; + const status = STATUSES.find((s) => s === r.status) ?? "unknown"; + return { + id, + label: str(r.label) ?? id, + kind: r.kind === "spend" ? "spend" : "window", + windowId: str(r.windowId) ?? "", + shared: r.shared === true, + tier: str(r.tier), + durationMs: num(r.durationMs), + resetsAt: num(r.resetsAt), + unit: r.unit === "usd" ? "usd" : r.unit === "credits" ? "credits" : "percent", + used, + limit: num(r.limit), + remaining: num(r.remaining), + usedFraction: num(r.usedFraction), + remainingFraction: num(r.remainingFraction), + status, + }; +} + +function normalizeCredits(v: unknown): AccountUsageCredits | null { + if (!isRecord(v)) return null; + return { + hasCredits: bool(v.hasCredits), + unlimited: bool(v.unlimited), + overageLimitReached: bool(v.overageLimitReached), + balance: num(v.balance), + spendControlReached: bool(v.spendControlReached), + }; +} + +function normalizeDailyCredits(v: unknown): AccountUsageDailyCredits | null { + if (!isRecord(v)) return null; + const balance = num(v.balance); + const allowance = num(v.allowance); + if (balance === null || allowance === null) return null; + return { + balance, + allowance, + resetAnchor: num(v.resetAnchor), + resetSource: v.resetSource === "manual" || v.resetSource === "detected" ? v.resetSource : null, + }; +} + +const PROVIDER_LABELS: Record<AccountUsageProviderId, string> = { + claude: "Claude", + codex: "Codex", + hyper: "Hyper", +}; + +function normalizeReport(r: Record<string, unknown>): AccountUsageReport | null { + const provider: AccountUsageProviderId | null = + r.provider === "claude" || r.provider === "codex" || r.provider === "hyper" ? r.provider : null; + if (provider === null) return null; + const account = isRecord(r.account) + ? { + accountId: str(r.account.accountId), + email: str(r.account.email), + planType: str(r.account.planType), + } + : null; + const limits = Array.isArray(r.limits) + ? r.limits + .filter(isRecord) + .map(normalizeLimit) + .filter((l): l is AccountUsageLimit => l !== null) + : []; + return { + provider, + label: str(r.label) ?? PROVIDER_LABELS[provider], + state: STATES.find((s) => s === r.state) ?? "unavailable", + account, + limits, + limitReached: bool(r.limitReached), + canServe: bool(r.canServe), + creditOverage: r.creditOverage === true, + credits: normalizeCredits(r.credits), + dailyCredits: normalizeDailyCredits(r.dailyCredits), + fetchedAt: num(r.fetchedAt), + lastAttemptAt: num(r.lastAttemptAt), + nextRefreshAt: num(r.nextRefreshAt), + error: str(r.error), + }; +} + +/** Coerce one untyped report (e.g. the `PUT /usage/:provider/settings` body); null if malformed. */ +export function normalizeAccountUsageReport(data: unknown): AccountUsageReport | null { + return isRecord(data) ? normalizeReport(data) : null; +} + +/** Coerce the untyped `GET /usage` body. Malformed → empty provider list. */ +export function normalizeAccountUsage(data: unknown, fallbackNow: number): AccountUsageResponse { + if (!isRecord(data)) return { providers: [], servedAt: fallbackNow }; + const providers = Array.isArray(data.providers) + ? data.providers + .filter(isRecord) + .map(normalizeReport) + .filter((p): p is AccountUsageReport => p !== null) + : []; + return { providers, servedAt: num(data.servedAt) ?? fallbackNow }; +} + +// ── Formatting ──────────────────────────────────────────────────────────────── + +/** "2d 4h" / "3h 12m" / "45m" / "<1m". */ +export function formatDuration(ms: number): string { + if (ms < 60_000) return "<1m"; + const totalMin = Math.floor(ms / 60_000); + const d = Math.floor(totalMin / 1440); + const h = Math.floor((totalMin % 1440) / 60); + const m = totalMin % 60; + if (d > 0) return h > 0 ? `${d}d ${h}h` : `${d}d`; + if (h > 0) return m > 0 ? `${h}h ${m}m` : `${h}h`; + return `${m}m`; +} + +/** "updated 3m ago" style age, or "just now". */ +export function formatAge(fetchedAt: number | null, now: number): string | null { + if (fetchedAt === null) return null; + const ms = now - fetchedAt; + return ms < 60_000 ? "just now" : `${formatDuration(ms)} ago`; +} + +function formatUsd(v: number): string { + return `$${v.toFixed(2)}`; +} + +/** Whole credits as-is, fractional ones to 1 decimal ("150", "12.5"). */ +function formatCredits(v: number): string { + return Number.isInteger(v) ? String(v) : v.toFixed(1); +} + +// ── Bars ────────────────────────────────────────────────────────────────────── + +export type BarTone = "success" | "warning" | "error" | "neutral"; + +export interface UsageBarView { + readonly id: string; + readonly label: string; + /** Fill 0..100 for the `<progress>` value. */ + readonly percent: number; + /** "42%" or "$12.50 / $50.00". */ + readonly valueLabel: string; + readonly tone: BarTone; + /** + * Pace marker position 0..100 (% of the window elapsed) — the usage level + * that would be "on track" to exactly use the allowance by the reset. Null + * when the window's start can't be determined (no duration/reset). + */ + readonly pacePercent: number | null; + /** "+12% ahead of pace" / "8% under pace" / null. */ + readonly paceLabel: string | null; + /** True when usage is ahead of the pace marker. */ + readonly aheadOfPace: boolean; + /** "resets in 3h 12m" / null. */ + readonly resetLabel: string | null; +} + +/** + * Fraction (0..1) of the window elapsed at `now`. The window started at + * `resetsAt - durationMs`; returns null when either is unknown. + */ +export function elapsedFraction( + durationMs: number | null, + resetsAt: number | null, + now: number, +): number | null { + if (durationMs === null || resetsAt === null || durationMs <= 0) return null; + const start = resetsAt - durationMs; + return Math.min(1, Math.max(0, (now - start) / durationMs)); +} + +function toneFor(status: AccountUsageLimitStatus, usedFraction: number | null): BarTone { + if (status === "exhausted") return "error"; + if (status === "warning") return "warning"; + if (status === "ok") return usedFraction !== null && usedFraction >= 0.75 ? "warning" : "success"; + return "neutral"; +} + +export function viewUsageBar(limit: AccountUsageLimit, now: number): UsageBarView { + const fraction = limit.usedFraction; + const percent = fraction === null ? 0 : Math.min(100, Math.max(0, fraction * 100)); + let valueLabel: string; + if (limit.unit === "usd") { + valueLabel = + limit.limit === null + ? `${formatUsd(limit.used)} (no cap)` + : `${formatUsd(limit.used)} / ${formatUsd(limit.limit)}`; + } else if (limit.unit === "credits") { + valueLabel = + limit.limit === null + ? `${formatCredits(limit.used)} used` + : `${formatCredits(limit.used)} / ${formatCredits(limit.limit)}`; + } else { + valueLabel = `${Math.round(limit.used)}%`; + } + const elapsed = elapsedFraction(limit.durationMs, limit.resetsAt, now); + const pacePercent = elapsed === null ? null : elapsed * 100; + let paceLabel: string | null = null; + let aheadOfPace = false; + if (pacePercent !== null && fraction !== null) { + const diff = Math.round(percent - pacePercent); + aheadOfPace = diff > 0; + paceLabel = + diff === 0 ? "on pace" : diff > 0 ? `${diff}% ahead of pace` : `${-diff}% under pace`; + } + const resetLabel = + limit.resetsAt === null + ? null + : limit.resetsAt <= now + ? "resetting" + : `resets in ${formatDuration(limit.resetsAt - now)}`; + return { + id: limit.id, + label: limit.label, + percent, + valueLabel, + tone: toneFor(limit.status, fraction), + pacePercent, + paceLabel, + aheadOfPace, + resetLabel, + }; +} + +// ── Provider cards ──────────────────────────────────────────────────────────── + +export interface ProviderCardView { + readonly provider: AccountUsageProviderId; + readonly label: string; + /** "Pro · [email protected]" / null. */ + readonly subtitle: string | null; + readonly state: AccountUsageState; + /** Badge text for non-fresh states ("stale", "unavailable", …) or null when fresh. */ + readonly stateBadge: string | null; + /** Explanatory line for empty/non-fresh states, else null. */ + readonly message: string | null; + readonly bars: readonly UsageBarView[]; + /** "updated 3m ago" / null. */ + readonly ageLabel: string | null; + /** Credit-overage note (Codex), or null. */ + readonly creditNote: string | null; + /** Daily-credit details + editable settings (Hyper), or null. */ + readonly daily: DailyCreditsView | null; +} + +export interface DailyCreditsView { + /** "100 credits left" (+ " (incl. bundles)" when the balance exceeds the allowance). */ + readonly balanceLabel: string; + readonly allowance: number; + /** The reset anchor as a local `HH:MM` (for a time input), or "" when unknown. */ + readonly resetTime: string; + /** "set manually" / "detected from a refill (approx.)" / "unknown — set it to enable the pace marker". */ + readonly resetSourceLabel: string; + readonly resetSource: "manual" | "detected" | null; +} + +/** Local `HH:MM` of an epoch-ms instant (time-of-day only; resets recur daily). */ +export function toLocalTimeOfDay(epochMs: number): string { + const d = new Date(epochMs); + return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; +} + +/** + * Parse a local `HH:MM` into a reset anchor: the most recent occurrence of that + * local time at or before `now` (any occurrence works — resets recur daily). + * Null for malformed input. + */ +export function anchorFromTimeOfDay(hhmm: string, now: number): number | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + const d = new Date(now); + d.setHours(h, min, 0, 0); + if (d.getTime() > now) d.setDate(d.getDate() - 1); + return d.getTime(); +} + +/** Parse a positive-integer allowance input; null when invalid. */ +export function parseAllowanceInput(raw: string): number | null { + const t = raw.trim(); + if (!/^\d+$/.test(t)) return null; + const n = Number(t); + return n > 0 && n <= 1_000_000_000 ? n : null; +} + +function viewDailyCredits(d: AccountUsageDailyCredits): DailyCreditsView { + return { + balanceLabel: `${formatCredits(d.balance)} credits left${d.balance > d.allowance ? " (incl. bundles)" : ""}`, + allowance: d.allowance, + resetTime: d.resetAnchor === null ? "" : toLocalTimeOfDay(d.resetAnchor), + resetSourceLabel: + d.resetSource === "manual" + ? "set manually" + : d.resetSource === "detected" + ? "detected from a refill (approx.)" + : "unknown — set it to enable the pace marker", + resetSource: d.resetSource, + }; +} + +function stateMessage(r: AccountUsageReport): string | null { + switch (r.state) { + case "fresh": + return r.limits.length === 0 ? "No quota windows reported." : null; + case "stale": + return `Showing last known usage — refresh failed${r.error ? ` (${r.error})` : ""}.`; + case "unavailable": + return `Usage unavailable${r.error ? `: ${r.error}` : "."}`; + case "unsupported": + return r.error ?? "This credential type can't report subscription usage."; + case "not-configured": + if (r.provider === "hyper") return "No Hyper API key configured (HYPER_API_KEY)."; + return r.provider === "claude" + ? "Not signed in to Claude (no OAuth credentials)." + : "Not signed in to Codex (no ChatGPT OAuth credentials)."; + } +} + +function capitalize(s: string): string { + return s.length === 0 ? s : s[0]?.toUpperCase() + s.slice(1); +} + +export function viewProviderCard(r: AccountUsageReport, now: number): ProviderCardView { + const parts = [ + r.account?.planType ? capitalize(r.account.planType) : null, + r.account?.email ?? null, + ].filter((p): p is string => p !== null && p !== ""); + let creditNote: string | null = null; + if (r.creditOverage) { + creditNote = "Plan allowance used — requests are being funded by credits."; + } else if (r.limitReached === true && r.canServe === false) { + creditNote = "Plan limit reached — requests are blocked until reset."; + } + return { + provider: r.provider, + label: r.label, + subtitle: parts.length > 0 ? parts.join(" · ") : null, + state: r.state, + stateBadge: r.state === "fresh" ? null : r.state.replace("-", " "), + message: stateMessage(r), + bars: r.limits.map((l) => viewUsageBar(l, now)), + ageLabel: r.state === "fresh" || r.state === "stale" ? formatAge(r.fetchedAt, now) : null, + creditNote, + daily: r.dailyCredits ? viewDailyCredits(r.dailyCredits) : null, + }; +} + +export function viewProviderCards( + usage: AccountUsageResponse, + now: number, +): readonly ProviderCardView[] { + return usage.providers.map((p) => viewProviderCard(p, now)); +} diff --git a/src/features/account-usage/ui/AccountUsageView.svelte b/src/features/account-usage/ui/AccountUsageView.svelte new file mode 100644 index 0000000..2a7a589 --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.svelte @@ -0,0 +1,155 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { + AccountUsageReport, + AccountUsageResponse, + LoadAccountUsage, + SaveAccountUsageSettings, + } from "../logic/types"; + import { viewProviderCards } from "../logic/view-model"; + import DailyCreditsSettings from "./DailyCreditsSettings.svelte"; + import UsageBar from "./UsageBar.svelte"; + + let { + load, + saveSettings, + now = () => Date.now(), + pollMs = 60_000, + }: { + load: LoadAccountUsage; + /** Save per-provider settings (Hyper allowance + reset time). */ + saveSettings: SaveAccountUsageSettings; + /** Injected clock (tests). */ + now?: () => number; + /** How often to re-read `GET /usage` (served from the backend cache). */ + pollMs?: number; + } = $props(); + + let usage = $state<AccountUsageResponse | null>(null); + let error = $state<string | null>(null); + let refreshing = $state(false); + let inFlight = false; + /** Ticks so the pace marker + "resets in" labels advance between polls. */ + let clock = $state(untrack(() => now())); + + const cards = $derived(usage ? viewProviderCards(usage, clock) : []); + + async function refresh(force: boolean): Promise<void> { + if (inFlight) return; + inFlight = true; + if (force) refreshing = true; + const result = await load(force ? { refresh: true } : undefined); + inFlight = false; + refreshing = false; + clock = now(); + if (result.ok) { + usage = result.usage; + error = null; + } else { + error = result.unavailable + ? "Account usage is unavailable (backend extension not loaded)." + : result.error; + } + } + + /** Splice a provider's refreshed report (from a settings save) into the view. */ + function replaceReport(report: AccountUsageReport): void { + clock = now(); + if (!usage) return; + usage = { + ...usage, + providers: usage.providers.map((p) => (p.provider === report.provider ? report : p)), + }; + } + + $effect(() => { + const interval = untrack(() => pollMs); + untrack(() => void refresh(false)); + const poll = setInterval(() => void refresh(false), interval); + const tick = setInterval(() => { + clock = now(); + }, 30_000); + return () => { + clearInterval(poll); + clearInterval(tick); + }; + }); +</script> + +<div class="flex flex-col gap-3"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Subscription usage</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => void refresh(true)} + disabled={refreshing} + aria-label="Refresh account usage" + > + {#if refreshing}<span class="loading loading-spinner loading-xs"></span>{/if} + Refresh + </button> + </div> + + {#if error} + <p class="text-xs text-error">{error}</p> + {/if} + + {#if usage === null && error === null} + <p class="text-xs opacity-60">Loading…</p> + {/if} + + {#each cards as card (card.provider)} + <section class="flex flex-col gap-2 rounded-box bg-base-200 p-2" aria-label="{card.label} usage"> + <div class="flex items-start justify-between gap-2"> + <div class="flex min-w-0 flex-col"> + <span class="text-sm font-semibold">{card.label}</span> + {#if card.subtitle} + <span class="truncate text-[11px] opacity-60">{card.subtitle}</span> + {/if} + </div> + <div class="flex shrink-0 flex-col items-end gap-0.5"> + {#if card.stateBadge} + <span + class="badge badge-xs" + class:badge-warning={card.state === "stale"} + class:badge-error={card.state === "unavailable"} + class:badge-ghost={card.state === "unsupported" || card.state === "not-configured"} + >{card.stateBadge}</span + > + {/if} + {#if card.ageLabel} + <span class="text-[11px] opacity-50">updated {card.ageLabel}</span> + {/if} + </div> + </div> + + {#if card.message} + <p class="text-xs opacity-70">{card.message}</p> + {/if} + {#if card.creditNote} + <p class="text-xs text-warning">{card.creditNote}</p> + {/if} + + {#each card.bars as bar (bar.id)} + <UsageBar {bar} /> + {/each} + + {#if card.daily} + <DailyCreditsSettings + daily={card.daily} + save={saveSettings} + onSaved={replaceReport} + {now} + /> + {/if} + </section> + {/each} + + {#if cards.some((c) => c.bars.some((b) => b.pacePercent !== null))} + <p class="flex items-center gap-1.5 text-[11px] opacity-50"> + <span class="inline-block size-2 rounded-full bg-base-content"></span> + on-track pace — usage that would exactly use the allowance by its reset + </p> + {/if} +</div> diff --git a/src/features/account-usage/ui/AccountUsageView.test.ts b/src/features/account-usage/ui/AccountUsageView.test.ts new file mode 100644 index 0000000..ec1e51b --- /dev/null +++ b/src/features/account-usage/ui/AccountUsageView.test.ts @@ -0,0 +1,184 @@ +import type { AccountUsageResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { + AccountUsageResult, + AccountUsageSettingsResult, + SaveAccountUsageSettings, +} from "../logic/types"; +import AccountUsageView from "./AccountUsageView.svelte"; + +const NOW = 1_760_000_000_000; + +const noSave: SaveAccountUsageSettings = async () => ({ ok: false, error: "unused" }); +const DAY = 86_400_000; + +const usage: AccountUsageResponse = { + servedAt: NOW, + providers: [ + { + provider: "claude", + label: "Claude", + state: "fresh", + account: { accountId: "a", email: "[email protected]", planType: "max" }, + limits: [ + { + id: "claude:7d", + label: "7 Day", + kind: "window", + windowId: "7d", + shared: true, + tier: null, + durationMs: 28 * DAY, + resetsAt: NOW + 14 * DAY, + unit: "percent", + used: 30, + limit: 100, + remaining: 70, + usedFraction: 0.3, + remainingFraction: 0.7, + status: "ok", + }, + ], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: NOW, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 300_000, + error: null, + }, + { + provider: "codex", + label: "Codex", + state: "not-configured", + account: null, + limits: [], + limitReached: null, + canServe: null, + creditOverage: false, + credits: null, + dailyCredits: null, + fetchedAt: null, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 30_000, + error: "No Codex OAuth credentials", + }, + ], +}; + +describe("AccountUsageView", () => { + it("renders a daisyUI progress bar with a pace marker at the elapsed position", async () => { + const calls: Array<{ refresh?: boolean } | undefined> = []; + const load = async (opts?: { refresh?: boolean }): Promise<AccountUsageResult> => { + calls.push(opts); + return { ok: true, usage }; + }; + render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW }); + const bar = await screen.findByLabelText("7 Day usage"); + expect(bar.tagName).toBe("PROGRESS"); + expect(bar.classList.contains("progress")).toBe(true); + expect(bar.getAttribute("value")).toBe("30"); + const marker = screen.getByTestId("pace-marker"); + expect(marker.getAttribute("style")).toContain("left: 50%"); + expect(screen.getByText("20% under pace")).toBeTruthy(); + expect(screen.getByText(/Not signed in to Codex/)).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "Refresh account usage" })); + expect(calls.at(-1)).toEqual({ refresh: true }); + }); + + it("shows the unavailable message on 503", async () => { + const load = async (): Promise<AccountUsageResult> => ({ + ok: false, + error: "x", + unavailable: true, + }); + render(AccountUsageView, { load, saveSettings: noSave, now: () => NOW }); + expect(await screen.findByText(/backend extension not loaded/)).toBeTruthy(); + }); + + it("edits the Hyper allowance + reset time and splices the saved report", async () => { + const hyper = { + provider: "hyper" as const, + label: "Hyper", + state: "fresh" as const, + account: { accountId: null, email: null, planType: null }, + limits: [ + { + id: "hyper:daily", + label: "Daily credits", + kind: "window" as const, + windowId: "1d", + shared: true, + tier: null, + durationMs: DAY, + resetsAt: null, + unit: "credits" as const, + used: 150, + limit: 250, + remaining: 100, + usedFraction: 0.6, + remainingFraction: 0.4, + status: "ok" as const, + }, + ], + limitReached: null, + canServe: true, + creditOverage: false, + credits: null, + dailyCredits: { balance: 100, allowance: 250, resetAnchor: null, resetSource: null }, + fetchedAt: NOW, + lastAttemptAt: NOW, + nextRefreshAt: NOW + 300_000, + error: null, + }; + const load = async (): Promise<AccountUsageResult> => ({ + ok: true, + usage: { servedAt: NOW, providers: [hyper] }, + }); + const saved: Array<Parameters<SaveAccountUsageSettings>[1]> = []; + const saveSettings: SaveAccountUsageSettings = async (_p, settings) => { + saved.push(settings); + const r: AccountUsageSettingsResult = { + ok: true, + report: { + ...hyper, + limits: [ + { + ...(hyper.limits[0] as (typeof hyper.limits)[0]), + limit: 300, + resetsAt: NOW + DAY / 2, + }, + ], + dailyCredits: { + balance: 100, + allowance: 300, + resetAnchor: NOW - DAY / 2, + resetSource: "manual", + }, + }, + }; + return r; + }; + render(AccountUsageView, { load, saveSettings, now: () => NOW }); + expect(await screen.findByText("150 / 250")).toBeTruthy(); + expect(screen.queryByTestId("pace-marker")).toBeNull(); + + await userEvent.click(screen.getByRole("button", { name: "Edit Hyper credit settings" })); + const allowance = screen.getByLabelText("Daily credit allowance"); + await userEvent.clear(allowance); + await userEvent.type(allowance, "300"); + const time = screen.getByLabelText("Daily credit reset time") as HTMLInputElement; + await userEvent.type(time, "09:00"); + await userEvent.click(screen.getByRole("button", { name: "Save Hyper credit settings" })); + + expect(saved[0]?.dailyAllowance).toBe(300); + expect(typeof saved[0]?.resetAnchor).toBe("number"); + expect(await screen.findByText(/set manually/)).toBeTruthy(); + expect(screen.getByTestId("pace-marker")).toBeTruthy(); + }); +}); diff --git a/src/features/account-usage/ui/DailyCreditsSettings.svelte b/src/features/account-usage/ui/DailyCreditsSettings.svelte new file mode 100644 index 0000000..7ed710a --- /dev/null +++ b/src/features/account-usage/ui/DailyCreditsSettings.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { AccountUsageReport, SaveAccountUsageSettings } from "../logic/types"; + import { + anchorFromTimeOfDay, + type DailyCreditsView, + parseAllowanceInput, + } from "../logic/view-model"; + + let { + daily, + save, + onSaved, + now = () => Date.now(), + }: { + daily: DailyCreditsView; + save: SaveAccountUsageSettings; + /** Receives the refreshed report after a successful save. */ + onSaved: (report: AccountUsageReport) => void; + now?: () => number; + } = $props(); + + let editing = $state(false); + let allowanceInput = $state(""); + let timeInput = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + + const parsedAllowance = $derived(parseAllowanceInput(allowanceInput)); + const timeValid = $derived(timeInput === "" || anchorFromTimeOfDay(timeInput, now()) !== null); + + function startEdit(): void { + untrack(() => { + allowanceInput = String(daily.allowance); + timeInput = daily.resetTime; + }); + error = null; + editing = true; + } + + async function submit(): Promise<void> { + if (parsedAllowance === null || !timeValid) return; + saving = true; + error = null; + // Empty time clears a MANUAL anchor (a detected one may still apply). + const resetAnchor = timeInput === "" ? null : anchorFromTimeOfDay(timeInput, now()); + const result = await save("hyper", { dailyAllowance: parsedAllowance, resetAnchor }); + saving = false; + if (result.ok) { + editing = false; + onSaved(result.report); + } else { + error = result.error; + } + } +</script> + +<div class="flex flex-col gap-1 text-[11px]"> + <div class="flex items-center justify-between gap-2"> + <span class="opacity-70">{daily.balanceLabel}</span> + {#if !editing} + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={startEdit} + aria-label="Edit Hyper credit settings">Edit</button + > + {/if} + </div> + {#if !editing} + <span class="opacity-60"> + Daily allowance {daily.allowance} · resets {daily.resetTime || "—"} ({daily.resetSourceLabel}) + </span> + {:else} + <form + class="flex flex-col gap-1" + onsubmit={(e) => { + e.preventDefault(); + void submit(); + }} + > + <label class="flex items-center justify-between gap-2"> + <span>Daily allowance</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + class:input-error={parsedAllowance === null} + aria-label="Daily credit allowance" + bind:value={allowanceInput} + disabled={saving} + /> + </label> + <label class="flex items-center justify-between gap-2"> + <span>Reset time (local)</span> + <input + type="time" + class="input input-bordered input-xs w-24 font-mono" + class:input-error={!timeValid} + aria-label="Daily credit reset time" + bind:value={timeInput} + disabled={saving} + /> + </label> + <span class="opacity-50">Leave the time empty to use the auto-detected refill time.</span> + {#if error}<span class="text-error">{error}</span>{/if} + <div class="flex justify-end gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => (editing = false)} + disabled={saving}>Cancel</button + > + <button + type="submit" + class="btn btn-primary btn-xs" + disabled={saving || parsedAllowance === null || !timeValid} + aria-label="Save Hyper credit settings" + > + {#if saving}<span class="loading loading-spinner loading-xs"></span>{/if} + Save + </button> + </div> + </form> + {/if} +</div> diff --git a/src/features/account-usage/ui/UsageBar.svelte b/src/features/account-usage/ui/UsageBar.svelte new file mode 100644 index 0000000..f4298c1 --- /dev/null +++ b/src/features/account-usage/ui/UsageBar.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import type { UsageBarView } from "../logic/view-model"; + + let { bar }: { bar: UsageBarView } = $props(); + + const progressClass = $derived( + { + success: "progress-success", + warning: "progress-warning", + error: "progress-error", + neutral: "", + }[bar.tone], + ); +</script> + +<div class="flex flex-col gap-0.5" data-testid="usage-bar" data-limit-id={bar.id}> + <div class="flex items-baseline justify-between gap-2 text-xs"> + <span class="truncate font-medium">{bar.label}</span> + <span class="shrink-0 font-mono tabular-nums">{bar.valueLabel}</span> + </div> + <div class="relative flex items-center"> + <progress + class="progress h-2 w-full {progressClass}" + value={bar.percent} + max="100" + aria-label="{bar.label} usage" + ></progress> + {#if bar.pacePercent !== null} + <!-- Pace marker: where usage would be if spread evenly to the reset. --> + <span + class="pointer-events-none absolute top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-base-100 bg-base-content" + style="left: {bar.pacePercent}%" + data-testid="pace-marker" + title="On-track pace: {Math.round(bar.pacePercent)}%" + aria-hidden="true" + ></span> + {/if} + </div> + {#if bar.resetLabel || bar.paceLabel} + <div class="flex justify-between gap-2 text-[11px] opacity-60"> + <span>{bar.resetLabel ?? ""}</span> + {#if bar.paceLabel} + <span class:text-warning={bar.aheadOfPace} class:opacity-100={bar.aheadOfPace} + >{bar.paceLabel}</span + > + {/if} + </div> + {/if} +</div> diff --git a/src/features/chat/adapter/image-encode.test.ts b/src/features/chat/adapter/image-encode.test.ts new file mode 100644 index 0000000..dbaa8ae --- /dev/null +++ b/src/features/chat/adapter/image-encode.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { convertToJpeg } from "./image-encode"; + +describe("convertToJpeg (canvas edge — real environment, no mocks)", () => { + it("skips non-convertible formats without touching the canvas", async () => { + const jpeg = new File([new Uint8Array([0xff, 0xd8])], "photo.jpg", { type: "image/jpeg" }); + await expect(convertToJpeg(jpeg)).resolves.toBe(null); + const gif = new File([new Uint8Array([0x47])], "anim.gif", { type: "image/gif" }); + await expect(convertToJpeg(gif)).resolves.toBe(null); + const unnamed = new File([new Uint8Array([0x00])], "blob", { type: "" }); + await expect(convertToJpeg(unnamed)).resolves.toBe(null); + }); + + it("falls back to null on decode failure (no real canvas in this environment)", async () => { + const png = new File([new Uint8Array([0x89, 0x50])], "shot.png", { type: "image/png" }); + // jsdom has no `createImageBitmap` → the decode throws → the failure path + // must resolve to `null` (never reject), so sending is never blocked. + await expect(convertToJpeg(png)).resolves.toBe(null); + }); +}); diff --git a/src/features/chat/adapter/image-encode.ts b/src/features/chat/adapter/image-encode.ts new file mode 100644 index 0000000..4570a5a --- /dev/null +++ b/src/features/chat/adapter/image-encode.ts @@ -0,0 +1,67 @@ +import { + computeResizedDimensions, + JPEG_QUALITY, + shouldConvertImage, + useConvertedImage, +} from "../logic/image-compress"; + +/** A successfully converted image: a JPEG blob at `JPEG_QUALITY`. */ +export interface ConvertedJpeg { + readonly blob: Blob; + readonly mimeType: "image/jpeg"; +} + +/** + * The injected browser edge for attach-time compression: a `File` in, a + * (maybe) smaller JPEG out. Returns `null` when the source is not converted + * (non-convertible format) or the conversion is skipped/failed (decode or + * encode error, smaller-wins rule) — the caller then sends the original + * bytes. Compression must never block or break sending, so every failure is + * swallowed into `null`. + */ +export type ImageJpegEncoder = (file: File) => Promise<ConvertedJpeg | null>; + +/** + * Canvas-based JPEG re-encode: decode the source, rescale to cap the longest + * edge at `MAX_IMAGE_EDGE_PX` (never upscale, high-quality smoothing), flatten + * any transparency onto a white background (JPEG has no alpha), and encode at + * `JPEG_QUALITY`. Applied only to convertible (lossless) sources; the result + * is used only under the smaller-wins rule. + * + * Thin by design — every decision comes from `../logic/image-compress`. + */ +export const convertToJpeg: ImageJpegEncoder = async (file: File) => { + try { + if (!shouldConvertImage(file.type)) return null; + + const bitmap = await createImageBitmap(file); + const target = computeResizedDimensions(bitmap.width, bitmap.height); + + const canvas = document.createElement("canvas"); + canvas.width = target.width; + canvas.height = target.height; + const ctx = canvas.getContext("2d"); + if (ctx === null) { + bitmap.close(); + return null; + } + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "high"; + // Flatten transparency: JPEG has no alpha channel. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, target.width, target.height); + ctx.drawImage(bitmap, 0, 0, target.width, target.height); + bitmap.close(); + + const blob = await new Promise<Blob | null>((resolve) => { + canvas.toBlob(resolve, "image/jpeg", JPEG_QUALITY); + }); + if (blob === null) return null; + // Smaller-wins: keep the original when the re-encode didn't actually help. + if (!useConvertedImage(file.size, blob.size)) return null; + return { blob, mimeType: "image/jpeg" }; + } catch { + // Decode/encode failure → the original image is sent untouched. + return null; + } +}; diff --git a/src/features/chat/adapter/pending-inputs.ts b/src/features/chat/adapter/pending-inputs.ts new file mode 100644 index 0000000..5798fdb --- /dev/null +++ b/src/features/chat/adapter/pending-inputs.ts @@ -0,0 +1,49 @@ +import type { PendingInput, PendingInputStorage } from "../pending-inputs"; + +export function createPendingInputStorage( + storage: Storage, + conversationId: string, +): PendingInputStorage { + const prefix = `dispatch.pending-inputs:${conversationId}:`; + let known = new Map<string, string>(); + return { + load() { + const inputs: PendingInput[] = []; + const loaded = new Map<string, string>(); + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key === null || !key.startsWith(prefix)) continue; + const raw = storage.getItem(key); + if (raw === null) continue; + const input = JSON.parse(raw); + if ( + (input?.request?.type !== "chat.send" && input?.request?.type !== "chat.queue") || + input.request.conversationId !== conversationId || + typeof input.request.requestId !== "string" || + key !== prefix + input.request.requestId || + (input.request.type === "chat.send" + ? typeof input.request.message !== "string" + : typeof input.request.text !== "string") || + (input.rejection !== undefined && typeof input.rejection !== "string") + ) + throw new Error("Invalid saved input"); + inputs.push(input); + loaded.set(input.request.requestId, raw); + } + known = loaded; + return inputs; + }, + save(inputs) { + // Per-request keys prevent another browser tab's send/receipt from overwriting ours. + // Quota/security failures reach the composer BEFORE it clears its staged input. + const next = new Map(inputs.map((input) => [input.request.requestId, JSON.stringify(input)])); + for (const [id, raw] of next) { + if (known.get(id) !== raw) storage.setItem(prefix + id, raw); + } + for (const id of known.keys()) { + if (!next.has(id)) storage.removeItem(prefix + id); + } + known = next; + }, + }; +} diff --git a/src/features/chat/delivery.test.ts b/src/features/chat/delivery.test.ts new file mode 100644 index 0000000..99ad2d4 --- /dev/null +++ b/src/features/chat/delivery.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import { createPendingInputStorage } from "./adapter/pending-inputs"; +import { createChatStore } from "./store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; + +function storage(): Storage { + const values = new Map<string, string>(); + return { + get length() { + return values.size; + }, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { + values.set(key, value); + }, + removeItem: (key) => { + values.delete(key); + }, + clear: () => values.clear(), + key: (index) => [...values.keys()][index] ?? null, + }; +} + +function fixture(saved = storage(), metricsSync = createFakeMetricsSync().impl) { + const transport = createFakeTransport(); + let id = 0; + const store = createChatStore({ + conversationId: "c", + createRequestId: () => `request-${++id}`, + pendingInputStorage: createPendingInputStorage(saved, "c"), + transport: transport.impl, + historySync: createFakeHistorySync().impl, + metricsSync, + cache: createFakeCache().impl, + }); + return { store, transport, saved }; +} + +const images = [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }]; + +describe("input delivery", () => { + it.each([ + "send", + "queueMessage", + ] as const)("saves %s input through refresh without automatically retransmitting", (method) => { + const f = fixture(); + f.store[method]("keep me", images); + expect(f.store.pendingInputs).toHaveLength(1); + f.store.dispose(); + const reloaded = fixture(f.saved); + expect(reloaded.store.pendingInputs).toMatchObject([{ request: { images } }]); + expect(JSON.stringify(reloaded.store.pendingInputs)).toContain("keep me"); + expect(reloaded.transport.sent).toEqual([]); + expect(reloaded.transport.sentQueue).toEqual([]); + reloaded.store.dispose(); + }); + + it("streamed user echoes and wrong receipts cannot discard an unconfirmed send", () => { + const f = fixture(); + f.store.send("mine", images); + f.store.handleDelta({ + type: "chat.delta", + event: { type: "user-message", conversationId: "c", turnId: "t", text: "mine" }, + }); + f.store.handleDelta({ type: "chat.accepted", conversationId: "other", requestId: "request-1" }); + f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "other" }); + expect(f.store.pendingInputs).toHaveLength(1); + f.store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "request-1" }); + expect(f.store.pendingInputs).toEqual([]); + expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]); + f.store.dispose(); + }); + + it("retains explicitly rejected input and images for recovery until discarded", () => { + const f = fixture(); + f.store.send("mine", images); + f.store.handleDelta({ + type: "chat.error", + conversationId: "c", + requestId: "request-1", + message: "disk full", + }); + const reloaded = fixture(f.saved); + expect(reloaded.store.pendingInputs).toMatchObject([ + { rejection: "disk full", request: { message: "mine", images } }, + ]); + reloaded.store.discardPendingInput("request-1"); + expect(createPendingInputStorage(f.saved, "c").load()).toEqual([]); + f.store.dispose(); + reloaded.store.dispose(); + }); + + it.each([ + "send", + "queueMessage", + ] as const)("does not clear or transmit %s input if saving fails", (method) => { + const saved = storage(); + saved.setItem = () => { + throw new Error("quota exceeded"); + }; + const f = fixture(saved); + expect(() => f.store[method]("mine", images)).toThrow("quota exceeded"); + expect(f.store.pendingInputs).toEqual([]); + expect(f.store.chunks).toEqual([]); + expect(f.transport.sent).toEqual([]); + expect(f.transport.sentQueue).toEqual([]); + f.store.dispose(); + }); + + it("coalesces a reconnect burst and permits one metrics catch-up after completion", async () => { + let resolve!: (value: { turns: [] }) => void; + const first = { + promise: new Promise<{ turns: [] }>((done) => { + resolve = done; + }), + resolve: (value: { turns: [] }) => resolve(value), + }; + const metricsSync = vi.fn().mockReturnValueOnce(first.promise).mockResolvedValue({ turns: [] }); + const f = fixture(storage(), metricsSync); + for (let i = 0; i < 100; i++) f.store.resync(); + expect(metricsSync).toHaveBeenCalledTimes(1); + first.resolve({ turns: [] }); + await vi.waitFor(() => expect(metricsSync).toHaveBeenCalledTimes(2)); + f.store.dispose(); + }); +}); + +it("concurrent browser tabs cannot overwrite each other's saved messages or receipts", () => { + const saved = storage(); + const first = createPendingInputStorage(saved, "c"); + const second = createPendingInputStorage(saved, "c"); + first.load(); + second.load(); + const input = (requestId: string) => ({ + request: { type: "chat.send" as const, conversationId: "c", requestId, message: requestId }, + }); + first.save([input("first")]); + second.save([input("second")]); + first.save([]); + expect(createPendingInputStorage(saved, "c").load()).toEqual([input("second")]); +}); diff --git a/src/features/chat/index.ts b/src/features/chat/index.ts index cf57cea..41cb06f 100644 --- a/src/features/chat/index.ts +++ b/src/features/chat/index.ts @@ -2,23 +2,50 @@ export type { ProviderRetryView, RenderedChunk, RenderGroup, - ToolBatchEntry, + ToolEntry, } from "../../core/chunks"; export { groupRenderedChunks, resolveImageUrl, viewProviderRetry } from "../../core/chunks"; export type { TurnMetricsEntry } from "../../core/metrics"; -export { isVisionModel } from "./model-select"; +export { createPendingInputStorage } from "./adapter/pending-inputs"; +export type { NamedAgentLike, NamedAgentSnapshotLike } from "./model-select"; +export { + CLAUDE_SUBSCRIPTION_KEY, + isClaudeSubscriptionModel, + isNamedAgentSelection, + isNumberedOpenCode, + isVisionModel, + migrateLegacyModelName, + modelKeyLabel, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, + openCodeNumber, + providerKeysWithNamedAgents, +} from "./model-select"; +export type { PendingInput, PendingInputStorage } from "./pending-inputs"; export type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; export type { EffortOption, ReasoningEffortSaveResult, SaveReasoningEffort, + SaveThinkingSelection, + SelectionOption, + SetThinkingRequest, + ThinkingResponse, + ThinkingSaveResult, + ThinkingSelection, + ThinkingSelectionSaveResult, } from "./reasoning-effort"; export { DEFAULT_REASONING_EFFORT, effectiveEffort, + effectiveSelection, effortOptions, isReasoningEffort, + isThinkingSelection, REASONING_EFFORT_LEVELS, + selectionOptions, } from "./reasoning-effort"; export type { ChatStore, ChatStoreDependencies } from "./store.svelte"; export { createChatStore } from "./store.svelte"; @@ -28,6 +55,7 @@ export { default as CompactionView } from "./ui/CompactionView.svelte"; export type { ComposerStatus } from "./ui/Composer.svelte"; export { default as Composer } from "./ui/Composer.svelte"; export { default as ModelSelector } from "./ui/ModelSelector.svelte"; +export { default as PendingInputs } from "./ui/PendingInputs.svelte"; export { default as ReasoningEffortSelector } from "./ui/ReasoningEffortSelector.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/chat/logic/composer.test.ts b/src/features/chat/logic/composer.test.ts new file mode 100644 index 0000000..86e9dc4 --- /dev/null +++ b/src/features/chat/logic/composer.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { stopButtonLabels } from "./composer"; + +describe("stopButtonLabels", () => { + it.each([undefined, 0])("preserves plain Stop with queued count %s", (count) => { + expect(stopButtonLabels(count)).toEqual({ + text: "Stop", + ariaLabel: "Stop generation", + title: undefined, + }); + }); + + it.each([1, 2])("labels the queued Stop action Flush with queued count %s", (count) => { + expect(stopButtonLabels(count)).toEqual({ + text: "Flush", + ariaLabel: "Flush", + title: "Stop the current response and start the queued message now", + }); + }); +}); diff --git a/src/features/chat/logic/composer.ts b/src/features/chat/logic/composer.ts new file mode 100644 index 0000000..e56a007 --- /dev/null +++ b/src/features/chat/logic/composer.ts @@ -0,0 +1,11 @@ +/** Stop still uses the same action; the backend carries queued input into a new turn. */ +export function stopButtonLabels(queuedMessageCount: number | undefined) { + if (queuedMessageCount !== undefined && queuedMessageCount >= 1) { + return { + text: "Flush", + ariaLabel: "Flush", + title: "Stop the current response and start the queued message now", + }; + } + return { text: "Stop", ariaLabel: "Stop generation", title: undefined }; +} diff --git a/src/features/chat/logic/image-compress.test.ts b/src/features/chat/logic/image-compress.test.ts new file mode 100644 index 0000000..88726bf --- /dev/null +++ b/src/features/chat/logic/image-compress.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { + computeResizedDimensions, + MAX_IMAGE_EDGE_PX, + shouldConvertImage, + useConvertedImage, +} from "./image-compress"; + +describe("shouldConvertImage", () => { + it("converts lossless sources (PNG, BMP)", () => { + expect(shouldConvertImage("image/png")).toBe(true); + expect(shouldConvertImage("image/bmp")).toBe(true); + }); + + it("skips already-lossy and animated formats", () => { + expect(shouldConvertImage("image/jpeg")).toBe(false); + expect(shouldConvertImage("image/webp")).toBe(false); + expect(shouldConvertImage("image/gif")).toBe(false); + }); + + it("leaves unknown or absent MIME types alone", () => { + expect(shouldConvertImage(undefined)).toBe(false); + expect(shouldConvertImage("")).toBe(false); + expect(shouldConvertImage("image/tiff")).toBe(false); + expect(shouldConvertImage("image/svg+xml")).toBe(false); + }); +}); + +describe("computeResizedDimensions", () => { + it("never upscales a source already within the cap", () => { + expect(computeResizedDimensions(100, 50)).toEqual({ width: 100, height: 50 }); + expect(computeResizedDimensions(2048, 1024)).toEqual({ width: 2048, height: 1024 }); + expect(computeResizedDimensions(640, 640)).toEqual({ width: 640, height: 640 }); + }); + + it("caps the longest edge at 2048", () => { + expect(computeResizedDimensions(4096, 2048)).toEqual({ width: 2048, height: 1024 }); + expect(computeResizedDimensions(8192, 4096)).toEqual({ width: 2048, height: 1024 }); + }); + + it("caps the longest edge regardless of orientation (portrait)", () => { + expect(computeResizedDimensions(2000, 3000)).toEqual({ width: 1365, height: 2048 }); + }); + + it("preserves aspect ratio on the rescale", () => { + const { width, height } = computeResizedDimensions(3000, 2000); + expect(width).toBe(2048); + // 2000 * (2048/3000) = 1365.33… → 1365 + expect(height).toBe(1365); + expect(width / height).toBeCloseTo(3000 / 2000, 2); + }); + + it("handles extreme aspect ratios without exceeding the cap", () => { + expect(computeResizedDimensions(5000, 10)).toEqual({ width: 2048, height: 4 }); + expect(computeResizedDimensions(10, 5000)).toEqual({ width: 4, height: 2048 }); + }); + + it("honors a custom cap", () => { + expect(computeResizedDimensions(1000, 500, 250)).toEqual({ width: 250, height: 125 }); + }); + + it("uses the default 2048px cap constant", () => { + expect(MAX_IMAGE_EDGE_PX).toBe(2048); + }); +}); + +describe("useConvertedImage", () => { + it("uses the conversion only when it is strictly fewer bytes", () => { + expect(useConvertedImage(3_000_000, 300_000)).toBe(true); + }); + + it("keeps the original when equal or larger", () => { + expect(useConvertedImage(300_000, 300_000)).toBe(false); + expect(useConvertedImage(100_000, 300_000)).toBe(false); + }); +}); diff --git a/src/features/chat/logic/image-compress.ts b/src/features/chat/logic/image-compress.ts new file mode 100644 index 0000000..a9037b8 --- /dev/null +++ b/src/features/chat/logic/image-compress.ts @@ -0,0 +1,77 @@ +/** + * Pure decision logic for attach-time image compression (the `Compress` + * toggle on the composer's staged-image thumbnails). + * + * The pipeline (re-encode to JPEG + rescale) runs at the browser edge + * (`../adapter/image-encode`); this module owns every DECISION so it stays + * pure `input → output` and unit-testable without a DOM: + * + * - `shouldConvertImage` — which source formats get converted. + * - `computeResizedDimensions` — the rescale math (cap the longest edge, + * never upscale, preserve aspect). + * - `useConvertedImage` — the smaller-wins rule. + */ + +/** JPEG encode quality for the canvas re-encode. */ +export const JPEG_QUALITY = 0.85; + +/** Longest-edge cap (px) for the rescale. Never upscale past the source. */ +export const MAX_IMAGE_EDGE_PX = 2048; + +/** + * Source formats that get converted. Only LOSSLESS sources are re-encoded: + * PNG (the screenshot case — multi-MB PNGs are the reason this exists) plus + * BMP (trivially canvas-decodable in every browser). Already-lossy formats + * (JPEG/WebP) and animated ones (GIF) are sent verbatim — recompressing them + * degrades quality and saves nothing, and re-encoding a GIF destroys its + * animation. TIFF is NOT listed: browsers can't natively decode it, so the + * canvas path would always fall back to the original anyway. An oversized + * JPEG stays untouched (the backend's body-budget safety net handles the + * extreme case). + */ +const CONVERTIBLE_MIME_TYPES = new Set(["image/png", "image/bmp"]); + +/** + * Whether a source MIME type is converted to JPEG at staging time. + * Unknown/absent MIME types are left alone (conservative — never gamble on + * bytes we can't classify). + */ +export function shouldConvertImage(mimeType: string | undefined): boolean { + return mimeType !== undefined && CONVERTIBLE_MIME_TYPES.has(mimeType); +} + +/** Pixel dimensions of a raster image. */ +export interface Dimensions { + readonly width: number; + readonly height: number; +} + +/** + * Target dimensions after the rescale: cap the LONGEST edge at `maxEdge` + * (default `MAX_IMAGE_EDGE_PX`), never upscale (a smaller source passes + * through unchanged), and preserve aspect ratio. + * + * Pure math — the canvas draw itself lives at the browser edge. + */ +export function computeResizedDimensions( + width: number, + height: number, + maxEdge: number = MAX_IMAGE_EDGE_PX, +): Dimensions { + const longest = Math.max(width, height); + if (longest <= maxEdge) return { width, height }; + const scale = maxEdge / longest; + // Rounding can never exceed the cap: each scaled edge is ≤ maxEdge before + // rounding, and the only values that round up land exactly ON the cap. + return { width: Math.round(width * scale), height: Math.round(height * scale) }; +} + +/** + * The smaller-wins rule: the converted JPEG is used only when it is actually + * FEWER bytes than the original (equal or larger → keep the original). + * Compared on raw byte counts (File/Blob `size`); base64 inflation affects + * both sides equally so the ratio is preserved. + */ +export function useConvertedImage(originalBytes: number, convertedBytes: number): boolean { + return convertedBytes < originalBytes; +} diff --git a/src/features/chat/logic/tool-summary.test.ts b/src/features/chat/logic/tool-summary.test.ts new file mode 100644 index 0000000..c0caa0d --- /dev/null +++ b/src/features/chat/logic/tool-summary.test.ts @@ -0,0 +1,456 @@ +import { describe, expect, it } from "vitest"; +import type { ToolEntry } from "../../../core/chunks"; +import { toolIcon, toolLabel, viewToolEntry } from "./tool-summary"; + +function entry( + name: string, + input: unknown, + content: string | null = "ok", + isError = false, +): ToolEntry { + return { + toolCallId: "a", + toolName: name, + call: { type: "tool-call", toolCallId: "a", toolName: name, input }, + result: + content === null + ? null + : { type: "tool-result", toolCallId: "a", toolName: name, content, isError }, + }; +} + +const view = (name: string, input: unknown, content: string | null = "ok", isError = false) => + viewToolEntry(entry(name, input, content, isError)); +const lines = (n: number) => Array.from({ length: n }, (_, i) => `line ${i + 1}`); + +describe("tool labels", () => { + it.each([ + ["run_shell", "Run command"], + ["read_file", "Read file"], + ["edit_file", "Edit file"], + ["write_file", "Write file"], + ["web_search", "Web search"], + ["lsp", "Code intelligence"], + ["todo_write", "Update to-dos"], + ["load_skill", "Load skill"], + ["consult_vision", "Ask vision model"], + ["read_image", "Read image"], + ["youtube_transcript", "YouTube transcript"], + ["summon_agent", "Summon agent"], + ["send_agent_message", "Message agent"], + ["read_agent", "Read agent reply"], + ["open_agent", "Open agent"], + ["cancel_agent_message", "Cancel agent message"], + ["list_agents", "List agents"], + ["list_agent_models", "List agent models"], + ])("built-in %s → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + expect(view(name, {}).label).toBe(label); + expect(view(name, {}).name).toBe(name); + }); + it.each([ + ["github__create_issue", "Github: Create issue"], + ["mcp__github__create_issue", "Github: Create issue"], + ["playwright__browserTakeScreenshot", "Playwright: Browser take screenshot"], + ["scaffold_echo", "Scaffold echo"], + ["fetchUrl", "Fetch url"], + ["some-kebab-tool", "Some kebab tool"], + ["toString", "To string"], + ])("unknown %s is humanized → %s", (name, label) => { + expect(toolLabel(name)).toBe(label); + }); +}); + +describe("tool icons", () => { + const builtIns = [ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "read_image", + "youtube_transcript", + "summon_agent", + "send_agent_message", + "read_agent", + "open_agent", + "cancel_agent_message", + "list_agents", + "list_agent_models", + ]; + it("gives every built-in tool its own distinct icon", () => { + const icons = builtIns.map(toolIcon); + expect(icons).not.toContain("tool"); + expect(icons).not.toContain("mcp"); + expect(new Set(icons).size).toBe(builtIns.length); + expect(view("run_shell", {}).icon).toBe("terminal"); + }); + it.each([ + ["github__create_issue", "mcp"], + ["mcp__github__create_issue", "mcp"], + ["scaffold_echo", "tool"], + ["__proto__", "tool"], + ["constructor", "tool"], + ])("%s → %s", (name, icon) => { + expect(toolIcon(name)).toBe(icon); + }); +}); + +describe("tool summaries", () => { + it.each([ + ["run_shell", { command: "bun run test\n --run" }, "bun run test --run"], + ["read_file", { path: "src/a.ts", offset: 5, limit: 80 }, "src/a.ts:5-84"], + ["read_file", { path: "src/a.ts", limit: 12 }, "src/a.ts:1-12"], + ["read_file", { path: "src/a.ts", offset: 10 }, "src/a.ts:10"], + ["read_file", { path: "src/a.ts" }, "src/a.ts"], + ["edit_file", { path: "src/a.ts", oldString: "a", newString: "b" }, "src/a.ts"], + ["write_file", { path: "src/a.ts", content: "data" }, "src/a.ts"], + ["web_search", { query: "svelte 5 runes", url: "ignored" }, "svelte 5 runes"], + ["web_search", { url: "https://example.org", mode: "scrape" }, "https://example.org"], + [ + "lsp", + { operation: "references", path: "src/a.ts", line: 12, character: 3 }, + "references src/a.ts:12:3", + ], + ["lsp", { operation: "diagnostics", path: "src/a.ts" }, "diagnostics src/a.ts"], + [ + "todo_write", + { todos: [{ status: "pending" }, { status: "completed" }, null] }, + "3 tasks · 1 completed", + ], + ["load_skill", { name: "research_methodology" }, "research_methodology"], + [ + "consult_vision", + { question: "What is in this picture?", imageIds: [1] }, + "What is in this picture?", + ], + ["youtube_transcript", { url: "https://youtu.be/abc" }, "https://youtu.be/abc"], + ["summon_agent", { title: "Review", task: "long task", agent: "Opus" }, "Review"], + ["summon_agent", { task: "Review the patch" }, "Review the patch"], + ["summon_agent", { agent: "Opus" }, "Opus"], + ["send_agent_message", { conversationId: "abcd", message: "Continue" }, "abcd"], + ["read_agent", { conversationId: "abcd" }, "abcd"], + ["open_agent", { conversationId: "abcd" }, "abcd"], + ["cancel_agent_message", { conversationId: "abcd", messageId: "m1" }, "abcd"], + ["list_agents", { relationship: "descendants" }, "descendants"], + ["list_agents", { query: "review" }, "review"], + ["list_agent_models", { query: "Opus" }, "Opus"], + ])("%s derives a meaningful call summary from %j", (name, input, expected) => { + expect(view(name, input).callSummary).toBe(expected); + }); + + it("formats edit call badges and old/new details without hiding multiline text", () => { + const input = { path: "a.ts", oldString: "a\nb\n", newString: "x\ny\nz\n" }; + const card = view("edit_file", input); + expect(card.callBadges).toEqual([ + { label: "+3", tone: "success" }, + { label: "−2", tone: "error" }, + ]); + expect(card.callDetail).toEqual({ + kind: "edit", + path: "a.ts", + oldText: input.oldString, + newText: input.newString, + }); + expect(view("edit_file", { path: "a", oldString: "", newString: "x" }).callBadges).toEqual([ + { label: "+1", tone: "success" }, + { label: "−0", tone: "error" }, + ]); + expect(view("edit_file", { path: "a" }).callBadges).toEqual([]); + }); + it("omits edit diffstats after failure while retaining attempted edits in call details", () => { + const input = { path: "src/missing.ts", oldString: "foo", newString: "bar" }; + const card = view("edit_file", input, "Error: oldString not found", true); + expect(card.callBadges).toEqual([]); + expect(card.callDetail).toEqual({ + kind: "edit", + path: input.path, + oldText: "foo", + newText: "bar", + }); + expect(card.resultSummary).toBe("failed"); + }); + it("preserves exact shell commands in the expanded detail", () => { + expect(view("run_shell", { command: "ls\n pwd" }).callDetail).toEqual({ + kind: "command", + text: "ls\n pwd", + }); + }); + it("shows scalar fields and pretty nested JSON", () => { + expect(view("mcp__foo", { path: "a.ts", count: 2, nested: { a: true } }).callDetail).toEqual({ + kind: "fields", + fields: [ + { key: "path", value: "a.ts", multiline: false }, + { key: "count", value: "2", multiline: false }, + { key: "nested", value: '{\n "a": true\n}', multiline: true }, + ], + }); + }); + it("formats short scalar arrays inline, retaining quotes, escapes, nulls and empty arrays", () => { + expect( + view("mcp", { + labels: ["ui", "chat"], + scalars: [1, true, null], + empty: [], + escaped: ["a\nb"], + }).callDetail, + ).toEqual({ + kind: "fields", + fields: [ + { key: "labels", value: '["ui", "chat"]', multiline: false }, + { key: "scalars", value: "[1, true, null]", multiline: false }, + { key: "empty", value: "[]", multiline: false }, + { key: "escaped", value: '["a\\nb"]', multiline: false }, + ], + }); + }); + it("keeps long arrays and nested structures pretty-printed and complete", () => { + const input = { long: ["x".repeat(80)], nested: [{ a: 1 }], mixed: [1, { a: 1 }] }; + const card = view("mcp", input); + if (card.callDetail.kind !== "fields") throw new Error("expected fields"); + expect(card.callDetail.fields.map((field) => field.multiline)).toEqual([true, true, true]); + expect(card.callDetail.fields.map((field) => JSON.parse(field.value))).toEqual( + Object.values(input), + ); + }); + it.each([ + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ])("generic fallback recognizes %s", (key) => { + expect(view("mcp__foo", { [key]: "target" }).callSummary).toBe("target"); + }); + it("generic fallback prefers the first meaningful argument, else readable key: value pairs", () => { + expect(view("mcp__foo", { command: " ", path: "a", query: "b" }).callSummary).toBe("a"); + expect(view("mcp__foo", { a: 1, b: "x" }).callSummary).toBe("a: 1 · b: x"); + // Only nested values → no summary rather than a JSON dump. + expect(view("mcp__foo", { opts: { deep: true } }).callSummary).toBe(""); + }); + it.each([ + "not JSON", + "{bad", + 42, + true, + [1, "a"], + '"hi"', + ])("tolerates malformed/non-object input %j", (input) => { + expect(() => view("read_file", input)).not.toThrow(); + expect(view("read_file", input).callSummary).not.toBe(""); + expect(view("read_file", input).callDetail.kind).toBe("fields"); + }); + it.each([ + null, + undefined, + {}, + [], + "", + " ", + "null", + "{}", + ])("an argument-less call (%j) shows no summary and a 'no arguments' detail — never `{}`", (input) => { + const card = view("list_agent_models", input); + expect(card.callSummary).toBe(""); + expect(card.callDetail).toEqual({ kind: "none" }); + }); + it.each([ + "run_shell", + "read_file", + "edit_file", + "write_file", + "web_search", + "lsp", + "todo_write", + "load_skill", + "consult_vision", + "youtube_transcript", + "summon_agent", + "read_agent", + ])("%s falls back safely for missing arguments", (name) => { + for (const input of [null, undefined, {}]) { + expect(() => view(name, input)).not.toThrow(); + expect(view(name, input).callSummary).toBe(""); + expect(view(name, input).callDetail).toEqual({ kind: "none" }); + } + expect(view(name, "{bad").callSummary).toBe("{bad"); + }); + it("parses JSON-string inputs", () => { + const card = view("run_shell", '{"command":"bun run test"}'); + expect(card.callSummary).toBe("bun run test"); + expect(card.callDetail).toEqual({ kind: "command", text: "bun run test" }); + }); + it("handles unexpected built-in argument types and prototype-looking tool names", () => { + expect(view("todo_write", { todos: "wrong" }).callSummary).toBe("todos: wrong"); + expect(view("run_shell", { command: { nested: true } }).callDetail.kind).toBe("fields"); + expect(view("constructor", { path: "a" }).callSummary).toBe("a"); + expect(view("__proto__", {}).callSummary).toBe(""); + expect(view("__proto__", {}).label).toBe("Proto"); + expect( + view("mcp", JSON.parse('{"__proto__":{"command":"bad"},"path":"good"}')).callSummary, + ).toBe("good"); + }); + it("does not throw on unserializable input", () => { + const cyclic: Record<string, unknown> = {}; + cyclic.self = cyclic; + expect(view("mcp", cyclic).callSummary).toBe(""); + expect(view("mcp", cyclic).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "self", value: "[unserializable input]", multiline: false }], + }); + expect(() => view("mcp", { integer: 1n })).not.toThrow(); + }); + it("rejects ancestor cycles but preserves shared non-cyclic tool values", () => { + const nested: Record<string, unknown> = { name: "nested" }; + const root = { nested }; + nested.parent = root; + expect(view("mcp", { payload: root }).callDetail).toEqual({ + kind: "fields", + fields: [{ key: "payload", value: "[unserializable input]", multiline: false }], + }); + const shared = { name: "shared" }; + const payload = { first: shared, second: shared, values: [shared, shared] }; + const detail = view("mcp", { payload }).callDetail; + expect(detail.kind).toBe("fields"); + if (detail.kind !== "fields") throw new Error("Expected fields"); + expect(detail.fields[0]?.value).toBe(JSON.stringify(payload, null, 2)); + }); + it("caps and whitespace-collapses header strings but retains full detail", () => { + const command = ` echo\n${"x".repeat(300)}`; + const card = view("run_shell", { command }); + expect(card.callSummary.length).toBe(120); + expect(card.callSummary.endsWith("…")).toBe(true); + expect(card.callSummary).not.toMatch(/\n/); + expect(card.callDetail).toEqual({ kind: "command", text: command }); + }); + it("represents pending entries with no response summary/output", () => { + expect(view("run_shell", { command: "sleep 1" }, null)).toMatchObject({ + status: "pending", + resultSummary: null, + output: null, + }); + }); + it("represents orphan results with unknown call details, not pending", () => { + const e = { ...entry("read_file", {}, "data"), call: null }; + expect(viewToolEntry(e)).toMatchObject({ + name: "read_file", + status: "ok", + callSummary: "Call in earlier history", + resultSummary: "1 line", + }); + expect(viewToolEntry(e).callDetail).toEqual({ + kind: "unavailable", + text: "This call is earlier in the conversation. Show earlier messages to see it.", + }); + }); +}); + +describe("tool response facts", () => { + it.each([ + ["run_shell", "one\ntwo\n", false, "2 lines"], + ["run_shell", "done", false, "1 line"], + ["read_file", "1\tfile text\n", false, "1 line"], + ["read_file", "(empty file: a.ts)", false, "empty file"], + ["mcp", "", false, "no output"], + ["mcp", " \n\t", false, "no output"], + ["mcp", "a short response", false, "a short response"], + ["mcp", "A successful response with more than forty characters of text", false, "1 line"], + ["mcp", "x".repeat(200), false, "1 line"], + ["web_search", "a\nb\nc", false, "3 lines"], + ["run_shell", "", true, "failed"], + ["mcp", "Permission denied", true, "failed"], + ["run_shell", "error\ndetail", true, "failed"], + ["run_shell", "partial\n\n[Command timed out]", true, "timed out"], + ["run_shell", "[Output truncated: 12000 bytes]", false, "1 line · truncated"], + ["run_shell", "bad\n[Output truncated: 12000 bytes]", true, "failed · truncated"], + ["edit_file", 'Replaced 1 occurrence(s) in "a.ts".', false, "1 replacement"], + [ + "edit_file", + 'Replaced 3 occurrences in "a.ts".\n\nLSP diagnostics: error at line 2', + false, + "3 replacements · diagnostics", + ], + ["write_file", 'Created "a.ts" (4200 bytes).', false, "created · 4.1 KB"], + ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote · 10 B"], + ["write_file", 'Created "a.ts" (1048576 bytes).', false, "created · 1.0 MB"], + ])("%s summarizes %j (error=%s)", (name, content, isError, expected) => { + const card = view(name, {}, content, isError); + expect(card.resultSummary).toBe(expected); + expect(card.status).toBe(isError ? "error" : "ok"); + expect(card.output?.isError).toBe(isError); + }); + it.each([ + ["run_shell", "oops\ndetails", true, "failed"], + ["run_shell", "partial\n[Command timed out]", true, "timed out"], + ["write_file", 'Created "a.ts" (4200 bytes).', false, "created"], + ["write_file", 'Overwrote "a.ts" (10 bytes).', false, "overwrote"], + ["edit_file", 'Replaced 3 occurrences in "a.ts".\nDiagnostics: warning', false, "replaced"], + ["run_shell", "a\nb\nc\nd", false, "4 lines"], + ["mcp", "Created issue #42", false, "1 line"], + ["mcp", "", false, "no output"], + ["read_file", "(empty file: a.ts)", false, "empty file"], + ])("%s supplies a complete compact response fact", (name, content, isError, expected) => { + expect(view(name, {}, content, isError).compactResultSummary).toBe(expected); + }); + it("never puts error content in header summaries, but retains it verbatim in output", () => { + const content = 'Error: oldString not found in content of "src/missing.ts".'; + const card = view("edit_file", { path: "src/missing.ts" }, content, true); + expect(card.resultSummary).toBe("failed"); + expect(card.compactResultSummary).toBe("failed"); + expect(card.output).toEqual({ kind: "full", lines: [content], isError: true }); + expect(card.callSummary).toBe("src/missing.ts"); + expect(view("mcp", {}, null).compactResultSummary).toBeNull(); + }); + it("does not infer success on an error that resembles a write result", () => { + expect(view("write_file", {}, 'Created "a.ts" (10 bytes).', true).resultSummary).toMatch( + /^failed/, + ); + }); +}); + +describe("tool output elision", () => { + it.each([0, 1, 6, 11, 12])("shows %i lines in full", (n) => { + expect(view("mcp", {}, lines(n).join("\n")).output).toEqual({ + kind: "full", + lines: lines(n), + isError: false, + }); + }); + it.each([ + 13, 14, 100, + ])("shows first/last six of %i lines, retaining everything for expansion", (n) => { + expect(view("mcp", {}, lines(n).join("\n"), true).output).toEqual({ + kind: "elided", + head: lines(n).slice(0, 6), + hiddenCount: n - 12, + tail: lines(n).slice(-6), + all: lines(n), + isError: true, + }); + }); + it("ignores one final newline terminator at the 12/13 boundary (including CRLF)", () => { + expect(view("mcp", {}, `${lines(12).join("\r\n")}\r\n`).output?.kind).toBe("full"); + expect(view("mcp", {}, `${lines(13).join("\n")}\n`).output).toMatchObject({ + kind: "elided", + hiddenCount: 1, + }); + }); + it("preserves meaningful blank lines including an extra trailing blank line", () => { + expect(view("mcp", {}, "a\n\nb\n\n").output).toEqual({ + kind: "full", + lines: ["a", "", "b", ""], + isError: false, + }); + }); +}); diff --git a/src/features/chat/logic/tool-summary.ts b/src/features/chat/logic/tool-summary.ts new file mode 100644 index 0000000..2380adc --- /dev/null +++ b/src/features/chat/logic/tool-summary.ts @@ -0,0 +1,460 @@ +import type { ToolEntry } from "../../../core/chunks"; + +export interface ToolCardView { + /** The raw wire tool name (tooltip / debugging). */ + readonly name: string; + /** English display name, e.g. `run_shell` → "Run command" (see {@link toolLabel}). */ + readonly label: string; + /** Which icon the card shows — a semantic key; the UI maps it to a Phosphor icon. */ + readonly icon: ToolIconKey; + readonly status: "pending" | "ok" | "error"; + readonly callSummary: string; + readonly callBadges: readonly { + readonly label: string; + readonly tone: "success" | "error"; + }[]; + readonly resultSummary: string | null; + readonly compactResultSummary: string | null; + readonly callDetail: + | { readonly kind: "unavailable"; readonly text: string } + /** The call took no arguments (`{}` / absent input) — rendered as a muted note. */ + | { readonly kind: "none" } + | { readonly kind: "command"; readonly text: string } + | { + readonly kind: "edit"; + readonly path: string; + readonly oldText: string; + readonly newText: string; + } + | { + readonly kind: "fields"; + readonly fields: readonly { + readonly key: string; + readonly value: string; + readonly multiline: boolean; + }[]; + }; + readonly output: ToolOutputView | null; +} + +export type ToolOutputView = + | { readonly kind: "full"; readonly lines: readonly string[]; readonly isError: boolean } + | { + readonly kind: "elided"; + readonly head: readonly string[]; + readonly hiddenCount: number; + readonly tail: readonly string[]; + readonly all: readonly string[]; + readonly isError: boolean; + }; + +type Args = Readonly<Record<string, unknown>>; +type CallSummary = (args: Args) => string; + +function oneLine(text: string, cap = 120): string { + const compact = text.replace(/\s+/g, " ").trim(); + return compact.length > cap ? `${compact.slice(0, cap - 1)}…` : compact; +} + +function normalize(input: unknown): unknown { + if (typeof input !== "string") return input; + try { + return JSON.parse(input); + } catch { + return input; + } +} + +function asArgs(input: unknown): Args { + return typeof input === "object" && input !== null && !Array.isArray(input) + ? (input as Args) + : {}; +} + +function serialize(input: unknown, pretty = false): string { + if (typeof input === "string") return input; + try { + // Detect ancestor cycles before the native serializer recurses. Bun's + // pretty JSON cycle detection can otherwise consume seconds of UI time. + // Track ancestors, not every visited object: shared non-cyclic values are valid. + const ancestors: object[] = []; + return ( + JSON.stringify( + input, + function (this: unknown, _key, value: unknown) { + if (value !== null && typeof value === "object") { + while (ancestors.length && ancestors.at(-1) !== this) ancestors.pop(); + if (ancestors.includes(value)) throw new TypeError("Circular tool input"); + ancestors.push(value); + } + return value; + }, + pretty ? 2 : undefined, + ) ?? String(input) + ); + } catch { + return "[unserializable input]"; + } +} + +/** Small scalar arrays are easier to scan inline; nested/long values stay pretty JSON. */ +function fieldValue(value: unknown): string { + if ( + Array.isArray(value) && + value.every((item) => item === null || ["string", "number", "boolean"].includes(typeof item)) + ) { + try { + const inline = `[${value.map((item) => JSON.stringify(item)).join(", ")}]`; + if (inline.length <= 80) return inline; + } catch { + // Non-wire values still use the safe serializer below. + } + } + return serialize(value, true); +} + +function arg(args: Args, ...keys: string[]): string { + for (const key of keys) { + const value = Object.hasOwn(args, key) ? args[key] : undefined; + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + } + return ""; +} + +function lineCount(text: string): number { + return text === "" ? 0 : outputLines(text).length; +} + +/** Drop one final newline terminator, not meaningful blank lines within the output. */ +function outputLines(content: string): string[] { + const normalized = content.replace(/\r\n/g, "\n"); + return normalized === "" ? [] : normalized.replace(/\n$/, "").split("\n"); +} + +function readSummary(args: Args): string { + const path = arg(args, "path"); + const offset = typeof args.offset === "number" && Number.isFinite(args.offset) ? args.offset : 1; + const limit = typeof args.limit === "number" && Number.isFinite(args.limit) ? args.limit : null; + return `${path}${limit !== null ? `:${offset}-${offset + limit - 1}` : args.offset !== undefined ? `:${offset}` : ""}`; +} + +function lspSummary(args: Args): string { + const line = arg(args, "line"); + const character = arg(args, "character"); + return `${arg(args, "operation")} ${arg(args, "path")}${line ? `:${line}${character ? `:${character}` : ""}` : ""}`; +} + +function todoSummary(args: Args): string { + if (!Array.isArray(args.todos)) return ""; + const done = args.todos.filter((t) => asArgs(t).status === "completed").length; + return `${args.todos.length} tasks · ${done} completed`; +} + +const summarizeAgent: CallSummary = (args) => arg(args, "title", "task", "agent", "model"); +const summarizeConversation: CallSummary = (args) => arg(args, "conversationId", "message"); + +/** English names for Dispatch's built-in tools; anything else goes through {@link humanize}. */ +const TOOL_LABELS: Readonly<Record<string, string>> = { + run_shell: "Run command", + read_file: "Read file", + edit_file: "Edit file", + write_file: "Write file", + web_search: "Web search", + lsp: "Code intelligence", + todo_write: "Update to-dos", + load_skill: "Load skill", + consult_vision: "Ask vision model", + read_image: "Read image", + youtube_transcript: "YouTube transcript", + summon_agent: "Summon agent", + send_agent_message: "Message agent", + read_agent: "Read agent reply", + open_agent: "Open agent", + cancel_agent_message: "Cancel agent message", + list_agents: "List agents", + list_agent_models: "List agent models", +}; + +/** + * Semantic icon keys (Svelte-free; `ui/tool-icons.ts` maps each to a Phosphor + * component). Unknown tools get `mcp` when namespaced `<server>__<tool>`, + * else the generic `tool` wrench. + */ +export type ToolIconKey = + | "terminal" + | "file-read" + | "file-edit" + | "file-write" + | "search" + | "code" + | "todos" + | "skill" + | "vision" + | "image" + | "youtube" + | "agent-summon" + | "agent-send" + | "agent-read" + | "agent-open" + | "agent-cancel" + | "agents" + | "models" + | "mcp" + | "tool"; + +const TOOL_ICONS: Readonly<Record<string, ToolIconKey>> = { + run_shell: "terminal", + read_file: "file-read", + edit_file: "file-edit", + write_file: "file-write", + web_search: "search", + lsp: "code", + todo_write: "todos", + load_skill: "skill", + consult_vision: "vision", + read_image: "image", + youtube_transcript: "youtube", + summon_agent: "agent-summon", + send_agent_message: "agent-send", + read_agent: "agent-read", + open_agent: "agent-open", + cancel_agent_message: "agent-cancel", + list_agents: "agents", + list_agent_models: "models", +}; + +/** The icon key for a tool (built-in table, else MCP plug / generic wrench). */ +export function toolIcon(name: string): ToolIconKey { + if (Object.hasOwn(TOOL_ICONS, name)) return TOOL_ICONS[name] ?? "tool"; + return name.split("__").filter((p) => p !== "").length >= 2 ? "mcp" : "tool"; +} + +/** `create_issue` / `createIssue` / `create-issue` → "Create issue". */ +function sentenceCase(raw: string): string { + const words = raw + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[\s_\-.]+/) + .filter((w) => w !== "") + .map((w) => w.toLowerCase()); + if (words.length === 0) return raw; + const text = words.join(" "); + return text.charAt(0).toUpperCase() + text.slice(1); +} + +/** + * Generic English name for an unknown tool. MCP tools arrive namespaced as + * `<serverId>__<toolName>` (backend `mcp/registry.ts`), shown as "Server: Tool name". + */ +function humanize(name: string): string { + const parts = name.split("__").filter((p) => p !== ""); + if (parts[0]?.toLowerCase() === "mcp" && parts.length > 1) parts.shift(); + if (parts.length >= 2) { + const server = sentenceCase(parts[0] ?? ""); + return `${server}: ${sentenceCase(parts.slice(1).join(" "))}`; + } + return sentenceCase(name); +} + +/** The English display name for a tool (built-in table, else humanized). */ +export function toolLabel(name: string): string { + return Object.hasOwn(TOOL_LABELS, name) ? (TOOL_LABELS[name] ?? name) : humanize(name); +} + +/** True for input that carries no arguments at all (absent, `null`, `{}`, `[]`, blank string). */ +function isEmptyInput(input: unknown): boolean { + if (input === undefined || input === null) return true; + if (typeof input === "string") return input.trim() === ""; + if (Array.isArray(input)) return input.length === 0; + return typeof input === "object" && Object.keys(input).length === 0; +} + +/** Fallback summary: scalar args as `key: value` (never raw JSON braces). */ +function scalarPairs(args: Args): string { + return Object.entries(args) + .filter( + ([, v]) => ["string", "number", "boolean"].includes(typeof v) && String(v).trim() !== "", + ) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(" · "); +} + +/** Optional built-in polish; all unknown/MCP names use the same generic path. */ +const summaries: Readonly<Record<string, CallSummary>> = { + run_shell: (args) => arg(args, "command"), + read_file: readSummary, + edit_file: (args) => arg(args, "path"), + write_file: (args) => arg(args, "path"), + web_search: (args) => arg(args, "query", "url"), + lsp: lspSummary, + todo_write: todoSummary, + load_skill: (args) => arg(args, "name"), + consult_vision: (args) => arg(args, "question", "path"), + youtube_transcript: (args) => arg(args, "url"), + summon_agent: summarizeAgent, + send_agent_message: summarizeConversation, + read_agent: summarizeConversation, + open_agent: summarizeConversation, + cancel_agent_message: summarizeConversation, + list_agents: (args) => arg(args, "query", "relationship", "workspaceId"), + list_agent_models: (args) => arg(args, "query"), +}; + +function detail(name: string, input: unknown, args: Args): ToolCardView["callDetail"] { + if (name === "run_shell" && typeof args.command === "string") { + return { kind: "command", text: args.command }; + } + if (name === "edit_file" && typeof args.path === "string") { + return { + kind: "edit", + path: args.path, + oldText: typeof args.oldString === "string" ? args.oldString : "", + newText: typeof args.newString === "string" ? args.newString : "", + }; + } + if (isEmptyInput(input)) return { kind: "none" }; + const entries = Object.entries(args); + return { + kind: "fields", + fields: (entries.length > 0 ? entries : [["input", input]]).map(([key, value]) => { + const text = fieldValue(value); + return { key: String(key), value: text, multiline: text.includes("\n") }; + }), + }; +} + +function bytesLabel(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function responseSummary(name: string, content: string, isError: boolean): string { + const lines = outputLines(content); + const first = oneLine(lines.find((line) => line.trim()) ?? "", 72); + const count = `${lines.length} ${lines.length === 1 ? "line" : "lines"}`; + const timeout = /\[Command timed out\]/i.test(content); + const truncated = /\[Output truncated[^\]]*\]/i.test(content); + let summary: string; + if (timeout) summary = "timed out"; + else if (isError) summary = "failed"; + else if (!content.trim()) summary = "no output"; + else if (/^\(empty file\b/i.test(content)) summary = "empty file"; + else { + const replaced = + name === "edit_file" ? /^Replaced (\d+) occurrence(?:\(s\)|s)?/i.exec(content) : null; + const written = + name === "write_file" ? /^(Created|Overwrote)\b[^\n]*\((\d+) bytes\)/i.exec(content) : null; + if (replaced) { + summary = `${replaced[1]} ${replaced[1] === "1" ? "replacement" : "replacements"}`; + if (content.trim().includes("\n")) summary += " · diagnostics"; + } else if (written) { + summary = `${written[1]?.toLowerCase()} · ${bytesLabel(Number(written[2]))}`; + } else if ( + name === "run_shell" || + name === "read_file" || + lines.length > 1 || + first.length > 60 + ) { + summary = count; + } else summary = first; + } + const label = `${summary}${truncated ? " · truncated" : ""}`; + // Even the wide header must leave room for the call; prefer a whole short + // fact over chopping an arbitrary one-line response halfway through a word. + return label.length <= 40 ? label : compactResponseSummary(label, lines.length); +} + +/** Never CSS-clip response facts: swap to a whole short fact when space is scarce. */ +function compactResponseSummary(summary: string, count: number): string { + const fact = summary.split(" · ")[0] ?? summary; + if (/^(failed|timed out|no output|empty file|created|overwrote|\d+ lines?)$/.test(fact)) + return fact; + if (/^\d+ replacements?$/.test(fact)) return "replaced"; + return `${count} ${count === 1 ? "line" : "lines"}`; +} + +export function viewToolEntry(entry: ToolEntry): ToolCardView { + const input = normalize(entry.call?.input); + const args = asArgs(input); + const summarize = Object.hasOwn(summaries, entry.toolName) + ? summaries[entry.toolName] + : undefined; + const generic = arg( + args, + "command", + "path", + "file_path", + "query", + "url", + "pattern", + "name", + "question", + "title", + "task", + "message", + "conversationId", + ); + const builtInSummary = summarize?.(args).trim(); + const callSummary = + entry.call === null + ? entry.recovering + ? "Recovering tool input…" + : "Call in earlier history" + : isEmptyInput(input) + ? "" + : oneLine( + builtInSummary || + generic || + scalarPairs(args) || + // Non-object input (e.g. a bare string); objects with only nested + // values stay unsummarized rather than dumping JSON braces. + (Object.keys(args).length === 0 ? serialize(input) : ""), + ); + const callBadges: ToolCardView["callBadges"][number][] = []; + if (entry.toolName === "edit_file" && entry.call !== null && !entry.result?.isError) { + if (typeof args.oldString === "string" && typeof args.newString === "string") { + callBadges.push( + { label: `+${lineCount(args.newString)}`, tone: "success" }, + { label: `−${lineCount(args.oldString)}`, tone: "error" }, + ); + } + } + const result = entry.result; + const lines = result === null ? [] : outputLines(result.content); + const resultSummary = + result === null ? null : responseSummary(entry.toolName, result.content, result.isError); + return { + name: entry.toolName, + label: toolLabel(entry.toolName), + icon: toolIcon(entry.toolName), + status: result === null ? "pending" : result.isError ? "error" : "ok", + callSummary, + callBadges, + callDetail: + entry.call === null + ? { + kind: "unavailable", + text: entry.recovering + ? "Tool input is unavailable until it is persisted. Recovering from conversation history…" + : "This call is earlier in the conversation. Show earlier messages to see it.", + } + : detail(entry.toolName, input, args), + resultSummary, + compactResultSummary: + resultSummary === null ? null : compactResponseSummary(resultSummary, lines.length), + output: + result === null + ? null + : lines.length <= 12 + ? { kind: "full", lines, isError: result.isError } + : { + kind: "elided", + head: lines.slice(0, 6), + hiddenCount: lines.length - 12, + tail: lines.slice(-6), + all: lines, + isError: result.isError, + }, + }; +} diff --git a/src/features/chat/model-select.test.ts b/src/features/chat/model-select.test.ts index 6d3081d..523f1d8 100644 --- a/src/features/chat/model-select.test.ts +++ b/src/features/chat/model-select.test.ts @@ -1,9 +1,19 @@ import { describe, expect, it } from "vitest"; import { + isClaudeSubscriptionModel, + isNamedAgentSelection, isVisionModel, joinModelName, + migrateLegacyModelName, + modelKeyLabel, modelKeys, modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + namedAgentsProviderLabel, + openCodeNumber, + providerKeysWithNamedAgents, splitModelName, } from "./model-select"; @@ -47,11 +57,64 @@ describe("modelKeys", () => { ).toEqual(["openai", "anthropic", "google"]); }); + it("sorts numbered OpenCode keys numerically (#2 before #10)", () => { + expect( + modelKeys(["opencode-10/models", "opencode-2/models", "opencode-1/models", "openai/gpt-4"]), + ).toEqual(["opencode-1", "opencode-2", "opencode-10", "openai"]); + }); + it("is empty for no models", () => { expect(modelKeys([])).toEqual([]); }); }); +describe("openCodeNumber", () => { + it("parses a numbered OpenCode key", () => { + expect(openCodeNumber("opencode-1")).toBe(1); + expect(openCodeNumber("opencode-10")).toBe(10); + }); + + it("returns null for non-openCode / malformed keys", () => { + expect(openCodeNumber("umans")).toBeNull(); + expect(openCodeNumber("opencode")).toBeNull(); + expect(openCodeNumber("opencode-0")).toBeNull(); + expect(openCodeNumber("opencode-01")).toBeNull(); + }); +}); + +describe("modelKeyLabel", () => { + it("labels a numbered OpenCode key as OpenCode #N", () => { + expect(modelKeyLabel("opencode-2")).toBe("OpenCode #2"); + expect(modelKeyLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode keys unchanged", () => { + expect(modelKeyLabel("umans")).toBe("umans"); + }); +}); + +describe("migrateLegacyModelName", () => { + it("rewrites opencode/<model> to opencode-1/<model>", () => { + expect(migrateLegacyModelName("opencode/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("opencode/nested/model")).toBe("opencode-1/nested/model"); + }); + + it("leaves already-numbered and other-provider names unchanged", () => { + expect(migrateLegacyModelName("opencode-1/deepseek-v4-flash")).toBe( + "opencode-1/deepseek-v4-flash", + ); + expect(migrateLegacyModelName("umans/umans-glm-5.2")).toBe("umans/umans-glm-5.2"); + expect(migrateLegacyModelName("opencode-2/x")).toBe("opencode-2/x"); + }); + + it("is idempotent", () => { + const once = migrateLegacyModelName("opencode/deepseek-v4-flash"); + expect(migrateLegacyModelName(once)).toBe(once); + }); +}); + describe("modelsForKey", () => { it("returns the model suffixes under a key, in order", () => { const models = ["openai/gpt-4", "anthropic/claude-3", "openai/gpt-4o"]; @@ -87,3 +150,66 @@ describe("isVisionModel", () => { expect(isVisionModel({}, "kimi/k2")).toBe(false); }); }); + +describe("named-agent provider helpers", () => { + it("providerKeysWithNamedAgents puts the synthetic provider first, then raw keys", () => { + expect(providerKeysWithNamedAgents(["openai/gpt-4", "anthropic/claude"])).toEqual([ + NAMED_AGENTS_PROVIDER, + "openai", + "anthropic", + ]); + // Always present even with zero models. + expect(providerKeysWithNamedAgents([])).toEqual([NAMED_AGENTS_PROVIDER]); + }); + + it("namedAgentsProviderLabel + NAMED_AGENTS_PROVIDER are stable", () => { + expect(namedAgentsProviderLabel()).toBe("Named Agents"); + expect(NAMED_AGENTS_PROVIDER).toBe("__named_agents__"); + }); + + it("namedAgentNames returns the display names", () => { + expect(namedAgentNames([{ id: "a", name: "Coding", model: "m" }])).toEqual(["Coding"]); + }); + + it("isNamedAgentSelection is true only for a snapshot whose model equals the selection", () => { + expect( + isNamedAgentSelection("opencode-1/claude", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(true); + expect( + isNamedAgentSelection("other/model", { + namedAgentId: "a", + namedAgentName: "C", + model: "opencode-1/claude", + }), + ).toBe(false); + expect(isNamedAgentSelection("opencode-1/claude", null)).toBe(false); + }); + + it("namedAgentSelectionLabel renders 'Named Agents -> <name>'", () => { + expect(namedAgentSelectionLabel("Coding")).toBe("Named Agents -> Coding"); + }); +}); + +describe("isClaudeSubscriptionModel", () => { + it("is true for claude/<model> (the Claude Code subscription credential)", () => { + expect(isClaudeSubscriptionModel("claude/claude-opus-4-6")).toBe(true); + expect(isClaudeSubscriptionModel("claude/claude-sonnet-4-6")).toBe(true); + }); + + it("is false for Claude models served through other credentials", () => { + expect(isClaudeSubscriptionModel("opencode-1/claude-sonnet-4.5")).toBe(false); + expect(isClaudeSubscriptionModel("openrouter/anthropic/claude-opus-4")).toBe(false); + expect(isClaudeSubscriptionModel("openai/gpt-4o")).toBe(false); + }); + + it("is false for a bare key, a lookalike key, or undefined", () => { + expect(isClaudeSubscriptionModel("claude")).toBe(false); + expect(isClaudeSubscriptionModel("claude/")).toBe(false); + expect(isClaudeSubscriptionModel("claude-2/claude-opus-4-6")).toBe(false); + expect(isClaudeSubscriptionModel(undefined)).toBe(false); + }); +}); diff --git a/src/features/chat/model-select.ts b/src/features/chat/model-select.ts index 602e0ef..bebad2c 100644 --- a/src/features/chat/model-select.ts +++ b/src/features/chat/model-select.ts @@ -29,15 +29,80 @@ export function joinModelName(key: string, model: string): string { /** Distinct keys across all models, in first-seen order. */ export function modelKeys(models: readonly string[]): string[] { const seen = new Set<string>(); - const out: string[] = []; + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; for (const full of models) { const { key } = splitModelName(full); - if (!seen.has(key)) { - seen.add(key); - out.push(key); - } + if (seen.has(key)) continue; + seen.add(key); + const n = openCodeNumber(key); + if (n !== null) openCodeKeys.push({ key, n }); + else otherKeys.push(key); } - return out; + // Numbered OpenCode credentials sort NUMERICALLY (#2 before #10), placed + // before other providers (the primary credential in this app). Others keep + // first-seen order. + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** + * The stable numeric identity of a numbered OpenCode provider id/key + * (`opencode-1` → 1, `opencode-10` → 10), or `null` when the id is not a + * numbered OpenCode key. Numbers are canonical positive integers with no + * leading zeros. + */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** True iff `id` is a numbered OpenCode provider/key (`opencode-<n>`). */ +export function isNumberedOpenCode(id: string): boolean { + return openCodeNumber(id) !== null; +} + +/** + * The credential key of the Claude Code subscription (the external Claude + * extension — `auth-claude` + `provider-anthropic`). The backend registers it + * under `DISPATCH_CLAUDE_CREDENTIAL`, default `"claude"`, so its models arrive + * as `claude/<model>`. + */ +export const CLAUDE_SUBSCRIPTION_KEY = "claude"; + +/** + * True iff `full` is a model served through the Claude Code subscription + * (`claude/<model>`). Gates the cache timer (that subscription's prompt cache + * expires after 5 minutes). A bare key with no model is not a model. + */ +export function isClaudeSubscriptionModel(full: string | undefined): boolean { + if (full === undefined) return false; + const { key, model } = splitModelName(full); + return key === CLAUDE_SUBSCRIPTION_KEY && model !== ""; +} + +/** + * A friendly human label for a model key/provider id: `opencode-<n>` → + * "OpenCode #<n>"; any other id is returned unchanged. The RAW id is still what + * gets selected/persisted — this only affects display. + */ +export function modelKeyLabel(key: string): string { + const n = openCodeNumber(key); + return n === null ? key : `OpenCode #${n}`; +} + +/** + * Migration helper: rewrite a legacy `opencode/<model>` full model name to the + * numbered `opencode-1/<model>` namespace. Returns the input unchanged for + * anything that is not `opencode/`-prefixed (already-numbered names, other + * providers). Idempotent. This is the FE-side persisted-state migration; the + * backend performs its own equivalent on its own store. + */ +export function migrateLegacyModelName(full: string): string { + const legacyPrefix = "opencode/"; + if (!full.startsWith(legacyPrefix)) return full; + const model = full.slice(legacyPrefix.length); + return model.length === 0 ? full : `opencode-1/${model}`; } /** The model suffixes available under a given key, in order. */ @@ -62,3 +127,73 @@ export function isVisionModel( ): boolean { return modelInfo[fullName]?.vision === true; } + +// ── Named Agents synthetic provider (plan §6) ──────────────────────────────── +// +// The regular model picker gains a "Named Agents" provider at the TOP of the +// provider list. Selecting it + a Named Agent name resolves to that agent's raw +// model for the conversation AND records a snapshot so the selection survives +// later edits to the shared preset. These helpers are structural (they take a +// minimal NamedAgent-ish shape) so `model-select` stays decoupled from the +// named-agents feature up the chain; the picker binds the real types. + +/** A minimal Named Agent shape the synthetic provider needs (structural). */ +export interface NamedAgentLike { + readonly id: string; + readonly name: string; + readonly model: string; +} + +/** A minimal per-conversation Named Agent selection snapshot (structural). */ +export interface NamedAgentSnapshotLike { + readonly namedAgentId: string; + readonly namedAgentName: string; + readonly model: string; +} + +/** + * The synthetic "Named Agents" provider id used by the regular model picker + * (plan §6). Deliberately a value that can never collide with a real provider + * (`<credentialName>`), so the provider dropdown treats it as the first option. + */ +export const NAMED_AGENTS_PROVIDER = "__named_agents__"; + +/** The friendly label of the synthetic Named Agents provider. */ +export function namedAgentsProviderLabel(): string { + return "Named Agents"; +} + +/** + * The provider dropdown options for the picker: the synthetic "Named Agents" + * provider ALWAYS first, then the raw providers (via `modelKeys`). The synthetic + * entry stays present even with zero Named Agents (plan §6 shows a disabled + * "No named agents configured" model option). + */ +export function providerKeysWithNamedAgents(models: readonly string[]): string[] { + return [NAMED_AGENTS_PROVIDER, ...modelKeys(models)]; +} + +/** The Named Agent names offered in the model dropdown when the synthetic provider is selected. */ +export function namedAgentNames(agents: readonly NamedAgentLike[]): string[] { + return agents.map((a) => a.name); +} + +/** + * Whether a full raw model string corresponds to a Named Agent snapshot — + * i.e. the conversation was selected via a Named Agent whose captured raw model + * equals `selected`. When true, the picker displays `Named Agents -> <name>`. + */ +export function isNamedAgentSelection( + selected: string, + snapshot: NamedAgentSnapshotLike | null, +): boolean { + return snapshot !== null && snapshot.model === selected; +} + +/** + * The snapshot label shown in the model dropdown for a Named Agent selection: + * "Named Agents -> <name>". Pure display. + */ +export function namedAgentSelectionLabel(name: string): string { + return `${namedAgentsProviderLabel()} -> ${name}`; +} diff --git a/src/features/chat/pending-inputs.ts b/src/features/chat/pending-inputs.ts new file mode 100644 index 0000000..d7fb0aa --- /dev/null +++ b/src/features/chat/pending-inputs.ts @@ -0,0 +1,12 @@ +import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; + +/** A saved copy, never automatically retransmitted: requestId is not idempotent. */ +export interface PendingInput { + readonly request: (ChatSendMessage | ChatQueueMessage) & { readonly requestId: string }; + readonly rejection?: string; +} + +export interface PendingInputStorage { + load(): readonly PendingInput[]; + save(inputs: readonly PendingInput[]): void; +} diff --git a/src/features/chat/ports.ts b/src/features/chat/ports.ts index 2fe10dc..53ac236 100644 --- a/src/features/chat/ports.ts +++ b/src/features/chat/ports.ts @@ -1,4 +1,5 @@ import type { + ChatQueueCancelMessage, ChatQueueMessage, ChatSendMessage, ConversationHistoryResponse, @@ -6,12 +7,14 @@ import type { } from "@dispatch/transport-contract"; /** - * Injected transport port — sends chat messages to the server. Accepts both - * `chat.send` (start a turn) and `chat.queue` (enqueue a steering message; - * auto-starts a turn if idle). + * Injected transport port — sends chat messages to the server. Accepts + * `chat.send` (start a turn), `chat.queue` (enqueue a steering message; + * auto-starts a turn if idle), and `chat.queue.cancel` (remove a single queued + * message by id so it never runs — fire-and-forget, idempotent; the + * message-queue surface confirms the removal). */ export interface ChatTransport { - send(msg: ChatSendMessage | ChatQueueMessage): void; + send(msg: ChatSendMessage | ChatQueueMessage | ChatQueueCancelMessage): void; } /** diff --git a/src/features/chat/queued.test.ts b/src/features/chat/queued.test.ts new file mode 100644 index 0000000..3b579c6 --- /dev/null +++ b/src/features/chat/queued.test.ts @@ -0,0 +1,207 @@ +import type { ChatQueuedMessage } from "@dispatch/transport-contract"; +import type { AgentEvent } from "@dispatch/wire"; +import { describe, expect, it, vi } from "vitest"; +import { createChatStore } from "./store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; + +const images = [ + { url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + { url: "data:image/png;base64,BBBB" }, +]; + +function fixture(batched = false) { + const transport = createFakeTransport(); + const history = createFakeHistorySync(); + let nextId = 0; + const scheduled: (() => void)[] = []; + const store = createChatStore({ + conversationId: "c1", + createRequestId: () => `r${++nextId}`, + transport: transport.impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + ...(batched ? { scheduleFlush: (flush: () => void) => scheduled.push(flush) } : {}), + }); + function ack(requestId: string | undefined = "r1", conversationId = "c1") { + const msg: ChatQueuedMessage = { + type: "chat.queued", + conversationId, + reason: "compaction", + messageId: "q1", + ...(requestId !== undefined ? { requestId } : {}), + }; + store.handleDelta(msg); + } + function event(event: AgentEvent) { + store.handleDelta({ type: "chat.delta", event }); + } + function start(text?: string, turnId = "t1") { + event({ type: "turn-start", conversationId: "c1", turnId }); + if (text !== undefined) event({ type: "user-message", conversationId: "c1", turnId, text }); + } + return { store, transport, history, scheduled, ack, event, start }; +} + +describe("compaction-deferred chat.send", () => { + it.each([ + "hello", + "", + ])("removes just the accepted local text/images for %j without error, retry or queue sends", (text) => { + const f = fixture(); + f.store.send(text, images); + expect(f.store.chunks).toHaveLength(text ? 3 : 2); + f.ack(); + f.ack(); + expect(f.store.chunks).toEqual([]); + expect(f.store.generating).toBe(false); + expect(f.store.error).toBeNull(); + expect(f.transport.sent).toHaveLength(1); + expect(f.transport.sent[0]).toMatchObject({ requestId: "r1", images, message: text }); + expect(f.transport.sentQueue).toEqual([]); + f.store.dispose(); + }); + + it("uses distinct request identities for identical sends and preserves unrelated content", () => { + const f = fixture(); + f.start("another client's prompt"); + f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "working" }); + f.store.send("identical", images); + f.store.send("identical", images); + expect(f.transport.sent.map((m) => m.requestId)).toEqual(["r1", "r2"]); + f.ack("r1"); + f.ack("r1"); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "another client's prompt" }, + { type: "text", text: "working" }, + { type: "text", text: "identical" }, + ...images.map((i) => ({ type: "image", ...i })), + ]); + f.ack("r2"); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "another client's prompt" }, + { type: "text", text: "working" }, + ]); + expect(f.store.generating).toBe(true); + f.store.dispose(); + }); + + it.each([ + "missing", + "unknown", + "wrong-conversation", + ])("tolerates %s correlation without deleting any echo", (kind) => { + const f = fixture(); + f.store.send("hello", images); + f.store.handleDelta({ + type: "chat.queued", + conversationId: kind === "wrong-conversation" ? "c2" : "c1", + reason: "compaction", + messageId: "q1", + ...(kind === "missing" ? {} : { requestId: kind === "unknown" ? "r999" : "r1" }), + }); + expect(f.store.chunks).toHaveLength(3); + expect(f.store.error).toBeNull(); + f.store.dispose(); + }); + + it.each([ + false, + true, + ])("preserves a live confirmed prompt before delayed ack (batched=%s)", (batched) => { + const f = fixture(batched); + f.store.send("hello", images); + f.start("hello"); + f.event({ type: "text-delta", conversationId: "c1", turnId: "t1", delta: "answer" }); + f.ack(); + f.ack(); + for (const flush of f.scheduled) flush(); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "hello" }, + { type: "text", text: "answer" }, + ]); + expect(f.store.generating).toBe(true); + expect(f.store.error).toBeNull(); + f.store.dispose(); + }); + + it("turn-start tagging alone does not protect a still-local echo", () => { + const f = fixture(true); + f.store.send("hello", images); + f.start(); + f.ack(); + expect(f.store.chunks).toEqual([]); + expect(f.store.generating).toBe(true); + f.event({ type: "user-message", conversationId: "c1", turnId: "t1", text: "hello" }); + for (const flush of f.scheduled) flush(); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([{ type: "text", text: "hello" }]); + f.store.dispose(); + }); + + it("keeps a combined queue opening and steering; removes only the original individual echoes", () => { + const f = fixture(true); + f.store.send("one", images); + f.store.send("two", images); + f.start("one\n\ntwo"); + f.event({ type: "steering", conversationId: "c1", turnId: "t1", text: "steer" }); + f.ack("r2"); + f.ack("r1"); + expect(f.store.chunks.map((c) => c.chunk)).toEqual([ + { type: "text", text: "one\n\ntwo" }, + { type: "text", text: "steer" }, + ]); + expect(f.store.generating).toBe(true); + f.store.dispose(); + }); + + it.each([ + "ack-first", + "reset-first", + "history-first", + ])("handles epoch replacement idempotently: %s", async (order) => { + const f = fixture(); + await f.store.load(); + f.store.send("hello", images); + if (order === "ack-first") f.ack(); + f.history.historyEpoch = 1; + f.history.returnChunks = [ + { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } }, + ]; + f.store.resync(1); + if (order === "reset-first") f.ack(); + await vi.waitFor(() => expect(f.store.pendingSync).toBe(false)); + f.start("hello"); + const before = f.store.chunks; + f.ack(); + f.ack(); + expect(f.store.chunks).toEqual(before); + expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk)); + expect(f.store.generating).toBe(true); + expect(f.store.error).toBeNull(); + expect(f.transport.sent).toHaveLength(1); + f.store.dispose(); + }); + + it("keeps normal immediate-send text/image deduplication and authoritative history", async () => { + const f = fixture(); + f.store.send("hello", images); + f.start("hello"); + expect(f.store.chunks).toHaveLength(3); + f.history.returnChunks = [ + { seq: 1, role: "user", turnId: "t1", chunk: { type: "text", text: "hello" } }, + { seq: 2, role: "user", turnId: "t1", chunk: { type: "image", url: "/images/c1/saved.png" } }, + ]; + f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" }); + await vi.waitFor(() => expect(f.store.pendingSync).toBe(false)); + expect(f.store.chunks.map((c) => c.chunk)).toEqual(f.history.returnChunks.map((c) => c.chunk)); + expect(f.store.chunks.every((c) => !c.provisional)).toBe(true); + expect(f.transport.sent).toHaveLength(1); + f.store.dispose(); + }); +}); diff --git a/src/features/chat/reasoning-effort.test.ts b/src/features/chat/reasoning-effort.test.ts index 6d409e9..e870bac 100644 --- a/src/features/chat/reasoning-effort.test.ts +++ b/src/features/chat/reasoning-effort.test.ts @@ -2,9 +2,12 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_REASONING_EFFORT, effectiveEffort, + effectiveSelection, effortOptions, isReasoningEffort, + isThinkingSelection, REASONING_EFFORT_LEVELS, + selectionOptions, } from "./reasoning-effort"; describe("reasoning-effort helpers", () => { @@ -43,3 +46,39 @@ describe("reasoning-effort helpers", () => { } }); }); + +describe("thinking selection (the separate on/off axis)", () => { + it("selectionOptions lists 'off' first, then the ladder (default marked)", () => { + const options = selectionOptions(); + expect(options).toHaveLength(1 + REASONING_EFFORT_LEVELS.length); + expect(options[0]?.value).toBe("off"); + expect(options[0]?.label).toBe("Off"); + // the rest are the ladder, unchanged from effortOptions() + expect(options.slice(1).map((o) => o.value)).toEqual([...REASONING_EFFORT_LEVELS]); + expect(options.find((o) => o.value === "high")?.label).toBe("high (default)"); + }); + + it("isThinkingSelection narrows 'off' + ladder strings, rejects the rest", () => { + expect(isThinkingSelection("off")).toBe(true); + for (const level of REASONING_EFFORT_LEVELS) { + expect(isThinkingSelection(level)).toBe(true); + } + expect(isThinkingSelection("banana")).toBe(false); + expect(isThinkingSelection("")).toBe(false); + expect(isThinkingSelection("OFF")).toBe(false); + expect(isThinkingSelection("none")).toBe(false); // NOT a wire value we send + }); + + it("effectiveSelection shows 'off' when thinking is explicitly disabled", () => { + // thinking off is a SEPARATE axis: the effort level is irrelevant while off. + expect(effectiveSelection("xhigh", false)).toBe("off"); + expect(effectiveSelection(null, false)).toBe("off"); + }); + + it("effectiveSelection shows the effort level when thinking is on (default)", () => { + // null thinking = never set ⇒ thinking ON (default) ⇒ show the effort level. + expect(effectiveSelection(null, null)).toBe("high"); // default effort + expect(effectiveSelection("low", null)).toBe("low"); + expect(effectiveSelection("max", true)).toBe("max"); // explicitly on + }); +}); diff --git a/src/features/chat/reasoning-effort.ts b/src/features/chat/reasoning-effort.ts index 1eb77b6..39e1c5a 100644 --- a/src/features/chat/reasoning-effort.ts +++ b/src/features/chat/reasoning-effort.ts @@ -36,7 +36,7 @@ export function effectiveEffort(persisted: ReasoningEffort | null): ReasoningEff return persisted ?? DEFAULT_REASONING_EFFORT; } -/** One `<option>` of the selector. */ +/** One `<option>` of the effort ladder. */ export interface EffortOption { readonly value: ReasoningEffort; readonly label: string; @@ -64,3 +64,99 @@ export type ReasoningEffortSaveResult = export type SaveReasoningEffort = ( level: ReasoningEffort, ) => Promise<ReasoningEffortSaveResult | null>; + +// ── Thinking on/off (a SEPARATE axis from the effort level) ───────────────── +// +// Per the umans API (and the user's mental model), "thinking off" is NOT a +// zero-effort level — it is a distinct "disable extended thinking entirely" +// signal. The umans route expresses it as `reasoning_effort: "none"`; Dispatch +// surfaces it as a SEPARATE per-conversation boolean so the effort LEVEL is +// preserved across an off→on toggle (turning thinking back on restores the +// previously-chosen depth). The per-conversation selector CONFLATES the two +// axes into one `<select>` (UX), but the WIRE keeps them separate. +// +// ⚠️ BACKEND CONTRACT GAP — see `backend-handoff.md`. The `thinking` endpoint + +// wire types below are the PROPOSED shape; the backend has NOT shipped them yet. +// They are defined FE-local (mirroring the shipped `ReasoningEffortResponse` / +// `SetReasoningEffortRequest` shape) so the FE is built + tested against the +// target contract. Re-pin + re-mirror once the backend ships them. + +/** + * Response of `GET /conversations/:id/thinking` (PROPOSED). `thinking` is null + * when never set (the server then resolves turns with thinking ON — the + * default), `false` when explicitly disabled, `true` when explicitly enabled. + */ +export interface ThinkingResponse { + readonly conversationId: string; + readonly thinking: boolean | null; +} + +/** Body of `PUT /conversations/:id/thinking` (PROPOSED). */ +export interface SetThinkingRequest { + readonly thinking: boolean; +} + +/** + * The per-conversation selector's value: `"off"` (thinking disabled — the + * separate axis) or a reasoning-effort LEVEL. NOT a widened ladder: `"off"` is + * not a degree of effort, it is the absence of thinking. + */ +export type ThinkingSelection = "off" | ReasoningEffort; + +/** One `<option>` of the combined selector (off or a level). */ +export interface SelectionOption { + readonly value: ThinkingSelection; + readonly label: string; +} + +/** + * The selector's options: `"off"` first (the separate disable signal), then the + * effort ladder with the server default marked `(default)`. A never-set + * conversation (thinking on, effort null) reads "high (default)". + */ +export function selectionOptions(): readonly SelectionOption[] { + return [{ value: "off", label: "Off" }, ...effortOptions()]; +} + +/** Narrow an untrusted `<select>` value to a {@link ThinkingSelection}. */ +export function isThinkingSelection(value: string): value is ThinkingSelection { + return value === "off" || isReasoningEffort(value); +} + +/** + * The selection the per-conversation selector should show as selected: `"off"` + * when thinking is explicitly disabled (`persistedThinking === false`), else the + * effective effort level. `persistedThinking === null` (never set) ⇒ thinking + * ON (the default) ⇒ the effort level is shown — NOT "off". + */ +export function effectiveSelection( + persistedEffort: ReasoningEffort | null, + persistedThinking: boolean | null, +): ThinkingSelection { + if (persistedThinking === false) return "off"; + return effectiveEffort(persistedEffort); +} + +// ── Injected port for the combined selector (off OR a level) ───────────────── + +/** Outcome of persisting a {@link ThinkingSelection} (one or two PUTs). */ +export type ThinkingSelectionSaveResult = + | { readonly ok: true; readonly selection: ThinkingSelection } + | { readonly ok: false; readonly error: string }; + +/** + * Persist a thinking selection (consumer-defines-port; the composition root + * adapts the store's `PUT .../thinking` + `PUT .../reasoning-effort` to this). + * - `"off"` → disable thinking (the separate signal); the effort level is left + * untouched so an off→on toggle restores it. + * - a level → set the effort level AND ensure thinking is ON (the level is + * meaningless while thinking is off). + */ +export type SaveThinkingSelection = ( + selection: ThinkingSelection, +) => Promise<ThinkingSelectionSaveResult | null>; + +/** Outcome of `PUT /conversations/:id/thinking`. */ +export type ThinkingSaveResult = + | { readonly ok: true; readonly thinking: boolean } + | { readonly ok: false; readonly error: string }; diff --git a/src/features/chat/recovery.test.ts b/src/features/chat/recovery.test.ts new file mode 100644 index 0000000..4b46288 --- /dev/null +++ b/src/features/chat/recovery.test.ts @@ -0,0 +1,529 @@ +import type { + ChatResyncRequiredMessage, + ConversationHistoryResponse, +} from "@dispatch/transport-contract"; +import type { AgentEvent, StepId, StoredChunk, TurnEndedEvent } from "@dispatch/wire"; +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createIdbChunkStore } from "../../adapters/idb"; +import { createConversationCache } from "../conversation-cache"; +import type { HistorySync } from "./ports"; +import { createChatStore } from "./store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "./test-helpers"; + +const step = "t1#0" as StepId; +const opening: StoredChunk = { + seq: 1, + role: "user", + turnId: "t1", + chunk: { type: "text", text: "prompt" }, +}; +const call: StoredChunk = { + seq: 3, + role: "assistant", + turnId: "t1", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "run_shell", + stepId: step, + input: { command: "full input" }, + }, +}; +const result: StoredChunk = { + seq: 4, + role: "tool", + turnId: "t1", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "run_shell", + stepId: step, + content: "full output", + isError: false, + }, +}; +const committed: StoredChunk[] = [ + opening, + { seq: 2, role: "assistant", turnId: "t1", chunk: { type: "text", text: "complete text" } }, + call, + result, +]; + +function fixture(historySync?: HistorySync, batched = false) { + const history = createFakeHistorySync(); + const transport = createFakeTransport(); + const cache = createFakeCache(); + const scheduled: (() => void)[] = []; + const store = createChatStore({ + conversationId: "c1", + createRequestId: () => "r1", + historySync: historySync ?? history.impl, + metricsSync: createFakeMetricsSync().impl, + cache: cache.impl, + transport: transport.impl, + ...(batched ? { scheduleFlush: (fn: () => void) => scheduled.push(fn) } : {}), + }); + const event = (event: AgentEvent) => store.handleDelta({ type: "chat.delta", event }); + const notice = ( + turnId: string | undefined = "t1", + reason: ChatResyncRequiredMessage["reason"] = "replay-limit", + ) => + store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + reason, + ...(turnId !== undefined ? { turnId } : {}), + }); + const end = (reason: TurnEndedEvent["reason"] = "sealed", turnId = "t1") => + event({ type: "turn-ended", conversationId: "c1", turnId, reason }); + const text = (delta: string, turnId = "t1") => + event({ type: "text-delta", conversationId: "c1", turnId, delta }); + return { store, history, transport, cache, scheduled, event, notice, end, text }; +} + +async function settled(store: ReturnType<typeof createChatStore>) { + for (let i = 0; i < 80 && store.pendingSync; i++) await Promise.resolve(); + expect(store.pendingSync).toBe(false); +} +function response(chunks: readonly StoredChunk[], historyEpoch = 0): ConversationHistoryResponse { + return { chunks, historyEpoch, latestSeq: chunks.at(-1)?.seq ?? 0 }; +} +function deferred() { + let resolve!: (value: ConversationHistoryResponse) => void; + const promise = new Promise<ConversationHistoryResponse>((r) => { + resolve = r; + }); + return { promise, resolve }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("omitted chat recovery", () => { + it.each([ + false, + true, + ])("marks omitted replay active without content and recovers full HTTP payload (batched=%s)", async (batched) => { + vi.useFakeTimers(); + const f = fixture(undefined, batched); + f.text("partial"); + f.notice(); + expect(f.store.generating).toBe(true); + expect(f.store.recovering).toBe(true); + expect(f.store.chunks.map((r) => r.chunk)).toEqual([{ type: "text", text: "partial" }]); + await settled(f.store); + // No initial commit yet. A tool result follows an OMITTED call input. + f.event({ + type: "tool-result", + conversationId: "c1", + turnId: "t1", + stepId: step, + toolCallId: "a", + toolName: "run_shell", + content: "short replay", + isError: false, + }); + f.history.returnChunks = committed; + await vi.advanceTimersByTimeAsync(1000); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toEqual([ + expect.objectContaining({ chunk: call.chunk, provisional: false }), + ]); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1); + expect(f.store.chunks.find((r) => r.chunk.type === "tool-result")?.chunk).toEqual(result.chunk); + expect(f.store.generating).toBe(true); + expect(f.store.error).toBeNull(); + expect(f.transport.sent).toEqual([]); + f.store.dispose(); + expect(vi.getTimerCount()).toBe(0); + }); + + it("coalesces repeat notices and polls after step notification races persistence, without parallel requests", async () => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async () => { + calls++; + return calls === 1 ? pending.promise : response(committed); + }); + f.notice(); + for (let i = 0; i < 20; i++) f.notice(); + for (let i = 0; i < 15; i++) await Promise.resolve(); + expect(calls).toBe(1); + f.event({ type: "step-complete", conversationId: "c1", turnId: "t1", stepId: step }); + await vi.advanceTimersByTimeAsync(10000); + expect(calls).toBe(1); + pending.resolve(response([])); + await settled(f.store); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(999); + expect(calls).toBe(1); + await vi.advanceTimersByTimeAsync(1); + expect(calls).toBe(2); + expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk)); + f.store.dispose(); + }); + + it.each([ + "sealed", + "aborted", + "error", + "rejected", + ] as const)("final-syncs %s behind an in-flight fetch and preserves immediate queued carry", async (reason) => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed))); + f.notice(); + for (let i = 0; i < 10; i++) await Promise.resolve(); + f.text("old tail"); + f.end(reason); + f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued input" }); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" }); + f.text("carried tail", "t2"); + pending.resolve(response([opening])); + await settled(f.store); + expect(calls).toBe(2); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(true); + expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([ + { type: "text", text: "queued input" }, + { type: "text", text: "carried tail" }, + ]); + expect(f.store.chunks.filter((r) => r.turnId === "t1").every((r) => !r.provisional)).toBe(true); + await vi.advanceTimersByTimeAsync(5000); + expect(calls).toBe(2); + f.end("sealed", "t2"); + expect(f.store.generating).toBe(false); + f.store.dispose(); + }); + + it.each([ + false, + true, + ])("notice during %s-cache load requests one rerun, preserving current window", async (warm) => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async (_id, _since, window) => { + calls++; + if (!warm) expect(window?.limit).toBe(192); + return calls === 1 ? pending.promise : response([opening]); + }); + if (warm) f.cache.store.set("c1", [opening]); + const load = f.store.load(); + for (let i = 0; i < 15; i++) await Promise.resolve(); + f.notice(); + pending.resolve(response([opening])); + await load; + await settled(f.store); + expect(calls).toBe(2); + expect(f.store.chunks).toHaveLength(1); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + f.store.dispose(); + }); + + it("payload notice after a completed HTTP load preserves live tail, unrelated turn and tool progress", async () => { + vi.useFakeTimers(); + const f = fixture(); + await f.store.load(); + f.event({ type: "turn-start", conversationId: "c1", turnId: "other" }); + f.text("unrelated", "other"); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" }); + f.text("partial text"); + f.event({ + type: "tool-call", + conversationId: "c1", + turnId: "t1", + stepId: step, + toolCallId: "a", + toolName: "run_shell", + input: { command: "short" }, + }); + f.event({ + type: "tool-result", + conversationId: "c1", + turnId: "t1", + stepId: step, + toolCallId: "a", + toolName: "run_shell", + content: "short", + isError: false, + }); + f.text("complete text"); // same payload as earlier step; NOT the same identity + f.event({ + type: "tool-output", + conversationId: "c1", + turnId: "t1", + toolCallId: "next", + data: "progress", + stream: "stdout", + }); + f.history.returnChunks = committed; + f.notice("t1", "payload-limit"); + await settled(f.store); + expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([ + { type: "text", text: "unrelated" }, + { type: "text", text: "complete text" }, + ]); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-call")).toHaveLength(1); + expect(f.store.chunks.filter((r) => r.chunk.type === "tool-result")).toHaveLength(1); + f.store.dispose(); + }); + + it("bounds epoch churn during recovery and cancels its retry timer on disposal", async () => { + vi.useFakeTimers(); + let calls = 0; + const f = fixture(async () => { + f.store.invalidateHistory(++calls); + return response([], calls - 1); + }); + f.notice(); + await settled(f.store); + expect(calls).toBe(3); + expect(f.store.error).toContain("sync paused"); + await vi.advanceTimersByTimeAsync(10000); + expect(calls).toBe(3); + f.store.dispose(); + await vi.advanceTimersByTimeAsync(10000); + expect(calls).toBe(3); + expect(vi.getTimerCount()).toBe(0); + }); + + it("ignores other conversations and disposed notices; accepts a notice without turnId", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.store.handleDelta({ + type: "chat.resync-required", + conversationId: "other", + reason: "payload-limit", + }); + expect(f.store.recovering).toBe(false); + f.store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + reason: "payload-limit", + }); + await settled(f.store); + expect(f.store.generating).toBe(false); + expect(f.store.recovering).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t1" }); + f.store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + reason: "payload-limit", + }); + expect(f.store.generating).toBe(true); + expect(f.store.recovering).toBe(true); + f.end("rejected"); + await settled(f.store); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(false); + f.store.dispose(); + f.notice(); + expect(vi.getTimerCount()).toBe(0); + }); +}); + +it("recovers a windowed rollback with real IndexedDB cache while preserving lifecycle evidence", async () => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() })); + await cache.commit( + "c1", + [{ seq: 1, role: "assistant", chunk: { type: "text", text: "old epoch" } }], + 5, + ); + const history = createFakeHistorySync(); + history.historyEpoch = 5; + const timers = new Map<object, () => void>(); + const store = createChatStore({ + conversationId: "c1", + createRequestId: () => "r", + cache, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + transport: createFakeTransport().impl, + chatLimit: 10, + recoveryTimer: { + schedule(fn) { + const key = {}; + timers.set(key, fn); + return key; + }, + cancel(key) { + timers.delete(key as object); + }, + }, + }); + try { + await store.load(); + history.historyEpoch = 0; + history.returnChunks = Array.from({ length: 20 }, (_, i) => ({ + seq: i + 1, + role: "assistant" as const, + turnId: "t1", + chunk: { type: "text" as const, text: `restored-${i}` }, + })); + const before = history.calls.length; + store.handleDelta({ + type: "chat.resync-required", + conversationId: "c1", + turnId: "t1", + reason: "replay-limit", + }); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(history.calls.length - before).toBe(2); + expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } }); + expect(await cache.historyEpoch("c1")).toBe(0); + expect(store.chunks.map((c) => c.seq)).toEqual([14, 15, 16, 17, 18, 19, 20]); + expect(store.hasEarlier).toBe(true); + expect(store.generating).toBe(true); + expect(timers.size).toBe(1); + store.handleDelta({ + type: "chat.delta", + event: { type: "turn-ended", conversationId: "c1", turnId: "t1", reason: "aborted" }, + }); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(store.generating).toBe(false); + expect(timers.size).toBe(0); + expect(store.hasEarlier).toBe(true); + } finally { + store.dispose(); + } +}); + +it("ignores an unrelated terminal and stays recovering through done/seal until its actual terminal", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.notice(); + await settled(f.store); + f.end("aborted", "unrelated"); + f.event({ type: "done", conversationId: "c1", turnId: "t1", reason: "aborted" }); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + f.event({ type: "turn-sealed", conversationId: "c1", turnId: "t1" }); + await settled(f.store); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + f.end("aborted"); + await settled(f.store); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("makes a post-terminal request even when the terminal races the third bounded epoch attempt", async () => { + vi.useFakeTimers(); + const third = deferred(); + let calls = 0; + const f = fixture(async () => { + calls++; + if (calls < 3) { + f.store.invalidateHistory(calls); + return response([], calls - 1); + } + return calls === 3 ? third.promise : response(committed, 2); + }); + f.notice(); + for (let i = 0; i < 60 && calls < 3; i++) await Promise.resolve(); + expect(calls).toBe(3); + f.end("error"); + third.resolve(response([], 2)); + await settled(f.store); + expect(calls).toBe(4); + expect(f.store.chunks.map((r) => r.chunk)).toEqual(committed.map((r) => r.chunk)); + expect(f.store.recovering).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("retains active recovery across a compaction epoch refresh and cancels it on reconnect/dispose", async () => { + vi.useFakeTimers(); + const f = fixture(); + f.notice(); + await settled(f.store); + f.history.historyEpoch = 1; + f.store.resync(1); + await settled(f.store); + expect(f.store.recovering).toBe(true); + expect(f.store.generating).toBe(true); + expect(vi.getTimerCount()).toBe(1); + f.store.resync(); + await settled(f.store); + expect(f.store.recovering).toBe(false); + expect(f.store.generating).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("flushes a batched aborted terminal plus carried turn before applying an older HTTP response", async () => { + vi.useFakeTimers(); + const pending = deferred(); + let calls = 0; + const f = fixture(async () => (++calls === 1 ? pending.promise : response(committed)), true); + f.notice(); + for (let i = 0; i < 15; i++) await Promise.resolve(); + f.text("old partial"); + f.end("aborted"); + f.event({ type: "user-message", conversationId: "c1", turnId: "t2", text: "queued" }); + f.event({ type: "turn-start", conversationId: "c1", turnId: "t2" }); + f.text("live carried output", "t2"); + pending.resolve(response([opening])); + await settled(f.store); + expect(calls).toBe(2); + expect(f.store.chunks.filter((r) => r.provisional).map((r) => r.chunk)).toEqual([ + { type: "text", text: "queued" }, + { type: "text", text: "live carried output" }, + ]); + expect(f.store.generating).toBe(true); + expect(f.store.recovering).toBe(false); + expect(vi.getTimerCount()).toBe(0); + f.store.dispose(); +}); + +it("does not truncate a payload larger than the WS budget recovered over HTTP", async () => { + vi.useFakeTimers(); + const large = "x".repeat(4 * 1024 * 1024 + 1); + const full: StoredChunk[] = [ + { + ...call, + chunk: { + ...call.chunk, + type: "tool-call", + toolCallId: "a", + toolName: "run_shell", + input: { command: large }, + stepId: step, + }, + }, + { + ...result, + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "run_shell", + content: large, + isError: false, + stepId: step, + }, + }, + ]; + const f = fixture(); + f.history.returnChunks = full; + f.notice(); + await settled(f.store); + expect(f.store.chunks).toHaveLength(2); + expect(f.store.chunks.map((r) => r.chunk)).toEqual(full.map((r) => r.chunk)); + expect(f.transport.sent).toEqual([]); + f.store.dispose(); +}); diff --git a/src/features/chat/store.svelte.ts b/src/features/chat/store.svelte.ts index 9911438..dacbe06 100644 --- a/src/features/chat/store.svelte.ts +++ b/src/features/chat/store.svelte.ts @@ -1,8 +1,13 @@ import type { + ChatAcceptedMessage, ChatDeltaMessage, ChatErrorMessage, + ChatQueueCancelMessage, + ChatQueuedMessage, ChatQueueMessage, + ChatResyncRequiredMessage, ChatSendMessage, + ChatSubscriptionRejectedMessage, } from "@dispatch/transport-contract"; import type { ChatMessage, ImageInput, StoredChunk, TurnProviderRetryEvent } from "@dispatch/wire"; import type { RenderedChunk, TranscriptState } from "../../core/chunks"; @@ -13,7 +18,9 @@ import { foldEvent, initialState, initialWindowSize, + markHistoryRecovering, normalizeChatLimit, + removeLocalEcho, restoreEarlier, selectChunks, selectGenerating, @@ -24,19 +31,28 @@ import { unloadCount, windowTranscript, } from "../../core/chunks"; -import type { MetricsState, TurnMetricsEntry } from "../../core/metrics"; +import type { CacheTimerState, MetricsState, TurnMetricsEntry } from "../../core/metrics"; import { applyDurableMetrics, + foldCacheTimerEvent, foldMetricsEvent, + initialCacheTimerState, initialMetricsState, + seedTurnGap, selectCurrentContextSize, selectOrderedTurnMetrics, + stopTurnGap, } from "../../core/metrics"; import type { ConversationCache } from "../conversation-cache"; +import type { NamedAgentSelectionSnapshot } from "../named-agents"; +import type { PendingInput, PendingInputStorage } from "./pending-inputs"; import type { ChatTransport, HistorySync, MetricsSync } from "./ports"; export interface ChatStoreDependencies { + readonly pendingInputStorage?: PendingInputStorage; readonly conversationId: string; + /** Injected unique client-local correlation token for each new send. */ + readonly createRequestId: () => string; readonly model?: string; readonly transport: ChatTransport; readonly historySync: HistorySync; @@ -67,9 +83,27 @@ export interface ChatStoreDependencies { * `reportError` → full-screen error modal. Absent → errors are logged only. */ readonly onError?: (context: string, err: unknown) => void; + /** + * Schedules a deferred flush of buffered live events (e.g. once per animation + * frame). When provided, `handleDelta` QUEUES events and folds the whole batch + * in one pass on the scheduled callback — so a burst (the late-join replay on + * opening a conversation mid-turn, or a fast stream on a slow device) costs ONE + * derivation + render instead of one per WS frame. Absent → every event is + * folded synchronously (the original behavior; used by tests). + */ + readonly scheduleFlush?: (flush: () => void) => void; + /** Wall clock (epoch ms) — times the cache-timer gaps. Absent → `Date.now`. */ + readonly now?: () => number; + /** Recovery polling clock; browser effects are replaceable by deterministic tests. */ + readonly recoveryTimer?: { + schedule(callback: () => void, delayMs: number): unknown; + cancel(handle: unknown): void; + }; } export interface ChatStore { + readonly pendingInputs: readonly PendingInput[]; + discardPendingInput(requestId: string): void; readonly messages: readonly ChatMessage[]; readonly chunks: readonly RenderedChunk[]; readonly turnMetrics: readonly TurnMetricsEntry[]; @@ -96,6 +130,8 @@ export interface ChatStore { * to the newest attempt + delay; cleared when content resumes or the turn ends. */ readonly providerRetry: TurnProviderRetryEvent | null; + /** Omitted live payloads may not exist in HTTP history until step persistence. */ + readonly recovering: boolean; readonly pendingSync: boolean; readonly error: string | null; readonly model: string | undefined; @@ -111,7 +147,29 @@ export interface ChatStore { * a trim (see `TranscriptState.hiddenThinkingCount`). */ readonly thinkingKeyBase: number; - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void; + /** + * The cache timer (see `core/metrics/cache-timer.ts`): the running gap + * between model requests (a step's tools executing, or the wait after a turn + * ended) plus the stamped durations of finished gaps, keyed by `stepId` / + * `turnId`. Timed with the local clock from live events; in-memory only (a + * reload keeps just a turn gap seeded via `noteLastActivity`). + */ + readonly cacheTimer: CacheTimerState; + /** + * Seed the turn gap from an authoritative activity timestamp (the + * conversation list's `lastActivityAt`) — call only for an IDLE conversation. + * Ignored while a turn is generating; a running turn gap keeps the later start. + */ + noteLastActivity(at: number): void; + handleDelta( + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, + ): void; /** * Send a user message (start a turn via `chat.send`). Optimistically echoes * the text + any `images` as provisional user chunks (`[text, image, …]` in @@ -128,11 +186,32 @@ export interface ChatStore { * auto-starts a turn with the message as its opening prompt (equivalent to * `chat.send`). No optimistic transcript echo — the queue SURFACE carries the * pending message until drain; the `steering` event places it in the - * transcript. `text` must be non-empty (the server 400/errors otherwise). + * transcript. Empty `text` is allowed when images are supplied. WS delivery + * is fire-and-forget; transport send is not a persistence acknowledgement. */ - queueMessage(text: string): void; + queueMessage(text: string, images?: readonly ImageInput[]): void; + /** + * Cancel (remove) a single queued steering message by id so it never runs + * (`chat.queue.cancel` WS op). Fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot); a cancel of an already-drained / unknown message is a + * silent server-side no-op. The caller optimistically hides the row; the + * surface update reconciles. `messageId` is the stable `QueuedMessage.id` + * the queue surface snapshot carries. No transcript change — a cancelled + * message is never delivered as steering. + */ + cancelQueuedMessage(messageId: string): void; setModel(model: string): void; /** + * Set (or clear, with null) the Named Agent selection snapshot this chat sends + * with `chat.send` (plan §7). The backend persists it on the conversation at + * creation so it freezes the friendly id/name + resolved model — later edits to + * the Named Agent do not change this conversation's selection. The raw model + * (already set via `setModel`) is sent alongside; the snapshot is display/identity + * metadata that preserves HOW it was selected. + */ + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void; + /** * Update the chat limit LIVE: re-normalizes, then adjusts the loaded window. * Lowering it unloads older committed chunks (deferred via the gate while the * reader is scrolled up, catching up on the next mutation). Raising it @@ -158,20 +237,165 @@ export interface ChatStore { * running, the server's post-subscribe replay re-asserts `generating`. The * app store pairs this with a `chat.subscribe` for the conversation. */ - resync(): void; + resync(historyEpoch?: number): void; + /** Discard pre-replacement transcript state without loading an unseen conversation. */ + invalidateHistory(historyEpoch: number): void; dispose(): void; } export function createChatStore(deps: ChatStoreDependencies): ChatStore { + let pendingInputs = $state<readonly PendingInput[]>(deps.pendingInputStorage?.load() ?? []); + function savePendingInputs(next: readonly PendingInput[]): void { + deps.pendingInputStorage?.save(next); + pendingInputs = next; + } + function confirmInput(requestId: string): void { + savePendingInputs(pendingInputs.filter((input) => input.request.requestId !== requestId)); + } let transcript = $state<TranscriptState>(initialState()); let metrics = $state<MetricsState>(initialMetricsState()); let _pendingSync = $state(false); let _error = $state<string | null>(null); let _model = $state<string | undefined>(deps.model); + let _namedAgent = $state<NamedAgentSelectionSnapshot | null>(null); + let cacheTimer = $state<CacheTimerState>(initialCacheTimerState()); + const now = deps.now ?? Date.now; let disposed = false; + let rerunRequested = false; + let historyEpoch: number | undefined; + let revision = 0; + let reloadWindow = false; + let earliestSeq = $state<number | null>(null); + let recoveringTurnId: string | undefined; + let recovering = $state(false); + const finishedRecoveryTurns = new Set<string>(); + let finalSyncRequested = false; + let recoveryPaused = false; + const timer = deps.recoveryTimer ?? { + schedule: (callback: () => void, delay: number) => setTimeout(callback, delay), + cancel: (handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>), + }; + let recoveryTimer: unknown; + + function cancelRecoveryTimer(): void { + if (recoveryTimer !== undefined) timer.cancel(recoveryTimer); + recoveryTimer = undefined; + } + + function scheduleRecovery(): void { + if (disposed || !recovering || recoveryPaused || recoveryTimer !== undefined) return; + recoveryTimer = timer.schedule(() => { + recoveryTimer = undefined; + flushEvents(); + void syncTail(initialWindowSize(chatLimit)); + }, 1000); + } + + function resetTranscript(epoch: number): void { + historyEpoch = epoch; + revision++; + reloadWindow = true; + transcript = initialState(); + if (recoveringTurnId !== undefined) + transcript = markHistoryRecovering(transcript, recoveringTurnId); + earliestSeq = null; + pendingEvents = []; + } + function hasEarlier(): boolean { + return selectHasEarlier(transcript, earliestSeq); + } + + /** The most recent turn's id (resolves a seeded turn gap when it is stamped). */ + function lastTurnIdOf(m: MetricsState): string | undefined { + const entries = selectOrderedTurnMetrics(m); + return entries[entries.length - 1]?.turnId; + } let chatLimit = normalizeChatLimit(deps.chatLimit); + // Memoized projections, keyed by state-object identity (the reducers are + // immutable, so identity == content). The getters are read several times per + // render (ChatPane + ChatView); returning the SAME array for an unchanged + // state keeps downstream `$derived`s (grouping, metrics placement) from + // recomputing on every read. Plain closures, not `$derived` — a store factory + // may run inside a component effect, which would own (and later destroy) it. + const memo = <S, R>(fn: (s: S) => R): ((s: S) => R) => { + let lastIn: S | undefined; + let lastOut: R | undefined; + return (s) => { + if (lastOut === undefined || s !== lastIn) { + lastIn = s; + lastOut = fn(s); + } + return lastOut; + }; + }; + const chunksOf = memo(selectChunks); + const turnMetricsOf = memo(selectOrderedTurnMetrics); + + // Buffered live events awaiting a scheduled flush (see `scheduleFlush`). + let pendingEvents: ChatDeltaMessage["event"][] = []; + let flushScheduled = false; + + /** Fold every buffered event in one pass, then trim + sync once. */ + function flushEvents(): void { + flushScheduled = false; + if (disposed || pendingEvents.length === 0) return; + const batch = pendingEvents; + pendingEvents = []; + applyEvents(batch); + } + + function applyEvents(events: readonly ChatDeltaMessage["event"][]): void { + let t = transcript; + let m = metrics; + let sealed = false; + let failed = false; + let recoveryEnded = false; + let c = cacheTimer; + const at = now(); + for (const event of events) { + t = foldEvent(t, event); + m = foldMetricsEvent(m, event); + const mNow = m; + c = foldCacheTimerEvent(c, event, at, () => lastTurnIdOf(mNow)); + if ( + event.type === "turn-ended" && + recovering && + (recoveringTurnId === undefined || event.turnId === recoveringTurnId) + ) { + finishedRecoveryTurns.add(event.turnId); + finalSyncRequested = true; + recoveringTurnId = undefined; + recovering = false; + recoveryEnded = true; + cancelRecoveryTimer(); + } + if (event.type === "turn-sealed") sealed = true; + else if (event.type === "error") failed = true; + } + transcript = t; + metrics = m; + cacheTimer = c; + maybeTrim(); + if (recovering && recoveringTurnId !== undefined) { + // done/error/seal can precede persistence and the real terminal. Keep + // lifecycle evidence until turn-ended, not merely until a content event. + transcript = markHistoryRecovering(transcript, recoveringTurnId); + } + if (recoveryEnded) { + void syncTail(initialWindowSize(chatLimit)); + void syncMetrics(); + } else if (sealed) { + void syncTail(); + void syncMetrics(); + } else if (failed) { + // A turn that FAILS never seals, but the backend still persists the + // metrics it accumulated — pull them so the turn gets its row. + void syncMetrics(); + } + } + /** * Enforce the chat limit after a transcript mutation — unless the injected * gate says the reader is scrolled up (then defer; the next mutation retries @@ -191,27 +415,142 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { * between the cache and the fetched window. */ async function syncTail(coldLimit?: number): Promise<void> { - if (disposed || _pendingSync) return; + if (disposed) return; + if (_pendingSync) { + rerunRequested = true; + return; + } _pendingSync = true; - try { - const since = await deps.cache.sinceSeq(deps.conversationId); - const window = since === 0 && coldLimit !== undefined ? { limit: coldLimit } : undefined; - const res = await deps.historySync(deps.conversationId, since, window); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); - transcript = applyHistory(transcript, merged); - maybeTrim(); - _error = null; - } catch (err) { - _error = err instanceof Error ? err.message : String(err); - } finally { - _pendingSync = false; + // One request plus at most two sequential reruns. Continued churn waits for + // the next reconnect/seal/explicit resync, never a microtask refetch loop. + let attempts = 0; + do { + attempts++; + rerunRequested = false; + try { + const currentRevision = revision; + let cachedEpoch = await deps.cache.historyEpoch(deps.conversationId); + if (historyEpoch !== undefined && cachedEpoch !== historyEpoch) { + await deps.cache.reset(deps.conversationId, historyEpoch); + cachedEpoch = await deps.cache.historyEpoch(deps.conversationId); + } + const since = + reloadWindow || cachedEpoch === undefined + ? 0 + : await deps.cache.sinceSeq(deps.conversationId); + if (disposed) break; + if (revision !== currentRevision) { + rerunRequested = true; + continue; + } + const limit = + since === 0 + ? reloadWindow || recovering || finishedRecoveryTurns.size > 0 + ? initialWindowSize(chatLimit) + : coldLimit + : undefined; + const finishedAtRequest = [...finishedRecoveryTurns]; + finalSyncRequested = false; + const res = await deps.historySync( + deps.conversationId, + since, + limit === undefined ? undefined : { limit }, + ); + if (disposed) break; + if (revision !== currentRevision) { + rerunRequested = true; + continue; + } + const mismatch = + (cachedEpoch !== undefined && cachedEpoch !== res.historyEpoch) || + (historyEpoch !== undefined && historyEpoch !== res.historyEpoch); + if (mismatch && since !== 0) { + // A cursor response cannot replace a different history. Confirm with + // a new full-window request; don't mistake a lower epoch for staleness. + reloadWindow = true; + rerunRequested = true; + continue; + } + let merged: readonly StoredChunk[]; + if (mismatch || cachedEpoch === undefined) { + const replaced = await deps.cache.replace( + deps.conversationId, + res.chunks, + res.historyEpoch, + cachedEpoch, + ); + const replacedEpoch = await deps.cache.historyEpoch(deps.conversationId); + if (disposed) break; + if (revision !== currentRevision || !replaced || replacedEpoch !== res.historyEpoch) { + rerunRequested = true; + continue; + } + if (mismatch) resetTranscript(res.historyEpoch); + merged = res.chunks; + } else { + merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch); + const committedEpoch = await deps.cache.historyEpoch(deps.conversationId); + if (disposed) break; + if (revision !== currentRevision || committedEpoch !== res.historyEpoch) { + rerunRequested = true; + continue; + } + } + reloadWindow = false; + historyEpoch = res.historyEpoch; + if (since === 0 && (limit === undefined || res.chunks.length < limit)) + earliestSeq = merged[0]?.seq ?? 0; + flushEvents(); + transcript = applyHistory(transcript, merged, finishedAtRequest); + for (const turnId of finishedAtRequest) finishedRecoveryTurns.delete(turnId); + maybeTrim(); + _error = null; + } catch (err) { + _error = err instanceof Error ? err.message : String(err); + } + } while (rerunRequested && !disposed && attempts < 3); + if (rerunRequested && !disposed) { + recoveryPaused = true; + cancelRecoveryTimer(); + _error = "Conversation history changed repeatedly; sync paused until the next refresh."; + } + _pendingSync = false; + // Schedule AFTER the bounded sync burst. No interval can queue unbounded + // reruns behind a slow request; repeated notices coalesce on the same clock. + scheduleRecovery(); + // A terminal landing during the third (bounded epoch) attempt still owns + // one post-terminal request. It is a new external trigger, not an epoch + // retry loop; consume the flag before starting its bounded burst. + if (finalSyncRequested && !disposed) { + finalSyncRequested = false; + void syncTail(initialWindowSize(chatLimit)); } } - async function syncMetrics(): Promise<void> { + let pendingMetrics: Promise<void> | undefined; + let rerunMetrics = false; + function syncMetrics(): Promise<void> { + if (pendingMetrics !== undefined) { + rerunMetrics = true; + return pendingMetrics; + } + pendingMetrics = (async () => { + for (let attempt = 0; attempt < 2; attempt++) { + rerunMetrics = false; + await fetchMetrics(); + if (!rerunMetrics || disposed) break; + } + })().finally(() => { + pendingMetrics = undefined; + }); + return pendingMetrics; + } + + async function fetchMetrics(): Promise<void> { if (disposed) return; try { const res = await deps.metricsSync(deps.conversationId); + if (disposed) return; metrics = applyDurableMetrics(metrics, res.turns); } catch (err) { // Metrics fetch failure must not block history sync or throw; @@ -229,14 +568,27 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { * newest `count` of them). Shared by `showEarlier` and the raise-refill. */ async function backfillOlder(oldest: number, want: number): Promise<readonly StoredChunk[]> { + const currentRevision = revision; let earlier = (await deps.cache.load(deps.conversationId)).filter((c) => c.seq < oldest); + if (disposed || revision !== currentRevision) return []; const oldestKnown = earlier[0]?.seq ?? oldest; - if (earlier.length < want && oldestKnown > 1) { + if (earlier.length < want && (earliestSeq === null || oldestKnown > earliestSeq)) { const res = await deps.historySync(deps.conversationId, 0, { beforeSeq: oldestKnown, limit: want - earlier.length, }); - const merged = await deps.cache.commit(deps.conversationId, res.chunks); + if (disposed || revision !== currentRevision) return []; + if (res.historyEpoch !== historyEpoch) { + // Confirm a backfill mismatch with a fresh full window as well. Bump + // the local revision so an overlapping old tail cannot be applied. + revision++; + reloadWindow = true; + await syncTail(initialWindowSize(chatLimit)); + return []; + } + const merged = await deps.cache.commit(deps.conversationId, res.chunks, res.historyEpoch); + if (disposed || revision !== currentRevision) return []; + if (res.chunks.length < want - earlier.length) earliestSeq = merged[0]?.seq ?? 0; earlier = merged.filter((c) => c.seq < oldest); } return earlier; @@ -246,15 +598,15 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { * Refill toward the fresh-load window after a limit RAISE: pull older * history (cache first, then server) so the loaded set grows to match what a * fresh `load()` would show at the new limit. No-op when already at the - * origin (seq 1) or already within the window. `restoreEarlier` re-derives + * known history boundary or already within the window. `restoreEarlier` re-derives * the window start at apply time, so a delta landing during the await can't * corrupt the merge. NOT gated (refilling prepends above the viewport; the * caller preserves scroll position). */ async function refill(): Promise<void> { if (disposed) return; + if (!hasEarlier()) return; const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; const want = initialWindowSize(chatLimit) - transcript.committed.length; if (want <= 0) return; try { @@ -268,14 +620,18 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { } return { + get pendingInputs() { + return pendingInputs; + }, + discardPendingInput: confirmInput, get messages(): readonly ChatMessage[] { return selectMessages(transcript); }, get chunks(): readonly RenderedChunk[] { - return selectChunks(transcript); + return chunksOf(transcript); }, get turnMetrics(): readonly TurnMetricsEntry[] { - return selectOrderedTurnMetrics(metrics); + return turnMetricsOf(metrics); }, get currentContextSize(): number | undefined { return selectCurrentContextSize(metrics); @@ -286,6 +642,9 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { get providerRetry(): TurnProviderRetryEvent | null { return selectProviderRetry(transcript); }, + get recovering(): boolean { + return recovering; + }, get pendingSync(): boolean { return _pendingSync; }, @@ -296,55 +655,164 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { return _model; }, get hasEarlier(): boolean { - return selectHasEarlier(transcript); + return hasEarlier(); }, get thinkingKeyBase(): number { return transcript.hiddenThinkingCount; }, + get cacheTimer(): CacheTimerState { + return cacheTimer; + }, + + noteLastActivity(at: number): void { + if (selectGenerating(transcript)) return; + cacheTimer = seedTurnGap(cacheTimer, at); + }, - handleDelta(msg: ChatDeltaMessage | ChatErrorMessage): void { + handleDelta( + msg: + | ChatAcceptedMessage + | ChatSubscriptionRejectedMessage + | ChatDeltaMessage + | ChatErrorMessage + | ChatQueuedMessage + | ChatResyncRequiredMessage, + ): void { + if (disposed) return; + if (msg.type === "chat.accepted") { + if (msg.conversationId === deps.conversationId) confirmInput(msg.requestId); + return; + } + if (msg.type === "chat.subscription-rejected") { + if (msg.conversationId === deps.conversationId) { + _error = + "Live updates could not be subscribed. Message delivery is independent; saved inputs remain available below."; + void syncTail(); + } + return; + } + if (msg.type === "chat.resync-required") { + if (msg.conversationId !== deps.conversationId) return; + flushEvents(); + const target = + msg.turnId ?? (transcript.generating ? transcript.currentTurnId : null) ?? undefined; + // Oversized status packets need not identify an active turn. Refresh + // once, but do not invent generation or poll an idle conversation. + if (target === undefined) { + void syncTail(initialWindowSize(chatLimit)); + return; + } + const first = !recovering || recoveryPaused; + recoveryPaused = false; + recovering = true; + recoveringTurnId = target; + if (recoveringTurnId !== undefined) + transcript = markHistoryRecovering(transcript, recoveringTurnId); + // Immediate first handoff, including a coalesced rerun behind a cold + // load. Repeats wait for the existing fetch/one-second timer. + if (first) void syncTail(initialWindowSize(chatLimit)); + return; + } if (msg.type === "chat.error") { if (msg.conversationId !== undefined && msg.conversationId !== deps.conversationId) { return; } _error = msg.message; + if (msg.requestId !== undefined) { + savePendingInputs( + pendingInputs.map((input) => + input.request.requestId === msg.requestId + ? { ...input, rejection: msg.message } + : input, + ), + ); + transcript = removeLocalEcho(transcript, msg.requestId); + } + return; + } + if (msg.type === "chat.queued") { + if (disposed || msg.conversationId !== deps.conversationId) return; + // A queued live opening may already have confirmed the echoed text. + // Fold it first; turn-start alone must not protect still-local artifacts. + flushEvents(); + if (msg.requestId !== undefined) { + confirmInput(msg.requestId); + transcript = removeLocalEcho(transcript, msg.requestId); + } return; } if (msg.event.conversationId !== deps.conversationId) { return; } - transcript = foldEvent(transcript, msg.event); - metrics = foldMetricsEvent(metrics, msg.event); - maybeTrim(); - if (transcript.sealedTurnId !== null) { - void syncTail(); - void syncMetrics(); + if (deps.scheduleFlush === undefined) { + applyEvents([msg.event]); + return; + } + pendingEvents.push(msg.event); + if (!flushScheduled) { + flushScheduled = true; + deps.scheduleFlush(flushEvents); } }, send(text: string, images?: readonly ImageInput[]): void { - transcript = appendUserMessage(transcript, text, images); - maybeTrim(); - const msg: ChatSendMessage = { + flushEvents(); + // Sending ends the wait since the last turn: stamp it on that turn. + cacheTimer = stopTurnGap(cacheTimer, now(), () => lastTurnIdOf(metrics)); + const requestId = deps.createRequestId(); + const msg: ChatSendMessage & { requestId: string } = { type: "chat.send", + requestId, conversationId: deps.conversationId, message: text, ...(_model !== undefined ? { model: _model } : {}), + ...(_namedAgent !== null ? { namedAgent: _namedAgent } : {}), ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}), }; + try { + savePendingInputs([...pendingInputs, { request: msg }]); + } catch (error) { + deps.onError?.("Unable to save input; the message has not been sent", error); + throw error; + } + transcript = appendUserMessage(transcript, text, images, requestId); + maybeTrim(); deps.transport.send(msg); }, - queueMessage(text: string): void { + queueMessage(text: string, images?: readonly ImageInput[]): void { const trimmed = text.trim(); - if (trimmed.length === 0) return; - const msg: ChatQueueMessage = { + if (trimmed.length === 0 && !images?.length) return; + const msg: ChatQueueMessage & { requestId: string } = { type: "chat.queue", + requestId: deps.createRequestId(), conversationId: deps.conversationId, text: trimmed, + ...(images !== undefined && images.length > 0 ? { images: [...images] } : {}), ...(deps.workspaceId !== undefined ? { workspaceId: deps.workspaceId } : {}), }; + try { + savePendingInputs([...pendingInputs, { request: msg }]); + } catch (error) { + deps.onError?.("Unable to save input; the message has not been queued", error); + throw error; + } + deps.transport.send(msg); + }, + + cancelQueuedMessage(messageId: string): void { + // Fire-and-forget + idempotent (per the contract). The caller optimistically + // hides the row; the message-queue surface update reconciles. A cancel of + // an already-drained / unknown message is a silent server no-op, so there + // is no local-state change to make and nothing to roll back on a stray + // `chat.error` (which only fires for a malformed send — a client that + // sends the id it just rendered never hits it). + const msg: ChatQueueCancelMessage = { + type: "chat.queue.cancel", + conversationId: deps.conversationId, + messageId, + }; deps.transport.send(msg); }, @@ -352,6 +820,10 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { _model = model; }, + setNamedAgent(snapshot: NamedAgentSelectionSnapshot | null): void { + _namedAgent = snapshot; + }, + async setChatLimit(limit: number): Promise<void> { const prev = chatLimit; chatLimit = normalizeChatLimit(limit); @@ -369,8 +841,11 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { // server instead (CR-5 `?limit=`), so a huge conversation never ships // whole. The post-sync window re-asserts the cap either way. const windowSize = initialWindowSize(chatLimit); + const currentRevision = revision; + const cachedEpoch = await deps.cache.historyEpoch(deps.conversationId); const cached = await deps.cache.load(deps.conversationId); - if (cached.length > 0) { + if (!disposed && revision === currentRevision && cachedEpoch !== undefined) { + historyEpoch = cachedEpoch; transcript = windowTranscript(applyHistory(transcript, cached), windowSize); } await syncTail(windowSize); @@ -381,7 +856,7 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { async showEarlier(): Promise<void> { if (disposed) return; const oldest = transcript.committed[0]?.seq ?? transcript.hiddenBeforeSeq; - if (oldest <= 1) return; + if (!hasEarlier()) return; const want = unloadCount(chatLimit); try { const earlier = await backfillOlder(oldest, want); @@ -392,18 +867,34 @@ export function createChatStore(deps: ChatStoreDependencies): ChatStore { } }, - resync(): void { + invalidateHistory(epoch): void { + if (!disposed && (historyEpoch === undefined || epoch > historyEpoch)) resetTranscript(epoch); + }, + + resync(epoch?: number): void { if (disposed) return; + if (epoch !== undefined && (historyEpoch === undefined || epoch > historyEpoch)) + resetTranscript(epoch); // A turn may have sealed while we were disconnected (missed `turn-sealed`): // clear the now-stale spinner BEFORE re-subscribing, so a finished turn // doesn't spin forever. A still-running turn's replay re-asserts it. - transcript = clearGenerating(transcript); + flushEvents(); + recoveryPaused = false; + if (epoch === undefined) { + recovering = false; + recoveringTurnId = undefined; + cancelRecoveryTimer(); + transcript = clearGenerating(transcript); + } void syncTail(); void syncMetrics(); }, dispose(): void { disposed = true; + cancelRecoveryTimer(); + finishedRecoveryTurns.clear(); + pendingEvents = []; }, }; } diff --git a/src/features/chat/store.test.ts b/src/features/chat/store.test.ts index 8f36994..5040e67 100644 --- a/src/features/chat/store.test.ts +++ b/src/features/chat/store.test.ts @@ -1,5 +1,8 @@ import type { AgentEvent, ImageInput, StepId, StoredChunk } from "@dispatch/wire"; +import { IDBFactory, IDBKeyRange } from "fake-indexeddb"; import { describe, expect, it, vi } from "vitest"; +import { createIdbChunkStore } from "../../adapters/idb"; +import { createConversationCache } from "../conversation-cache"; import { createChatStore } from "./store.svelte"; import { createFakeCache, @@ -29,6 +32,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -61,6 +65,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -104,6 +109,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -128,6 +134,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, model: "openai/gpt-4", transport: transport.impl, @@ -150,6 +157,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -182,6 +190,7 @@ describe("createChatStore", () => { it("send omits images on the wire when none are staged (backward compatible)", () => { const transport = createFakeTransport(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: createFakeHistorySync().impl, @@ -198,6 +207,7 @@ describe("createChatStore", () => { it("send omits images on the wire for an empty array", () => { const transport = createFakeTransport(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: createFakeHistorySync().impl, @@ -214,6 +224,7 @@ describe("createChatStore", () => { it("send allows an images-only message (empty text)", () => { const transport = createFakeTransport(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: createFakeHistorySync().impl, @@ -238,6 +249,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -252,16 +264,52 @@ describe("createChatStore", () => { expect(transport.sentQueue[0]?.type).toBe("chat.queue"); expect(transport.sentQueue[0]?.conversationId).toBe(CONV_ID); expect(transport.sentQueue[0]?.text).toBe("steer left"); + expect(transport.sentQueue[0]).not.toHaveProperty("images"); store.dispose(); }); + it.each([" describe this ", "", " "])("queues images with text %j", (text) => { + const transport = createFakeTransport(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + workspaceId: "images-workspace", + transport: transport.impl, + historySync: createFakeHistorySync().impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + }); + const images: readonly ImageInput[] = [ + { url: "data:image/png;base64,AAAA", mimeType: "image/png" }, + { url: "https://example.com/photo.jpg" }, + ]; + + store.queueMessage(text, images); + + expect(transport.sentQueue).toEqual([ + { + type: "chat.queue", + requestId: expect.any(String), + conversationId: CONV_ID, + workspaceId: "images-workspace", + text: text.trim(), + images, + }, + ]); + expect(transport.sentQueue[0]?.images).not.toBe(images); + expect(transport.sent).toHaveLength(0); + expect(store.chunks).toHaveLength(0); + store.dispose(); + }); + it("trims whitespace before sending", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -282,6 +330,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -291,6 +340,7 @@ describe("createChatStore", () => { store.queueMessage(" "); store.queueMessage(""); + store.queueMessage("", []); expect(transport.sentQueue).toHaveLength(0); @@ -303,6 +353,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -318,12 +369,88 @@ describe("createChatStore", () => { }); }); + describe("cancelQueuedMessage (chat.queue.cancel)", () => { + it("posts a chat.queue.cancel with conversationId + messageId", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(transport.sent).toHaveLength(0); // chat.send stays empty + expect(transport.sentQueue).toHaveLength(0); // chat.queue stays empty + expect(transport.sentCancels).toHaveLength(1); + expect(transport.sentCancels[0]?.type).toBe("chat.queue.cancel"); + expect(transport.sentCancels[0]?.conversationId).toBe(CONV_ID); + expect(transport.sentCancels[0]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + + it("does NOT touch the transcript (a cancelled message never runs)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + store.cancelQueuedMessage("msg-42"); + + expect(store.chunks).toHaveLength(0); // no transcript echo / change + expect(store.error).toBeNull(); + + store.dispose(); + }); + + it("sends for any messageId (cancel is idempotent server-side, no FE guard)", () => { + const transport = createFakeTransport(); + const historySync = createFakeHistorySync(); + const metricsSync = createFakeMetricsSync(); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: transport.impl, + historySync: historySync.impl, + metricsSync: metricsSync.impl, + cache: cache.impl, + }); + + // The server no-ops an already-drained / unknown id; the FE fires-and- + // forgetgets, so even a repeat cancel is forwarded. + store.cancelQueuedMessage("msg-42"); + store.cancelQueuedMessage("msg-42"); + + expect(transport.sentCancels).toHaveLength(2); + expect(transport.sentCancels[1]?.messageId).toBe("msg-42"); + + store.dispose(); + }); + }); + it("chat.error sets error", () => { const transport = createFakeTransport(); const historySync = createFakeHistorySync(); const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -353,6 +480,7 @@ describe("createChatStore", () => { historySync.returnChunks = [makeStoredChunk(3, "assistant")]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -381,6 +509,7 @@ describe("createChatStore", () => { historySync.returnChunks = [makeStoredChunk(1, "assistant")]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -402,6 +531,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -434,6 +564,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -456,7 +587,7 @@ describe("createChatStore", () => { store.dispose(); }); - it("overlapping syncs are guarded", async () => { + it("overlapping syncs coalesce into a sequential rerun", async () => { const transport = createFakeTransport(); const _historySync = createFakeHistorySync(); const metricsSync = createFakeMetricsSync(); @@ -474,10 +605,11 @@ describe("createChatStore", () => { if (callCount === 1) { await firstSyncPromise; } - return { chunks: [], latestSeq: sinceSeq }; + return { chunks: [], latestSeq: sinceSeq, historyEpoch: 0 }; }; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: slowHistorySync, @@ -501,7 +633,7 @@ describe("createChatStore", () => { // Release the first sync resolveFirstSync?.(); - await new Promise((r) => setTimeout(r, 10)); + await vi.waitFor(() => expect(callCount).toBe(2)); store.dispose(); }); @@ -512,6 +644,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -557,6 +690,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, model: "openai/gpt-4", transport: transport.impl, @@ -581,6 +715,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -604,6 +739,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -634,6 +770,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -654,6 +791,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -678,6 +816,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -716,6 +855,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -777,6 +917,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -821,6 +962,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -893,6 +1035,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -959,6 +1102,7 @@ describe("createChatStore", () => { ]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -983,6 +1127,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1014,6 +1159,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1038,6 +1184,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1072,6 +1219,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1118,6 +1266,7 @@ describe("createChatStore", () => { const cache = createFakeCache(); let atBottom = false; // reader scrolled up const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1172,6 +1321,7 @@ describe("createChatStore", () => { const cache = createFakeCache(); let atBottom = false; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1210,6 +1360,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1239,6 +1390,7 @@ describe("createChatStore", () => { historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1274,6 +1426,7 @@ describe("createChatStore", () => { historySync.returnChunks = [makeStoredChunk(3)]; const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1298,6 +1451,7 @@ describe("createChatStore", () => { historySync.returnChunks = Array.from({ length: 500 }, (_, i) => makeStoredChunk(i + 1)); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1335,6 +1489,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1367,6 +1522,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1398,6 +1554,7 @@ describe("createChatStore", () => { ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1430,6 +1587,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1467,6 +1625,7 @@ describe("createChatStore", () => { Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)), ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1498,6 +1657,7 @@ describe("createChatStore", () => { // Server holds 200; cold-cache load at limit 100 → window 75 → seqs 126..200. historySync.returnChunks = Array.from({ length: 200 }, (_, i) => makeStoredChunk(i + 1)); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1525,6 +1685,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1564,6 +1725,7 @@ describe("createChatStore", () => { Array.from({ length: 50 }, (_, i) => makeStoredChunk(i + 1)), ); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1573,6 +1735,7 @@ describe("createChatStore", () => { }); await store.load(); // only 50 chunks → all loaded, window starts at seq 1 expect(store.chunks).toHaveLength(50); + await store.showEarlier(); // Probe server exhaustion; seq 1 alone is not a boundary. expect(store.hasEarlier).toBe(false); const callsAfterLoad = historySync.calls.length; @@ -1590,6 +1753,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1619,6 +1783,7 @@ describe("createChatStore", () => { const metricsSync = createFakeMetricsSync(); const cache = createFakeCache(); const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), conversationId: CONV_ID, transport: transport.impl, historySync: historySync.impl, @@ -1634,3 +1799,467 @@ describe("createChatStore", () => { expect(metricsSync.calls).toHaveLength(0); }); }); + +describe("createChatStore — batched delta application (scheduleFlush)", () => { + function setup() { + const scheduled: (() => void)[] = []; + const historySync = createFakeHistorySync(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: historySync.impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + scheduleFlush: (flush) => scheduled.push(flush), + }); + const runScheduled = (): void => { + for (const f of scheduled.splice(0)) f(); + }; + return { store, scheduled, runScheduled, historySync }; + } + const text = (delta: string) => + deltaEvent({ type: "text-delta", conversationId: CONV_ID, turnId: "t1", delta }); + + it("queues a burst and applies it in ONE scheduled flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + for (let i = 0; i < 500; i++) store.handleDelta(text("x")); + + expect(scheduled).toHaveLength(1); + expect(store.chunks).toHaveLength(0); + + runScheduled(); + expect(store.chunks).toHaveLength(1); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("x".repeat(500)); + expect(store.generating).toBe(true); + store.dispose(); + }); + + it("schedules a new flush for events arriving after a flush", () => { + const { store, scheduled, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + store.handleDelta(text("b")); + expect(scheduled).toHaveLength(1); + runScheduled(); + expect((store.chunks[0]?.chunk as { text: string }).text).toBe("ab"); + store.dispose(); + }); + + it("a sealed turn in the batch still triggers a history sync", async () => { + const { store, runScheduled, historySync } = setup(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + store.handleDelta(text("done")); + store.handleDelta(deltaEvent({ type: "turn-sealed", conversationId: CONV_ID, turnId: "t1" })); + expect(historySync.calls).toHaveLength(0); + runScheduled(); + await vi.waitFor(() => expect(historySync.calls.length).toBeGreaterThan(0)); + store.dispose(); + }); + + it("send() flushes queued events first so the user echo lands after them", () => { + const { store } = setup(); + store.handleDelta(text("earlier reply")); + store.send("next question"); + const roles = store.chunks.map((c) => c.role); + expect(roles).toEqual(["assistant", "user"]); + store.dispose(); + }); + + it("chunks getter returns a stable array for an unchanged transcript", () => { + const { store, runScheduled } = setup(); + store.handleDelta(text("a")); + runScheduled(); + expect(store.chunks).toBe(store.chunks); + store.dispose(); + }); + + it("a flush after dispose is a no-op", () => { + const { store, scheduled } = setup(); + store.handleDelta(text("a")); + store.dispose(); + for (const f of scheduled) f(); + expect(store.chunks).toHaveLength(0); + }); +}); + +describe("createChatStore — cacheTimer", () => { + function makeStore(now: () => number) { + return createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: createFakeHistorySync().impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + now, + }); + } + const call = (stepId: string, toolCallId: string) => + deltaEvent({ + type: "tool-call", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + toolCallId, + toolName: "read_file", + input: {}, + }); + const result = (stepId: string, toolCallId: string) => + deltaEvent({ + type: "tool-result", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + toolCallId, + toolName: "read_file", + content: "ok", + isError: false, + }); + const stepComplete = (stepId: string) => + deltaEvent({ + type: "step-complete", + conversationId: CONV_ID, + turnId: "t1", + stepId: stepId as StepId, + }); + const done = (turnId: string) => + deltaEvent({ type: "done", conversationId: CONV_ID, turnId, reason: "end-turn" }); + + it("is idle until something happens", () => { + const store = makeStore(() => 1000); + expect(store.cacheTimer.running).toBeNull(); + store.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t1" })); + expect(store.cacheTimer.running).toBeNull(); + store.dispose(); + }); + + it("times a step's tool execution with the injected clock and stamps it", () => { + let clock = 0; + const store = makeStore(() => clock); + store.handleDelta(call("t1#0", "a")); + clock = 1_000; + store.handleDelta(stepComplete("t1#0")); + expect(store.cacheTimer.running).toMatchObject({ kind: "step", startedAt: 1_000 }); + clock = 31_000; + store.handleDelta(result("t1#0", "a")); + expect(store.cacheTimer.running).toBeNull(); + expect(store.cacheTimer.stepStamps.get("t1#0")).toBe(30_000); + store.dispose(); + }); + + it("starts a turn gap on done and stamps it on send", () => { + let clock = 5_000; + const store = makeStore(() => clock); + store.handleDelta(done("t1")); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 }); + clock = 125_000; + store.send("next"); + expect(store.cacheTimer.running).toBeNull(); + expect(store.cacheTimer.turnStamps.get("t1")).toBe(120_000); + store.dispose(); + }); + + it("ignores another conversation's events", () => { + const store = makeStore(() => 5_000); + store.handleDelta( + deltaEvent({ type: "done", conversationId: "other", turnId: "t1", reason: "end-turn" }), + ); + expect(store.cacheTimer.running).toBeNull(); + store.dispose(); + }); + + it("noteLastActivity seeds a turn gap, keeps the later start, and is ignored while generating", () => { + const store = makeStore(() => 5_000); + store.noteLastActivity(3_000); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: null, startedAt: 3_000 }); + store.handleDelta(done("t1")); + expect(store.cacheTimer.running).toEqual({ kind: "turn", turnId: "t1", startedAt: 5_000 }); + store.noteLastActivity(4_000); // an older seed never rewinds it + expect(store.cacheTimer.running).toMatchObject({ startedAt: 5_000 }); + store.dispose(); + + const busy = makeStore(() => 5_000); + busy.handleDelta(deltaEvent({ type: "turn-start", conversationId: CONV_ID, turnId: "t2" })); + busy.noteLastActivity(4_000); + expect(busy.cacheTimer.running).toBeNull(); + busy.dispose(); + }); +}); + +describe("history epochs", () => { + it.each([ + "tail", + "backfill", + ])("recovers missed compaction on %s and exhausts non-1 history", async (path) => { + const history = createFakeHistorySync(); + const cache = createFakeCache(); + history.returnChunks = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1)); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache: cache.impl, + chatLimit: 10, + }); + await store.load(); + history.historyEpoch = 1; + history.returnChunks = [makeStoredChunk(101), makeStoredChunk(102)]; + if (path === "tail") { + store.resync(); + await vi.waitFor(() => expect(store.chunks[0]?.seq).toBe(101)); + } else await store.showEarlier(); + expect(store.chunks.map((c) => c.seq)).toEqual([101, 102]); + expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101, 102]); + expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } }); + expect(store.hasEarlier).toBe(false); + const calls = history.calls.length; + await store.showEarlier(); + expect(history.calls).toHaveLength(calls); + store.dispose(); + }); +}); + +it("a compaction broadcast during an in-flight fetch discards the old response", async () => { + const history = createFakeHistorySync(); + history.returnChunks = [makeStoredChunk(101)]; + let resolve!: (res: import("@dispatch/transport-contract").ConversationHistoryResponse) => void; + const first = new Promise<import("@dispatch/transport-contract").ConversationHistoryResponse>( + (r) => { + resolve = r; + }, + ); + const sync = vi + .fn<import("./ports").HistorySync>() + .mockImplementationOnce(() => first) + .mockImplementation(history.impl); + const cache = createFakeCache(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: sync, + metricsSync: createFakeMetricsSync().impl, + cache: cache.impl, + }); + const loading = store.load(); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(1)); + history.historyEpoch = 1; + store.resync(1); + resolve({ chunks: [makeStoredChunk(1)], latestSeq: 1, historyEpoch: 0 }); + await loading; + expect(sync).toHaveBeenCalledTimes(2); + expect(store.chunks.map((c) => c.seq)).toEqual([101]); + expect((await cache.impl.load(CONV_ID)).map((c) => c.seq)).toEqual([101]); + store.dispose(); +}); + +it("reloads unknown legacy cache epochs rather than merging pre-upgrade rows", async () => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const storage = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const cache = createConversationCache(storage); + await storage.append(CONV_ID, [makeStoredChunk(1)]); + const history = createFakeHistorySync(); + history.returnChunks = [makeStoredChunk(101)]; + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache, + }); + try { + await store.load(); + expect(history.calls[0]).toMatchObject({ sinceSeq: 0 }); + expect(store.chunks.map((c) => c.seq)).toEqual([101]); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect((await cache.load(CONV_ID)).map((c) => c.seq)).toEqual([101]); + } finally { + store.dispose(); + vi.unstubAllGlobals(); + } +}); + +describe("server epoch rollback", () => { + it.each([ + "tail", + "backfill", + ])("converges on %s with a real cache and a fresh bounded fetch", async (path) => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const storage = createIdbChunkStore({ indexedDB: new IDBFactory() }); + const cache = createConversationCache(storage); + const old = Array.from({ length: 20 }, (_, i) => makeStoredChunk(i + 1)); + await cache.commit(CONV_ID, path === "backfill" ? old.slice(-7) : old, 5); + const history = createFakeHistorySync(); + history.historyEpoch = 5; + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache, + chatLimit: 10, + }); + try { + await store.load(); + history.historyEpoch = 0; + // Recreated ids can reuse seqs with completely different content. + const restored: StoredChunk = { + seq: 1, + role: "user", + chunk: { type: "text", text: "restored" }, + }; + history.returnChunks = [restored]; + const before = history.calls.length; + if (path === "tail") { + store.resync(); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + } else await store.showEarlier(); + expect(history.calls.length - before).toBeLessThanOrEqual(2); + expect(history.calls.at(-1)).toMatchObject({ sinceSeq: 0, window: { limit: 7 } }); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect(await cache.load(CONV_ID)).toEqual([restored]); + expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]); + expect(store.hasEarlier).toBe(false); + expect(store.error).toBeNull(); + } finally { + store.dispose(); + vi.unstubAllGlobals(); + } + }); + + it("bounds reruns even when every response is invalidated by another compaction", async () => { + let calls = 0; + let churn = true; + let store!: ReturnType<typeof createChatStore>; + const sync: import("./ports").HistorySync = async () => { + calls++; + // Stop the old broken loop safely rather than hanging the test worker. + if (churn && calls <= 6) store.resync(calls); + return { chunks: [], latestSeq: 0, historyEpoch: calls - 1 }; + }; + store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: sync, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + }); + try { + await store.load(); + expect(calls).toBe(3); + expect(store.pendingSync).toBe(false); + expect(store.error).toMatch(/history changed repeatedly/i); + churn = false; + store.resync(); + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(calls).toBe(4); + expect(store.error).toBeNull(); + } finally { + store.dispose(); + } + }); +}); + +it.each([ + "tail", + "backfill", +])("rejects a delayed old %s after a lower-epoch history is observed", async (delayed) => { + vi.stubGlobal("IDBKeyRange", IDBKeyRange); + const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() })); + await cache.commit( + CONV_ID, + Array.from({ length: 7 }, (_, i) => makeStoredChunk(14 + i)), + 5, + ); + const restored: StoredChunk = { + seq: 1, + role: "user", + chunk: { type: "text", text: "new history" }, + }; + type Response = import("@dispatch/transport-contract").ConversationHistoryResponse; + let resolve!: (res: Response) => void; + const slow = new Promise<Response>((r) => { + resolve = r; + }); + let delayNext = false; + let epoch = 5; + const sync = vi.fn<import("./ports").HistorySync>().mockImplementation(async () => { + if (delayNext) { + delayNext = false; + return slow; + } + return { + chunks: epoch === 5 ? [] : [restored], + latestSeq: epoch === 5 ? 20 : 1, + historyEpoch: epoch, + }; + }); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: sync, + metricsSync: createFakeMetricsSync().impl, + cache, + chatLimit: 10, + }); + try { + await store.load(); + delayNext = true; + let earlier: Promise<void>; + if (delayed === "tail") { + store.resync(); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2)); + epoch = 0; + earlier = store.showEarlier(); + await earlier; // Invalidates the old tail and requests a sequential rerun. + } else { + earlier = store.showEarlier(); + await vi.waitFor(() => expect(sync).toHaveBeenCalledTimes(2)); + epoch = 0; + store.resync(); + await vi.waitFor(() => expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk])); + } + resolve({ chunks: [makeStoredChunk(21)], latestSeq: 21, historyEpoch: 5 }); + await earlier; + await vi.waitFor(() => expect(store.pendingSync).toBe(false)); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect(await cache.load(CONV_ID)).toEqual([restored]); + expect(store.chunks.map((c) => c.chunk)).toEqual([restored.chunk]); + expect(sync).toHaveBeenCalledTimes(4); + } finally { + store.dispose(); + vi.unstubAllGlobals(); + } +}); + +it("replaces an empty high-epoch cache with the server's empty lower epoch in one request", async () => { + const cache = createConversationCache(createIdbChunkStore({ indexedDB: new IDBFactory() })); + await cache.reset(CONV_ID, 8); + const history = createFakeHistorySync(); + const store = createChatStore({ + createRequestId: () => crypto.randomUUID(), + conversationId: CONV_ID, + transport: createFakeTransport().impl, + historySync: history.impl, + metricsSync: createFakeMetricsSync().impl, + cache, + }); + try { + await store.load(); + expect(history.calls).toHaveLength(1); + expect(await cache.historyEpoch(CONV_ID)).toBe(0); + expect(await cache.load(CONV_ID)).toEqual([]); + expect(store.chunks).toEqual([]); + expect(store.hasEarlier).toBe(false); + } finally { + store.dispose(); + } +}); diff --git a/src/features/chat/test-helpers.ts b/src/features/chat/test-helpers.ts index 26c5590..41cfb3b 100644 --- a/src/features/chat/test-helpers.ts +++ b/src/features/chat/test-helpers.ts @@ -1,4 +1,8 @@ -import type { ChatQueueMessage, ChatSendMessage } from "@dispatch/transport-contract"; +import type { + ChatQueueCancelMessage, + ChatQueueMessage, + ChatSendMessage, +} from "@dispatch/transport-contract"; import type { StoredChunk } from "@dispatch/wire"; import type { ConversationCache } from "../conversation-cache"; import type { ChatTransport, HistorySync, HistoryWindow, MetricsSync } from "./ports"; @@ -8,19 +12,25 @@ export interface FakeTransport { readonly sent: ChatSendMessage[]; /** All `chat.queue` messages sent through the fake transport. */ readonly sentQueue: ChatQueueMessage[]; + /** All `chat.queue.cancel` messages sent through the fake transport. */ + readonly sentCancels: ChatQueueCancelMessage[]; readonly impl: ChatTransport; } export function createFakeTransport(): FakeTransport { const sent: ChatSendMessage[] = []; const sentQueue: ChatQueueMessage[] = []; + const sentCancels: ChatQueueCancelMessage[] = []; return { sent, sentQueue, + sentCancels, impl: { send(msg) { if (msg.type === "chat.queue") { sentQueue.push(msg); + } else if (msg.type === "chat.queue.cancel") { + sentCancels.push(msg); } else { sent.push(msg); } @@ -33,14 +43,22 @@ export interface FakeHistorySync { readonly calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }>; /** Set the chunks to return on the next call. */ returnChunks: readonly StoredChunk[]; + historyEpoch: number; readonly impl: HistorySync; } export function createFakeHistorySync(): FakeHistorySync { const calls: Array<{ conversationId: string; sinceSeq: number; window?: HistoryWindow }> = []; let returnChunks: readonly StoredChunk[] = []; + let historyEpoch = 0; return { calls, + get historyEpoch() { + return historyEpoch; + }, + set historyEpoch(v: number) { + historyEpoch = v; + }, get returnChunks() { return returnChunks; }, @@ -62,7 +80,7 @@ export function createFakeHistorySync(): FakeHistorySync { chunks = chunks.slice(-window.limit); } const latestSeq = chunks.length > 0 ? Math.max(...chunks.map((c) => c.seq)) : sinceSeq; - return { chunks, latestSeq }; + return { chunks, latestSeq, historyEpoch }; }, }; } @@ -112,13 +130,28 @@ export interface FakeCache { export function createFakeCache(): FakeCache { const store = new Map<string, StoredChunk[]>(); + const epochs = new Map<string, number>(); return { store, impl: { + async historyEpoch(id) { + return epochs.get(id) ?? 0; + }, + async reset(id, epoch) { + store.delete(id); + epochs.set(id, epoch); + }, + async replace(id, incoming, epoch, expectedEpoch) { + if ((epochs.get(id) ?? 0) !== expectedEpoch) return false; + store.set(id, [...incoming]); + epochs.set(id, epoch); + return true; + }, async load(conversationId) { return store.get(conversationId) ?? []; }, - async commit(conversationId, incoming) { + async commit(conversationId, incoming, epoch) { + if (epoch !== undefined) epochs.set(conversationId, epoch); const existing = store.get(conversationId) ?? []; const seen = new Set(existing.map((c) => c.seq)); const toAppend = incoming.filter((c) => !seen.has(c.seq)); diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts index 5f8067d..f98fdbb 100644 --- a/src/features/chat/ui.test.ts +++ b/src/features/chat/ui.test.ts @@ -3,12 +3,26 @@ import { render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; import type { RenderedChunk } from "../../core/chunks"; -import type { TurnMetricsEntry } from "../../core/metrics"; +import { + type CacheTimerState, + initialCacheTimerState, + type TurnMetricsEntry, +} from "../../core/metrics"; +import type { ConvertedJpeg } from "./adapter/image-encode"; +import CacheTimerBadge from "./ui/CacheTimerBadge.svelte"; import ChatView from "./ui/ChatView.svelte"; import Composer from "./ui/Composer.svelte"; import ModelSelector from "./ui/ModelSelector.svelte"; import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte"; +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise<void> { + const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + describe("ChatView", () => { it("renders a message's text chunk", () => { const chunks: RenderedChunk[] = [ @@ -42,6 +56,25 @@ describe("ChatView", () => { expect(screen.getByText("Hello!")).toBeInTheDocument(); }); + it("renders local-path Markdown images through the backend image route", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { type: "text", text: "" }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { + props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" }, + }); + + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + }); + it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => { const chunks: RenderedChunk[] = [ { seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false }, @@ -81,7 +114,7 @@ describe("ChatView", () => { expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument(); }); - it("renders tool-call chunks", () => { + it("renders tool-call chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -96,16 +129,17 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); const pre = screen.getByText((content, element) => { return element?.tagName === "PRE" && content.includes("/tmp/test.txt"); }); expect(pre).toBeInTheDocument(); }); - it("renders tool-result chunks", () => { + it("renders tool-result chunks", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -121,9 +155,10 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getByText("read_file")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); expect(screen.getByText("file contents here")).toBeInTheDocument(); }); @@ -199,7 +234,7 @@ describe("ChatView", () => { expect(log.children).toHaveLength(0); }); - it("groups batched tool calls (shared stepId) into one DaisyUI list", () => { + it("renders parallel tool calls (shared stepId) as separate bubbles", async () => { const chunks: RenderedChunk[] = [ { seq: 1, @@ -241,15 +276,18 @@ describe("ChatView", () => { ]; const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - // Batched calls render as collapsible cards (one per call), not a list. + // Parallel calls render as independent collapsible cards (one per bubble). const collapses = container.querySelectorAll(".collapse"); expect(collapses).toHaveLength(2); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2); + // Both call names + the available result are shown; the result is absorbed // (no standalone tool-result card). - expect(screen.getByText("read_file")).toBeInTheDocument(); - expect(screen.getByText("list_dir")).toBeInTheDocument(); + expect(screen.getByText("Read file")).toBeInTheDocument(); + expect(screen.getByText("List dir")).toBeInTheDocument(); expect(screen.getByText("contents-of-a")).toBeInTheDocument(); }); @@ -271,8 +309,7 @@ describe("ChatView", () => { expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon expect(collapse).not.toHaveClass("collapse-plus"); // Visible bubble, like tool cards. - expect(collapse).toHaveClass("bg-base-200"); - expect(collapse).toHaveClass("rounded-box"); + expect(collapse).toHaveClass("inset-well"); // shared recessed panel (app.css) expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument(); }); @@ -372,11 +409,39 @@ describe("ChatView", () => { expect(screen.getByText("Hi")).toBeInTheDocument(); expect(screen.getByText("Hello!")).toBeInTheDocument(); expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/150 tok/)).toHaveLength(2); - expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument(); expect(screen.getByText(/1\.2s/)).toBeInTheDocument(); }); + it("renders a per-step cache hit badge coloured by level", () => { + const step = { + stepId: "t1#0" as StepId, + usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 750 }, + decodeMs: 100, + }; + const chunks: RenderedChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "tc1", + toolName: "t", + input: {}, + stepId: step.stepId, + }, + provisional: false, + }, + ]; + const turnMetrics: TurnMetricsEntry[] = [{ turnId: "t1", steps: [step], total: null }]; + render(ChatView, { props: { chunks, turnMetrics } }); + const badge = screen.getByTestId("step-cache-badge"); + expect(badge).toHaveTextContent("75%"); + expect(badge).toHaveClass("badge-success"); + }); + it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => { const chunks: RenderedChunk[] = [ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false }, @@ -477,11 +542,11 @@ describe("ChatView", () => { // Both step-metrics and turn-metrics render expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 20 tok out/)).toBeInTheDocument(); // They are in separate elements (different rows) - const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div"); - const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div"); + const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div"); + const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div"); expect(stepEl).not.toBe(turnEl); }); @@ -553,9 +618,9 @@ describe("ChatView", () => { // Step metrics rendered expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getAllByText(/15 tok/)).toHaveLength(2); + expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument(); // Turn metrics rendered - expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument(); + expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument(); // No "null" or "undefined" in the DOM expect(screen.queryByText("null")).toBeNull(); expect(screen.queryByText("undefined")).toBeNull(); @@ -601,8 +666,7 @@ describe("ChatView", () => { render(ChatView, { props: { chunks, turnMetrics } }); // Step metrics should render - expect(screen.getByText(/step 1/)).toBeInTheDocument(); - expect(screen.getByText(/150 tok/)).toBeInTheDocument(); + expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument(); // Turn total should NOT render (total is null — turn still in progress) expect(screen.queryByText(/^turn/)).toBeNull(); @@ -707,7 +771,7 @@ describe("ChatView", () => { expect(container.querySelector("img")?.getAttribute("src")).toBe(url); }); - it("renders a consult_vision tool call/result like any other tool", () => { + it("renders a consult_vision tool call/result like any other tool", async () => { // read_image is GONE — replaced by consult_vision (opens a vision-model // conversation, attaches the image + question, returns the answer). It is // a normal tool call: rendered generically by toolName. @@ -737,9 +801,10 @@ describe("ChatView", () => { }, ]; - render(ChatView, { props: { chunks } }); + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); - expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0); + expect(screen.getAllByText("Ask vision model").length).toBeGreaterThan(0); expect(screen.getByText("a red square on a white background")).toBeInTheDocument(); }); @@ -784,6 +849,71 @@ describe("ChatView", () => { }); describe("Composer", () => { + it.each([ + { status: "idle", queuedMessageCount: 0, text: "", label: "Send" }, + { status: "running", queuedMessageCount: 0, text: "next", label: "Queue" }, + { status: "running", queuedMessageCount: 0, text: "", label: "Stop generation" }, + { status: "running", queuedMessageCount: 1, text: "", label: "Flush" }, + ] as const)("keeps $label at the same fixed width", async ({ label, text, ...props }) => { + const user = userEvent.setup(); + render(Composer, { props: { ...props, onSend: vi.fn(), onQueue: vi.fn(), onStop: vi.fn() } }); + if (text) await user.type(screen.getByRole("textbox", { name: "Message input" }), text); + const button = screen.getByRole("button", { name: label }); + expect(button).toHaveClass("w-20", "shrink-0"); + expect(button).not.toHaveClass("w-36"); + }); + + it.each([ + "running", + "queued", + ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => { + const onStop = vi.fn(); + const onSend = vi.fn(); + const onQueue = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } }); + const expectPlainStop = () => { + const button = screen.getByRole("button", { name: "Stop generation" }); + expect(button).toHaveTextContent(/^Stop$/); + expect(button).not.toHaveAttribute("title"); + }; + expectPlainStop(); // No queue snapshot available. + await rerender({ queuedMessageCount: 0 }); + expectPlainStop(); + await user.click(screen.getByRole("button", { name: "Stop generation" })); + expect(onStop).toHaveBeenCalledTimes(1); + + for (const queuedMessageCount of [1, 2]) { + await rerender({ queuedMessageCount }); + const button = screen.getByRole("button", { name: "Flush" }); + expect(button).toHaveTextContent(/^Flush$/); + expect(button).toHaveAttribute( + "title", + "Stop the current response and start the queued message now", + ); + } + await user.click(screen.getByRole("button", { name: "Flush" })); + expect(onStop).toHaveBeenCalledTimes(2); + expect(onSend).not.toHaveBeenCalled(); + expect(onQueue).not.toHaveBeenCalled(); + + const input = screen.getByRole("textbox", { name: "Message input" }); + await user.type(input, "next"); + expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Flush" })).toBeNull(); + await user.click(screen.getByRole("button", { name: "Queue" })); + expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined); + expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument(); + await rerender({ queuedMessageCount: 0 }); + expectPlainStop(); // Queue drained or cancelled. + await rerender({ queuedMessageCount: 1, status: "idle" }); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + await rerender({ status: "error" }); + expect(screen.getByRole("button", { name: "Send" })).toBeDisabled(); + await rerender({ status, queuedMessageCount: undefined }); + expectPlainStop(); + }); + it("calls onSend with the typed text and clears", async () => { const onSend = vi.fn(); const user = userEvent.setup(); @@ -964,17 +1094,28 @@ describe("Composer", () => { expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); }); - it("queues (steers) text-only and never forwards images", async () => { - // While running, the Send button becomes "Queue"; steering is text-only. + it("still queues text-only with images omitted", async () => { const onQueue = vi.fn(); const onSend = vi.fn(); const user = userEvent.setup(); - const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } }); + render(Composer, { props: { onSend, onQueue, status: "running" } }); + await user.type(screen.getByRole("textbox", { name: "Message input" }), "steer{Enter}"); + expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer", undefined); + expect(onSend).not.toHaveBeenCalled(); + }); + + it("queues text and images while running and clears the composer", async () => { + const onQueue = vi.fn(); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { + props: { onSend, onQueue, status: "running", encodeJpeg: async () => null }, + }); const textarea = screen.getByRole("textbox", { name: "Message input" }); await user.type(textarea, "steer here"); - // Also stage an image — it must NOT be forwarded on a queue. + // Stage an image along with the steering text. const file = new File(["PNG"], "shot.png", { type: "image/png" }); const input = container.querySelector('input[type="file"]') as HTMLInputElement; Object.defineProperty(input, "files", { value: [file], writable: false }); @@ -984,8 +1125,187 @@ describe("Composer", () => { }); await user.click(screen.getByRole("button", { name: "Queue" })); - expect(onQueue).toHaveBeenCalledWith("steer here"); + expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer here", [ + { url: "data:image/png;base64,UE5H", mimeType: "image/png" }, + ]); expect(onSend).not.toHaveBeenCalled(); + expect(textarea).toHaveValue(""); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + }); + + it.each(["running", "queued"] as const)("queues images-only while %s", async (status) => { + const onQueue = vi.fn(); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, onQueue, status } }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { + value: [new File(["GIF"], "shot.gif", { type: "image/gif" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await screen.findByRole("button", { name: "Remove image" }); + + await user.click(screen.getByRole("button", { name: "Queue" })); + + expect(onQueue).toHaveBeenCalledExactlyOnceWith("", [ + { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }, + ]); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + }); + + it.each([ + "running", + "queued", + ] as const)("keeps images and text staged while %s without onQueue", async (status) => { + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container, rerender } = render(Composer, { props: { onSend, status } }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { + value: [new File(["GIF"], "shot.gif", { type: "image/gif" })], + }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await screen.findByRole("button", { name: "Remove image" }); + const textarea = screen.getByRole("textbox", { name: "Message input" }); + await user.type(textarea, "{Enter}"); // image-only: no queue callback + await user.type(textarea, "keep this{Enter}"); + container + .querySelector("form") + ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + + expect(onSend).not.toHaveBeenCalled(); + expect(textarea).toHaveValue("keep this"); + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Queue" })).toBeDisabled(); + // Once idle, the same staged input can actually be sent. + await rerender({ status: "idle" }); + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledExactlyOnceWith("keep this", [ + { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" }, + ]); + expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument(); + }); +}); + +describe("Composer image compression", () => { + /** Stage a PNG through the file picker and wait for the thumbnail. */ + async function stagePng(container: HTMLElement, content = "PNGDATA"): Promise<void> { + const file = new File([content], "shot.png", { type: "image/png" }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], writable: false }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + } + + /** A fake converted JPEG (the outermost-edge stub — no canvas involved). */ + function fakeJpeg(): ConvertedJpeg { + return { blob: new Blob(["SMALLJPEG"], { type: "image/jpeg" }), mimeType: "image/jpeg" }; + } + + it("shows the compress toggle CHECKED by default for a staged PNG", async () => { + const { container } = render(Composer, { + props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) }, + }); + await stagePng(container); + + const toggle = screen.getByRole("checkbox", { name: "Compress image" }); + expect(toggle).toBeChecked(); + }); + + it("hides the compress toggle for already-lossy formats (JPEG)", async () => { + const { container } = render(Composer, { + props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) }, + }); + const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" }); + const input = container.querySelector('input[type="file"]') as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], writable: false }); + input.dispatchEvent(new Event("change", { bubbles: true })); + await vi.waitFor(() => { + expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument(); + }); + + expect(screen.queryByRole("checkbox", { name: "Compress image" })).not.toBeInTheDocument(); + }); + + it("sends the converted JPEG (image/jpeg) when compress is on, encoding exactly once", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + + // The async conversion lands and the staged URL reflects what will be sent. + await vi.waitFor(() => { + expect(encodeJpeg).toHaveBeenCalledTimes(1); + }); + await vi.waitFor(() => { + const thumb = container.querySelector("img[alt='image/jpeg']"); + expect(thumb).not.toBeNull(); + expect(thumb?.getAttribute("src")).toMatch(/^data:image\/jpeg;base64,/); + }); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledTimes(1); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images).toHaveLength(1); + expect(images[0]?.mimeType).toBe("image/jpeg"); + expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("sends the ORIGINAL untouched bytes when the toggle is unchecked", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("checkbox", { name: "Compress image" })); + expect(screen.getByRole("checkbox", { name: "Compress image" })).not.toBeChecked(); + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(onSend).toHaveBeenCalledTimes(1); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/png"); + expect(images[0]?.url).toMatch(/^data:image\/png;base64,/); + }); + + it("re-checking uses the CACHED conversion — no second encode", async () => { + const encodeJpeg = vi.fn(async () => fakeJpeg()); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + const toggle = screen.getByRole("checkbox", { name: "Compress image" }); + await user.click(toggle); // OFF → original + await user.click(toggle); // ON again → must reuse the cached JPEG + + await user.click(screen.getByRole("button", { name: "Send" })); + expect(encodeJpeg).toHaveBeenCalledTimes(1); // still exactly one encode + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/jpeg"); + expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/); + }); + + it("falls back to the original image when conversion fails", async () => { + const encodeJpeg = vi.fn(async () => { + throw new Error("decode exploded"); + }); + const onSend = vi.fn(); + const user = userEvent.setup(); + const { container } = render(Composer, { props: { onSend, encodeJpeg } }); + await stagePng(container); + await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1)); + + await user.click(screen.getByRole("button", { name: "Send" })); + const [, images] = onSend.mock.calls[0] ?? []; + expect(images[0]?.mimeType).toBe("image/png"); + expect(images[0]?.url).toMatch(/^data:image\/png;base64,/); }); }); @@ -1006,7 +1326,9 @@ describe("ModelSelector", () => { expect(keySelect).toHaveValue("anthropic"); expect(modelSelect).toHaveValue("claude-3"); - expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]); + // The synthetic "Named Agents" provider is ALWAYS first (plan §6), even with + // zero configured agents. + expect(optionValues(keySelect)).toEqual(["__named_agents__", "openai", "anthropic", "google"]); // only the models under the selected key expect(optionValues(modelSelect)).toEqual(["claude-3"]); }); @@ -1041,6 +1363,133 @@ describe("ModelSelector", () => { expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o"); }); + it("lists Named Agents under the synthetic provider and resolves a selection to a snapshot", async () => { + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const models = ["openai/gpt-4"]; + const namedAgents = [ + { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "a2", name: "Research Agent", model: "umans/umans-glm-5.2" }, + ]; + + const { rerender } = render(ModelSelector, { + props: { + models, + selected: "openai/gpt-4", + onSelect: vi.fn(), + namedAgents, + onSelectNamedAgent, + }, + }); + + // Switching the provider to "Named Agents" jumps to the first agent (mirrors + // a raw provider jumping to its first model). + await user.selectOptions( + screen.getByRole("combobox", { name: "Key selector" }), + "__named_agents__", + ); + expect(onSelectNamedAgent).toHaveBeenCalledWith({ + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + + // The parent (store) applies the selection → re-render with the resolved raw + // model + snapshot, exactly as `selectModel(model, snapshot)` would do. + const codingAgent = namedAgents[0]; + await rerender({ + models, + selected: codingAgent?.model ?? "", + onSelect: vi.fn(), + namedAgents, + namedAgentSnapshot: { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }, + onSelectNamedAgent, + }); + + // The model dropdown now lists the named agents; pick a different one. + await user.selectOptions( + screen.getByRole("combobox", { name: "Model selector" }), + "Research Agent", + ); + expect(onSelectNamedAgent).toHaveBeenLastCalledWith({ + namedAgentId: "a2", + namedAgentName: "Research Agent", + model: "umans/umans-glm-5.2", + }); + }); + + it("shows a disabled placeholder when the synthetic provider has no configured agents", () => { + render(ModelSelector, { + props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() }, + }); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + const options = within(modelSelect).getAllByRole("option"); + // With the raw openai provider selected the model dropdown lists gpt-4; the + // "No named agents configured" placeholder appears only under the synthetic + // provider (which we'd select to see it). Assert the disabled/empty handling + // by checking the synthetic provider is present in the key selector. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(optionValues(keySelect)).toContain("__named_agents__"); + // Switch to the (empty) synthetic provider → disabled "No named agents configured". + expect(() => screen.getByRole("option", { name: /No named agents configured/ })).toThrow(); + expect(options).toHaveLength(1); + }); + + it("keeps showing a snapshot of a renamed/removed Named Agent (unavailable)", () => { + // A conversation selected via a Named Agent retains its snapshot even after + // the agent is gone from the current list (plan §7). + const namedAgentSnapshot = { + namedAgentId: "gone", + namedAgentName: "Old Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: [], + selected: "opencode-1/claude-sonnet-4.5", + onSelect: vi.fn(), + namedAgents: [], + namedAgentSnapshot, + }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + expect(keySelect).toHaveValue("__named_agents__"); + const modelSelect = screen.getByRole("combobox", { name: "Model selector" }); + expect(modelSelect).toHaveValue("Old Agent"); + expect(screen.getByRole("option", { name: /Old Agent.*unavailable/ })).toBeInTheDocument(); + }); + + it("selecting a raw model from a Named Agent conversation clears the snapshot via onSelect", async () => { + const onSelect = vi.fn(); + const onSelectNamedAgent = vi.fn(); + const user = userEvent.setup(); + const namedAgentSnapshot = { + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }; + render(ModelSelector, { + props: { + models: ["openai/gpt-4"], + selected: "opencode-1/claude-sonnet-4.5", + onSelect, + namedAgents: [{ id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }], + namedAgentSnapshot, + onSelectNamedAgent, + }, + }); + + // Switch from Named Agents to a raw provider → raw model selection clears the snapshot. + await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "openai"); + expect(onSelect).toHaveBeenCalledWith("openai/gpt-4"); + expect(onSelectNamedAgent).not.toHaveBeenCalled(); + }); + it("marks vision-capable models in the model dropdown", () => { const models = ["kimi/k2", "kimi/k1.5"]; const modelInfo = { @@ -1089,33 +1538,95 @@ describe("ModelSelector", () => { }); expect(screen.getByText(/auto-described/i)).toBeInTheDocument(); }); + + it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => { + const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-2/m2", onSelect }, + }); + + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const options = within(keySelect).getAllByRole("option"); + // Numeric order: #1 < #2 < #10 (after the always-first synthetic provider), + // and values stay RAW (opencode-N). + expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([ + "__named_agents__", + "opencode-1", + "opencode-2", + "opencode-10", + ]); + expect(options[1]?.textContent).toContain("OpenCode #1"); + expect(options[2]?.textContent).toContain("OpenCode #2"); + expect(options[3]?.textContent).toContain("OpenCode #10"); + }); + + it("shows a disappeared saved credential as unavailable without silently falling back", () => { + // Saved selection references opencode-5, but the catalog no longer has it. + const models = ["opencode-1/deepseek-v4-flash"]; + const onSelect = vi.fn(); + render(ModelSelector, { + props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect }, + }); + + // The unavailable key is surfaced as an explicit (disabled) option + notice. + const keySelect = screen.getByRole("combobox", { name: "Key selector" }); + const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i); + expect(unavailable).toBeInTheDocument(); + expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument(); + // No silent switch is attempted. + expect(onSelect).not.toHaveBeenCalled(); + }); }); describe("ReasoningEffortSelector", () => { - it("renders null (never set) as the default level, marked '(default)'", () => { - render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } }); + it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save: vi.fn() }, + }); const select = screen.getByRole("combobox", { name: "Reasoning effort" }); expect(select).toHaveValue("high"); expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument(); - // All five ladder levels are offered. - expect(within(select).getAllByRole("option")).toHaveLength(5); + // "Off" first, then the five ladder levels. + const options = within(select).getAllByRole("option"); + expect(options).toHaveLength(6); + expect(options[0]).toHaveValue("off"); + expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument(); }); - it("renders a persisted level as selected", () => { - render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } }); + it("renders a persisted level as selected when thinking is on", () => { + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() }, + }); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh"); }); + it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => { + // thinking off is a SEPARATE axis: even with a persisted effort level, the + // selector shows "off" while thinking is disabled. + render(ReasoningEffortSelector, { + props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() }, + }); + + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + // the level option is still present (restored on an off→on toggle) + expect( + within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }), + ).toBeInTheDocument(); + }); + it("selecting a level saves it via the injected port and confirms", async () => { - const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({ + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ ok: true as const, - reasoningEffort: level, + selection, })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1127,11 +1638,35 @@ describe("ReasoningEffortSelector", () => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max"); }); - it("a failed save shows the error and reverts to the persisted value", async () => { + it("selecting 'off' saves the separate disable signal (not a level)", async () => { + const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({ + ok: true as const, + selection, + })); + const user = userEvent.setup(); + + render(ReasoningEffortSelector, { + props: { persistedEffort: "high", persistedThinking: null, save }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off"); + + expect(save).toHaveBeenCalledTimes(1); + // "off" is the separate thinking-disable signal — NOT a zero-effort level. + expect(save).toHaveBeenCalledWith("off"); + await vi.waitFor(() => { + expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument(); + }); + expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off"); + }); + + it("a failed save shows the error and reverts to the persisted selection", async () => { const save = vi.fn(async () => ({ ok: false as const, error: "nope" })); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: "low", save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: "low", persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); @@ -1142,24 +1677,215 @@ describe("ReasoningEffortSelector", () => { }); it("disables the select while a save is in flight (no double-fire)", async () => { - let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined; + let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined; const save = vi.fn( () => - new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => { + new Promise<{ ok: true; selection: "max" }>((resolve) => { resolveSave = resolve; }), ); const user = userEvent.setup(); - render(ReasoningEffortSelector, { props: { persisted: null, save } }); + render(ReasoningEffortSelector, { + props: { persistedEffort: null, persistedThinking: null, save }, + }); await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max"); expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled(); - resolveSave?.({ ok: true, reasoningEffort: "max" }); + resolveSave?.({ ok: true, selection: "max" }); await vi.waitFor(() => { expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled(); }); }); }); + +describe("ChatView — cache timer", () => { + const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 }; + function step(stepId: string) { + return { stepId: stepId as StepId, usage, decodeMs: 100 }; + } + function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry { + const steps = stepIds.map(step); + return { turnId, steps, total: { turnId, usage, steps } }; + } + function exchange(turnId: string, base: number): RenderedChunk[] { + return [ + { + seq: base, + role: "user", + chunk: { type: "text", text: `q-${turnId}` }, + provisional: false, + turnId, + }, + { + seq: base + 1, + role: "assistant", + chunk: { type: "text", text: `a-${turnId}` }, + provisional: false, + turnId, + }, + ]; + } + function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk { + return { + seq, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: `call-${stepId}`, + toolName: "read_file", + input: {}, + stepId: stepId as StepId, + }, + provisional: false, + turnId, + }; + } + function state(partial: Partial<CacheTimerState>): CacheTimerState { + return { ...initialCacheTimerState(), ...partial }; + } + + it("renders nothing when the feature is off (cacheTimer null)", () => { + render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } }); + expect(screen.queryByTestId("cache-timer-live")).toBeNull(); + expect(screen.queryByTestId("cache-timer-stamp")).toBeNull(); + }); + + it("shows a live timer under executing tools, counting up to 10+ min", async () => { + vi.useFakeTimers(); + try { + const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)]; + render(ChatView, { + props: { + chunks, + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ + running: { + kind: "step", + turnId: "t1", + stepId: "t1#0", + startedAt: Date.now(), + toolCount: 1, + pending: new Set(["call-t1#0"]), + maxToolMs: 0, + }, + }), + }, + }); + const row = screen.getByTestId("cache-timer-tools"); + expect(row).toHaveTextContent(/Running tools/); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00"); + await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30"); + await vi.advanceTimersByTimeAsync(5 * 60_000); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min"); + } finally { + vi.useRealTimers(); + } + }); + + it("hides the live tools row for a step with no tool calls", () => { + render(ChatView, { + props: { + chunks: exchange("t1", 1), + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ + running: { + kind: "step", + turnId: "t1", + stepId: "t1#0", + startedAt: Date.now(), + toolCount: 0, + pending: new Set(), + maxToolMs: 0, + }, + }), + }, + }); + expect(screen.queryByTestId("cache-timer-tools")).toBeNull(); + }); + + it("stamps a finished step gap on that step's line, next to its cache badge", () => { + const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)]; + render(ChatView, { + props: { + chunks, + turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }], + cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }), + }, + }); + const stamp = screen.getByTestId("cache-timer-stamp"); + expect(stamp).toHaveTextContent("1:05"); + // Sized to match the step's (xs) cache badge. + expect(stamp).toHaveClass("badge-xs"); + expect(stamp).not.toHaveClass("badge-sm"); + // Same line as the step's cache badge. + expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge")); + expect(screen.queryByTestId("cache-timer-tools")).toBeNull(); + }); + + it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => { + render(ChatView, { + props: { + chunks: [...exchange("t1", 1), ...exchange("t2", 3)], + turnMetrics: [turn("t1"), turn("t2")], + cacheTimer: state({ + running: { kind: "turn", turnId: "t2", startedAt: Date.now() }, + turnStamps: new Map([["t1", 11 * 60_000]]), + }), + }, + }); + const live = screen.getByTestId("cache-timer-live"); + expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/); + expect(live).toHaveClass("badge-sm"); + const stamp = screen.getByTestId("cache-timer-stamp"); + expect(stamp).toHaveTextContent("10+ min"); + expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/); + }); + + it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => { + // Regression: the badge's effect once READ the `current` state it wrote, so + // with a real (always-advancing) clock it re-ran itself until Svelte threw + // `effect_update_depth_exceeded` — aborting the flush, so turn data showed late. + let calls = 0; + const now = () => { + calls++; + return 1_000_000 + calls; + }; + render(CacheTimerBadge, { props: { since: 1_000_000, now } }); + await new Promise((r) => setTimeout(r, 20)); + expect(calls).toBeLessThan(5); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00"); + }); + + it("renders the turn's metrics alongside a live turn timer", () => { + render(ChatView, { + props: { + chunks: exchange("t1", 1), + turnMetrics: [turn("t1")], + cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }), + }, + }); + expect(screen.getByText(/turn 1 · 10 tok out/)).toBeInTheDocument(); + expect(screen.getByText("a-t1")).toBeInTheDocument(); + expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/); + }); + + it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => { + render(ChatView, { + props: { + chunks: [...exchange("t1", 1), ...exchange("t2", 3)], + turnMetrics: [turn("t1"), turn("t2")], + cacheTimer: state({ + running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 }, + }), + }, + }); + const badges = screen.getAllByTestId("cache-timer-live"); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveTextContent("2:00"); + expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/); + }); +}); diff --git a/src/features/chat/ui/CacheTimerBadge.svelte b/src/features/chat/ui/CacheTimerBadge.svelte new file mode 100644 index 0000000..9e6f96d --- /dev/null +++ b/src/features/chat/ui/CacheTimerBadge.svelte @@ -0,0 +1,70 @@ +<script lang="ts"> + /** + * One cache-timer badge. LIVE when given `since` (counts up from that epoch + * ms, ticking once a second, and stops ticking at the 10-minute cap); STAMPED + * when given `elapsedMs` (a finished gap — static). Formatting lives in + * `core/metrics` (`viewCacheTimer`). Owning the tick here means only this + * badge re-renders each second, not the transcript. + */ + import { untrack } from "svelte"; + import { CACHE_TIMER_MAX_MS, viewCacheTimer } from "../../../core/metrics"; + + let { + since, + elapsedMs, + size = "sm", + now = Date.now, + }: { + /** Live: epoch ms the gap started. */ + since?: number; + /** Stamped: the gap's final duration (wins over `since`). */ + elapsedMs?: number; + /** Badge size — match the neighbouring cache badge (`xs` on step lines). */ + size?: "xs" | "sm"; + /** Wall clock (injected for tests). */ + now?: () => number; + } = $props(); + + // Initial read only (the effect below keeps it current while live). + let current = $state(untrack(() => now())); + + // Depends ONLY on `since`/`elapsedMs`/`now`. It must never READ `current` + // (it writes it): reading its own write makes the effect re-run itself on + // every clock change → `effect_update_depth_exceeded`, which aborts the whole + // reactive flush and stalls the transcript (turn data rendering late). + $effect(() => { + if (elapsedMs !== undefined || since === undefined) return; + const start = since; + const clock = now; + const t0 = clock(); + current = t0; + if (t0 - start >= CACHE_TIMER_MAX_MS) return; + const id = setInterval(() => { + const t = clock(); + current = t; + if (t - start >= CACHE_TIMER_MAX_MS) clearInterval(id); + }, 1000); + return () => clearInterval(id); + }); + + const badgeClass = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + } as const; + + const live = $derived(elapsedMs === undefined); + const view = $derived(viewCacheTimer(elapsedMs ?? current - (since ?? current))); +</script> + +<span + class="badge font-mono tabular-nums {badgeClass[view.level]}" + class:badge-xs={size === "xs"} + class:badge-sm={size === "sm"} + class:badge-outline={!live} + title={live + ? "Cache timer (running): time since the last model request. The Claude prompt cache expires after 5 minutes idle." + : "Cache timer (stopped): how long the cache sat idle before the next request. The Claude prompt cache expires after 5 minutes idle."} + data-testid={live ? "cache-timer-live" : "cache-timer-stamp"} + >{view.label}</span +> diff --git a/src/features/chat/ui/ChatView.svelte b/src/features/chat/ui/ChatView.svelte index e67ca5b..84e66d8 100644 --- a/src/features/chat/ui/ChatView.svelte +++ b/src/features/chat/ui/ChatView.svelte @@ -2,14 +2,19 @@ import type { TurnProviderRetryEvent } from "@dispatch/wire"; import { groupRenderedChunks, resolveImageUrl, type RenderedChunk, viewProviderRetry } from "../index"; import { + type CacheTimerState, interleaveTurnMetrics, + lastTurnRowIndex, viewCacheRate, viewExpectedCache, viewStepMetrics, viewTurnMetrics, type TurnMetricsEntry, } from "../../../core/metrics"; - import { Markdown } from "../../markdown"; + import { Markdown, type TextFileClient } from "../../markdown"; + import CacheTimerBadge from "./CacheTimerBadge.svelte"; + import LazyCollapse from "./LazyCollapse.svelte"; + import ToolCard from "./ToolCard.svelte"; const badgeClass = { success: "badge-success", @@ -19,14 +24,19 @@ let { chunks, + recovering = false, turnMetrics = [], hasEarlier = false, onShowEarlier, thinkingKeyBase = 0, providerRetry = null, apiBaseUrl = "", + cwd = null, + textFiles = undefined, + cacheTimer = null, }: { chunks: readonly RenderedChunk[]; + recovering?: boolean; turnMetrics?: readonly TurnMetricsEntry[]; /** Earlier history is unloaded (chat limit) and can be paged back in. */ hasEarlier?: boolean; @@ -53,10 +63,27 @@ * "" (root-relative — a browser resolves `/images/…` against its origin). */ apiBaseUrl?: string; + /** + * The active conversation's working directory (null when unset). Relative + * Markdown image srcs (``) resolve against it; local absolute + * and `file://` paths do not need it. Passed to `Markdown` with `apiBaseUrl`. + */ + cwd?: string | null; + /** Context-bound, injected live-file display port (never model history). */ + textFiles?: TextFileClient | undefined; + /** + * The cache timer (gaps between model requests): a running step gap shows + * live at the bottom (under the executing tools), a running turn gap live + * on that turn's metrics row; finished gaps are stamped on their step / + * turn metrics line. `null` (the default) hides it entirely — the caller + * gates on the model. + */ + cacheTimer?: CacheTimerState | null; } = $props(); // True while a show-earlier page-in is awaited (disables the button). let loadingEarlier = $state(false); + let failedImages = $state<string[]>([]); async function showEarlier() { if (!onShowEarlier || loadingEarlier) return; @@ -68,10 +95,36 @@ } } - const groups = $derived(groupRenderedChunks(chunks)); + const groups = $derived(groupRenderedChunks(chunks, recovering)); const rows = $derived(interleaveTurnMetrics(groups, turnMetrics)); + // A SEEDED turn gap (no turnId — e.g. after a reload) renders on the most + // recent turn's row (when no user message follows it). + const seededTurnRow = $derived( + cacheTimer?.running?.kind === "turn" && cacheTimer.running.turnId === null + ? lastTurnRowIndex(rows) + : null, + ); + + /** Start of the running turn gap if it belongs on this turn row, else undefined. */ + function liveTurnSince(turnId: string, rowIndex: number): number | undefined { + const r = cacheTimer?.running; + if (r?.kind !== "turn") return undefined; + if (r.turnId === turnId || (r.turnId === null && rowIndex === seededTurnRow)) { + return r.startedAt; + } + return undefined; + } + + // A running step gap with tool calls → the live timer under the executing tools. + const liveStepGap = $derived( + cacheTimer?.running?.kind === "step" && cacheTimer.running.toolCount > 0 + ? cacheTimer.running + : null, + ); + + // Tool cards use toolCallId keys across pending/result/seal and orphan page-in. // Stable per-row keys. Thinking blocks get an ordinal key (`think<n>`) that // survives the provisional→committed (seq null → seq N) transition, so the // collapse's open/close state is NOT lost when a turn seals. The ordinal @@ -88,8 +141,8 @@ } const group = row.group; let key: string; - if (group.kind === "tool-batch") { - key = `b${group.stepId}`; + if (group.kind === "tool") { + key = `t${group.entry.toolCallId}`; } else if (group.chunk.chunk.type === "thinking") { key = `think${thinking++}`; } else if (group.chunk.seq != null) { @@ -102,6 +155,12 @@ }); </script> +{#if recovering} + <p role="status" class="px-4 py-2 text-sm text-base-content/70"> + Recovering conversation history… Omitted tool input/output is unavailable until persisted. + </p> +{/if} + {#snippet chunkRow(rendered: RenderedChunk)} {#if rendered.role === "user"} <!-- User: a speech bubble, left-aligned. A user message may be multi-chunk @@ -114,13 +173,22 @@ {#if rendered.chunk.type === "text"} <p>{rendered.chunk.text}</p> {:else if rendered.chunk.type === "image"} - <img - src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} - alt={rendered.chunk.mimeType ?? "pasted image"} - loading="lazy" - decoding="async" - class="max-h-80 max-w-full rounded" - /> + {#if failedImages.includes(rendered.chunk.url)} + <span class="text-sm opacity-70">image expired</span> + {:else} + <img + src={resolveImageUrl(rendered.chunk.url, apiBaseUrl)} + alt={rendered.chunk.mimeType ?? "pasted image"} + loading="lazy" + decoding="async" + onerror={() => { + if (rendered.chunk.type === "image" && rendered.chunk.url.startsWith("/images/")) { + failedImages = [...failedImages, rendered.chunk.url]; + } + }} + class="max-h-80 max-w-full rounded" + /> + {/if} {/if} </div> </div> @@ -130,83 +198,15 @@ while generating, then "Thoughts" with no dots once complete. --> <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> <div class="chat-bubble w-full bg-transparent"> - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle thoughts" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> + <LazyCollapse label="Toggle thoughts"> + {#snippet title()} <span>{rendered.streaming ? "Thinking" : "Thoughts"}</span> {#if rendered.streaming} <span class="loading loading-dots loading-sm" aria-label="Generating"></span> {/if} - </div> - <div class="collapse-content"> - <p class="whitespace-pre-wrap">{rendered.chunk.text}</p> - </div> - </div> - </div> - </div> - {:else if rendered.chunk.type === "tool-call" || rendered.chunk.type === "tool-result"} - <!-- Single tool call/result: a collapsible card (collapsed by default, - like thinking). Title shows the tool name; content shows the - input/output. Same chat-start grid shim as the thinking block. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - {#if rendered.chunk.type === "tool-call"} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{rendered.chunk.toolName}</span> - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify( - rendered.chunk.input, - null, - 2, - )}</pre> - </div> - </div> - {:else} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool result" /> - <div - class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium" - class:text-error={rendered.chunk.isError} - > - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{rendered.chunk.toolName}</span> - {#if rendered.chunk.isError} - <span class="badge badge-error badge-xs">error</span> - {/if} - </div> - <div class="collapse-content"> - <pre class="max-h-96 overflow-auto text-xs">{rendered.chunk.content}</pre> - </div> - </div> - {/if} + {/snippet} + <p class="whitespace-pre-wrap">{rendered.chunk.type === "thinking" ? rendered.chunk.text : ""}</p> + </LazyCollapse> </div> </div> {:else} @@ -215,7 +215,12 @@ <div class="chat chat-start [&>.chat-bubble]:max-w-5xl"> <div class="chat-bubble w-full bg-transparent"> {#if rendered.chunk.type === "text"} - <Markdown text={rendered.chunk.text} streaming={rendered.streaming ?? false} /> + <Markdown + text={rendered.chunk.text} + streaming={rendered.streaming ?? false} + image={{ apiBase: apiBaseUrl, cwd }} + {textFiles} + /> {:else if rendered.chunk.type === "error"} <div class="text-error" role="alert"> {rendered.chunk.message} @@ -245,17 +250,33 @@ </button> </div> {/if} - {#each keyedRows as { row, key } (key)} + {#each keyedRows as { row, key }, i (key)} + <!-- `.chat-row`: content-visibility:auto — the browser skips layout/paint + for rows outside the viewport (see app.css). --> + <div class="chat-row"> {#if row.kind === "step-metrics"} {@const sv = viewStepMetrics(row.step, row.index)} + {@const stepCache = viewCacheRate(row.step.usage)} + {@const stepStamp = cacheTimer?.stepStamps.get(row.step.stepId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> - <div class="text-xs opacity-70"> - {sv.label} · {sv.tokensLabel} - {#if sv.tps} - · {sv.tps}{/if} - {#if sv.genTotal} - · {sv.genTotal}{/if} + <div class="flex flex-wrap items-center gap-x-1 text-xs"> + <span class="opacity-70"> + {sv.label} · {sv.tokensLabel} + {#if sv.tps} + · {sv.tps}{/if} + {#if sv.ttft} + · {sv.ttft} to first token{/if} + · + </span> + <span class="opacity-70">cache:</span> + <span + class="badge badge-xs {badgeClass[stepCache.level]}" + data-testid="step-cache-badge">{stepCache.pct}%</span + > + {#if stepStamp !== undefined} + <CacheTimerBadge elapsedMs={stepStamp} size="xs" /> + {/if} </div> </div> </div> @@ -263,12 +284,14 @@ {@const turnView = viewTurnMetrics(row.turn, row.turnNumber)} {@const lastCache = viewCacheRate(row.turn.usage)} {@const chatCache = viewCacheRate(row.cumulativeUsage)} - {@const retention = viewExpectedCache(row.turn.usage, row.prevTurnUsage)} + {@const retention = viewExpectedCache(row.turn.steps[0]?.usage, row.prevLastStepUsage)} + {@const turnLive = liveTurnSince(row.turn.turnId, i)} + {@const turnStamp = cacheTimer?.turnStamps.get(row.turn.turnId)} <div class="chat chat-start"> <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> <div class="flex flex-col gap-1 text-xs"> <div class="opacity-70"> - {turnView.label} · {turnView.tokensLabel} ({turnView.breakdown}) + {turnView.label} · {turnView.tokensLabel} · {turnView.breakdown} {#if turnView.tps} · {turnView.tps}{/if} {#if turnView.duration} @@ -289,6 +312,11 @@ <span class="badge badge-sm {badgeClass[retention.level]}">{retention.pct}%</span> </span> {/if} + {#if turnLive !== undefined} + <CacheTimerBadge since={turnLive} /> + {:else if turnStamp !== undefined} + <CacheTimerBadge elapsedMs={turnStamp} /> + {/if} </div> </div> </div> @@ -296,54 +324,27 @@ {:else if row.group.kind === "single"} {@render chunkRow(row.group.chunk)} {:else} - <!-- Batched tool calls (one step): each entry is a collapsible card. - Click to expand and see the input/output. --> - <div class="chat chat-start [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> - <div class="chat-bubble w-full bg-transparent"> - <div class="flex flex-col gap-1"> - {#each row.group.entries as entry (entry.call.toolCallId)} - <div class="collapse w-full rounded-box bg-base-200 text-sm"> - <input type="checkbox" aria-label="Toggle tool call" /> - <div class="collapse-title flex min-h-0 items-center gap-2 py-2 font-medium"> - <svg - class="h-4 w-4 opacity-60" - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - ><path - d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" - /></svg - > - <span>{entry.call.toolName}</span> - {#if entry.result?.isError} - <span class="badge badge-error badge-xs">error</span> - {:else if entry.result === null} - <span class="loading loading-spinner loading-xs" aria-label="Running"></span> - {/if} - </div> - <div class="collapse-content"> - <pre class="overflow-x-auto text-xs">{JSON.stringify( - entry.call.input, - null, - 2, - )}</pre> - {#if entry.result} - <pre - class="mt-1 max-h-96 overflow-auto text-xs" - class:text-error={entry.result.isError}>{entry.result.content}</pre> - {/if} - </div> - </div> - {/each} - </div> + <!-- Each call owns one bubble; the matching result is folded into its card. --> + <div class="chat chat-start [grid-template-columns:auto_minmax(0,1fr)] [&>.chat-bubble]:max-w-5xl [&>.chat-bubble]:p-0"> + <div class="chat-bubble min-w-0 w-full bg-transparent"> + <ToolCard entry={row.group.entry} /> </div> </div> {/if} + </div> {/each} + {#if liveStepGap} + <!-- Live cache timer while the last step's tools execute; stamped onto that + step's metrics line once they return (or the next step starts). --> + <div class="chat chat-start"> + <div class="chat-bubble w-full max-w-5xl bg-transparent p-0"> + <div class="flex items-center gap-1 text-xs" data-testid="cache-timer-tools"> + <span class="opacity-70">Running tools</span> + <CacheTimerBadge since={liveStepGap.startedAt} size="xs" /> + </div> + </div> + </div> + {/if} {#if providerRetry} {@const rv = viewProviderRetry(providerRetry)} <!-- Transient yellow warning: a provider error is being retried with backoff. diff --git a/src/features/chat/ui/ChatView.test.ts b/src/features/chat/ui/ChatView.test.ts new file mode 100644 index 0000000..157ccac --- /dev/null +++ b/src/features/chat/ui/ChatView.test.ts @@ -0,0 +1,558 @@ +import type { StepId } from "@dispatch/wire"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { RenderedChunk } from "../../../core/chunks"; +import ChatView from "./ChatView.svelte"; + +/** Collapsed card bodies render lazily — open every card so its body mounts. */ +async function openAllCollapses(container: HTMLElement): Promise<void> { + const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]'); + for (const box of boxes) { + if (!box.checked) await userEvent.click(box); + } +} + +// Defensive rendering: a conversation transcript can contain duplicate +// `toolCallId`s (persisted provider double-emission). The +// grouped render path MUST dedupe them so the keyed {#each} never throws +// Svelte `each_key_duplicate` (which aborts the reactive flush and makes the +// tab appear dead). See fix-toolcall-duplicate-keys plan Part 1. +describe("ChatView — duplicate toolCallIds", () => { + it("renders duplicated toolCallIds without throwing, showing one bubble per unique call", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 2, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + // The duplicate-emission pattern: the same ids re-emitted immediately after. + { + seq: 3, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "a", + toolName: "read_file", + input: { path: "/a" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 4, + role: "assistant", + chunk: { + type: "tool-call", + toolCallId: "b", + toolName: "list_dir", + input: { path: "/b" }, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 5, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 6, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "b", + toolName: "list_dir", + content: "contents-of-b", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + // Duplicate results too — absorbed into the corresponding cards. + { + seq: 7, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "a", + toolName: "read_file", + content: "contents-of-a", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + { + seq: 8, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "b", + toolName: "list_dir", + content: "contents-of-b", + isError: false, + stepId: "t1#0" as StepId, + }, + provisional: false, + }, + ]; + + const { container } = render(ChatView, { props: { chunks } }); + await openAllCollapses(container); + + // No each_key_duplicate crash. Calls render as independent collapsible cards — + // exactly ONE per unique toolCallId (deduped), not 4. + const collapses = container.querySelectorAll(".collapse"); + expect(collapses).toHaveLength(2); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2); + + // Both unique tool names shown; each result rendered once (deduped). + expect(screen.getByText("Read file")).toBeInTheDocument(); + expect(screen.getByText("List dir")).toBeInTheDocument(); + expect(screen.getByText("contents-of-a")).toBeInTheDocument(); + expect(screen.getByText("contents-of-b")).toBeInTheDocument(); + }); +}); + +describe("ChatView — lazy collapse bodies", () => { + it("does not mount a collapsed card's body until it is first opened, then keeps it", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "tool", + chunk: { + type: "tool-result", + toolCallId: "x", + toolName: "run_shell", + content: "HUGE-OUTPUT", + isError: false, + }, + provisional: false, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + // English display name; the raw wire name stays available as the tooltip. + expect(screen.getByText("Run command")).toHaveAttribute("title", "run_shell"); + expect(screen.queryByText("HUGE-OUTPUT")).toBeNull(); + + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + + await userEvent.click(box); // close: body stays mounted (close animation) + expect(screen.getByText("HUGE-OUTPUT")).toBeInTheDocument(); + expect(container.querySelectorAll(".chat-row")).toHaveLength(1); + }); +}); + +function shellChunks(content: string | null, isError = false, committed = false): RenderedChunk[] { + const call: RenderedChunk = { + seq: committed ? 1 : null, + role: "assistant", + provisional: !committed, + chunk: { + type: "tool-call", + toolCallId: "shell-1", + toolName: "run_shell", + input: { command: "bun run test" }, + }, + }; + return content === null + ? [call] + : [ + call, + { + seq: committed ? 2 : null, + role: "tool", + provisional: !committed, + chunk: { + type: "tool-result", + toolCallId: "shell-1", + toolName: "run_shell", + content, + isError, + }, + }, + ]; +} + +describe("ChatView — tool cards", () => { + it("pairs a single call/result into one collapsed bubble with both summaries and a success icon", async () => { + const { container } = render(ChatView, { props: { chunks: shellChunks("first\nsecond") } }); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1); + expect(container.querySelectorAll(".collapse")).toHaveLength(1); + expect(screen.getByText("bun run test")).toBeInTheDocument(); + expect(screen.getByText("→ 2 lines")).toBeInTheDocument(); + expect(screen.getByLabelText("Succeeded")).toHaveTextContent("✓"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + expect(box).not.toBeChecked(); + expect(container.querySelector("pre")).toBeNull(); + await userEvent.click(box); + expect(screen.getByText("$ bun run test")).toBeInTheDocument(); + expect(screen.getByText("first second")).toBeInTheDocument(); + expect(screen.queryByRole("checkbox", { name: /tool result/ })).toBeNull(); + }); + it("transitions pending → result → sealed without losing open state", async () => { + const { rerender } = render(ChatView, { props: { chunks: shellChunks(null) } }); + expect(screen.getByLabelText("Running")).toHaveClass("loading-dots", "loading-sm"); + expect(screen.getByText("→ running…")).toHaveClass("tool-result", "text-base-content/60"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByText("running…")).toBeInTheDocument(); + await rerender({ chunks: shellChunks("done") }); + expect(screen.queryByLabelText("Running")).toBeNull(); + expect(screen.queryByText("→ running…")).toBeNull(); + expect(screen.getByLabelText("Succeeded")).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + await rerender({ chunks: shellChunks("done", false, true) }); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + expect(screen.getByText("done")).toBeInTheDocument(); + }); + it("keeps errors collapsed with a failure icon and tints the expanded output", async () => { + const { container } = render(ChatView, { + props: { chunks: shellChunks("Permission denied", true) }, + }); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + expect(box).not.toBeChecked(); + expect(screen.getByLabelText("Failed")).toHaveClass("text-error"); + expect(screen.getByLabelText("Failed")).toHaveTextContent("✗"); + expect(screen.getByText("→ failed").closest(".tool-result")).toHaveClass("text-error"); + expect(container.querySelector(".tool-header")).not.toHaveTextContent("Permission denied"); + expect(container.querySelector("pre")).toBeNull(); + await userEvent.click(box); + expect(screen.getByText("Permission denied").parentElement).toHaveClass("text-error"); + }); + it("renders an orphan result as the same card and retains state when its call is paged back in", async () => { + const chunks = shellChunks("contents", false, true); + const { container, rerender } = render(ChatView, { props: { chunks: chunks.slice(1) } }); + expect(container.querySelectorAll(".chat-bubble")).toHaveLength(1); + expect(screen.getByText("Call in earlier history")).toHaveClass("earlier-history"); + expect(screen.getByLabelText("Succeeded")).toHaveClass("opacity-60"); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + expect(screen.getByRole("note")).toHaveTextContent( + "This call is earlier in the conversation. Show earlier messages to see it.", + ); + expect(screen.getByRole("note")).toHaveClass("italic", "text-base-content/60"); + expect(screen.queryByRole("heading", { name: "Call" })).toBeNull(); + expect(screen.getByText("contents")).toBeInTheDocument(); + await rerender({ chunks }); + expect(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })).toBe(box); + expect(box).toBeChecked(); + expect(screen.getByText("$ bun run test")).toBeInTheDocument(); + }); + it("folds 13 output lines as 6 + toggle + 6, expands and collapses without closing the card", async () => { + const output = Array.from({ length: 13 }, (_, i) => `output-${i + 1}`).join("\n"); + const { rerender } = render(ChatView, { props: { chunks: shellChunks(output) } }); + const box = screen.getByRole("checkbox", { name: "Toggle tool call Run command" }); + await userEvent.click(box); + const response = screen.getByRole("region", { name: "Response" }); + expect(response).toHaveTextContent("output-1"); + expect(response).toHaveTextContent("output-6"); + expect(response).not.toHaveTextContent("output-7"); + expect(response).toHaveTextContent("output-8"); + expect(response).toHaveTextContent("output-13"); + const toggle = screen.getByRole("button", { name: "⋯ 1 more lines" }); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + await userEvent.click(toggle); + expect(response).toHaveTextContent("output-7"); + expect(response.querySelectorAll("pre")).toHaveLength(1); + expect(response.querySelector("pre")?.textContent).toBe(output); + const fewer = screen.getByRole("button", { name: "Show fewer lines" }); + expect(fewer).toHaveClass("btn-soft", "btn-xs"); + expect(fewer.parentElement).toHaveClass("sticky-controls"); + const fullOutput = response.querySelector("pre"); + if (fullOutput === null) throw new Error("expected full output"); + expect(fewer.compareDocumentPosition(fullOutput)).toBe(Node.DOCUMENT_POSITION_FOLLOWING); + expect(box).toBeChecked(); + expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await userEvent.click(screen.getByRole("button", { name: "Show fewer lines" })); + expect(response).not.toHaveTextContent("output-7"); + expect(box).toBeChecked(); + // The separate output-fold state also survives sealing. + await userEvent.click(screen.getByRole("button", { name: "⋯ 1 more lines" })); + expect(response).toHaveTextContent("output-7"); + await rerender({ chunks: shellChunks(output, false, true) }); + expect(box).toBeChecked(); + expect(screen.getByRole("button", { name: "Show fewer lines" })).toHaveAttribute( + "aria-expanded", + "true", + ); + expect(response).toHaveTextContent("output-7"); + }); + it("shows 12 output lines without an inner toggle", async () => { + const output = Array.from({ length: 12 }, (_, i) => `output-${i + 1}`).join("\n"); + render(ChatView, { props: { chunks: shellChunks(output) } }); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Run command" })); + expect(screen.queryByRole("button", { name: /more lines|fewer lines/ })).toBeNull(); + expect(screen.getByRole("region", { name: "Response" })).toHaveTextContent("output-12"); + }); + it("renders edit details and treats tool output as text, not executable markup", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "edit", + toolName: "edit_file", + input: { path: "a.ts", oldString: "before", newString: "after\nsecond" }, + }, + }, + { + seq: 2, + role: "tool", + provisional: false, + chunk: { + type: "tool-result", + toolCallId: "edit", + toolName: "edit_file", + content: 'Replaced 1 occurrence(s) in "a.ts".\n<script>alert(1)</script>', + isError: false, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + expect(screen.getByText("+2")).toHaveClass("text-success"); + expect(screen.getByText("−1")).toHaveClass("text-error"); + expect(screen.getByText("→ 1 replacement · diagnostics")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" })); + // Unified diff: one tinted row per line, each with its own −/+ gutter marker. + const diff = screen.getByRole("group", { name: "Edit diff" }); + const removed = diff.querySelectorAll(".edit-diff-del"); + const added = diff.querySelectorAll(".edit-diff-add"); + expect(Array.from(removed, (r) => r.textContent?.trim())).toEqual(["−before"]); + expect(Array.from(added, (r) => r.textContent?.trim())).toEqual(["+after", "+second"]); + expect(container.querySelector("script")).toBeNull(); + expect(container).toHaveTextContent("<script>alert(1)</script>"); + }); +}); + +it("renders compact, aligned generic fields with legible headings and inline scalar arrays", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "issue", + toolName: "mcp__github__create_issue", + input: { + owner: "realtradam", + repo: "frontend", + title: "Tool cards", + labels: ["ui", "chat"], + options: { nested: true }, + }, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + await userEvent.click( + screen.getByRole("checkbox", { name: "Toggle tool call Github: Create issue" }), + ); + for (const name of ["Call", "Response"]) { + expect(screen.getByRole("heading", { name })).toHaveClass("font-semibold", "text-base-content"); + } + const call = screen.getByRole("region", { name: "Call" }); + expect(call.querySelector("dl")).toHaveClass("call-fields"); + expect(screen.getByText("owner")).toHaveClass("font-semibold", "text-base-content/85"); + const labels = screen.getByText('["ui", "chat"]'); + expect(labels.tagName).toBe("PRE"); + expect(labels.textContent).not.toContain("\n"); + expect(container.querySelectorAll("dt")).toHaveLength(5); + expect(container.querySelectorAll("dd")).toHaveLength(5); + expect(screen.getByText("options").nextElementSibling).toHaveTextContent('"nested": true'); +}); + +it("removes a pending edit's diffstat when it fails, without hiding the attempted edit", async () => { + const call: RenderedChunk = { + seq: null, + role: "assistant", + provisional: true, + chunk: { + type: "tool-call", + toolCallId: "failed-edit", + toolName: "edit_file", + input: { path: "src/missing.ts", oldString: "foo", newString: "bar" }, + }, + }; + const result: RenderedChunk = { + seq: null, + role: "tool", + provisional: true, + chunk: { + type: "tool-result", + toolCallId: "failed-edit", + toolName: "edit_file", + content: "Error: oldString not found", + isError: true, + }, + }; + const { container, rerender } = render(ChatView, { props: { chunks: [call] } }); + expect(screen.getByText("+1")).toBeInTheDocument(); + expect(screen.getByText("−1")).toBeInTheDocument(); + expect(screen.getByText("→ running…")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("checkbox", { name: "Toggle tool call Edit file" })); + await rerender({ chunks: [call, result] }); + expect(screen.queryByText("+1")).toBeNull(); + expect(screen.queryByText("−1")).toBeNull(); + expect(screen.queryByText("→ running…")).toBeNull(); + expect(container.querySelector(".tool-header")).toHaveTextContent("src/missing.ts"); + expect(container.querySelector(".tool-header")).toHaveTextContent("→ failed"); + expect(screen.getByText("foo")).toBeInTheDocument(); + expect(screen.getByText("bar")).toBeInTheDocument(); + expect(screen.getByText("Error: oldString not found")).toBeInTheDocument(); +}); + +it("shows an argument-less call without `{}` — empty header summary, 'No arguments' in the body", async () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { type: "tool-call", toolCallId: "m", toolName: "list_agent_models", input: {} }, + }, + { + seq: 2, + role: "tool", + provisional: false, + chunk: { + type: "tool-result", + toolCallId: "m", + toolName: "list_agent_models", + content: "Available named agents (3)", + isError: false, + }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + expect(screen.getByText("List agent models")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("{}"); + await userEvent.click( + screen.getByRole("checkbox", { name: "Toggle tool call List agent models" }), + ); + expect(screen.getByText("No arguments")).toBeInTheDocument(); + expect(container).not.toHaveTextContent("{}"); +}); + +it("renders a per-tool Phosphor icon in the header", () => { + const chunks: RenderedChunk[] = [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "s", + toolName: "run_shell", + input: { command: "ls" }, + }, + }, + { + seq: 2, + role: "assistant", + provisional: false, + chunk: { type: "tool-call", toolCallId: "r", toolName: "read_file", input: { path: "a.ts" } }, + }, + ]; + const { container } = render(ChatView, { props: { chunks } }); + const icons = Array.from(container.querySelectorAll(".tool-header svg[aria-hidden='true']")); + expect(icons).toHaveLength(2); + // Different tools → different glyphs. + expect(icons[0]?.innerHTML).not.toBe(icons[1]?.innerHTML); +}); + +it("replaces failed persisted images with an expired placeholder, without marking inline images expired", async () => { + const chunks: RenderedChunk[] = ["/images/conv/gone.png", "data:image/png;base64,AAAA"].map( + (url, i) => ({ seq: i + 1, role: "user", chunk: { type: "image", url }, provisional: false }), + ); + render(ChatView, { chunks, apiBaseUrl: "https://backend.test" }); + const [stored, inline] = screen.getAllByRole("img"); + if (!stored || !inline) throw new Error("expected both images"); + expect(stored).toHaveAttribute("src", "https://backend.test/images/conv/gone.png"); + await fireEvent.error(inline); + expect(screen.queryByText("image expired")).toBeNull(); + await fireEvent.error(stored); + expect(screen.getByText("image expired")).toBeInTheDocument(); + expect(screen.getAllByRole("img")).toEqual([inline]); +}); + +it("shows omitted in-progress tool input as recovering/unavailable, never No arguments", async () => { + const { container, rerender } = render(ChatView, { + recovering: true, + chunks: [ + { + seq: null, + role: "tool", + provisional: true, + chunk: { + type: "tool-result", + toolCallId: "missing", + toolName: "run_shell", + content: "real output", + isError: false, + }, + }, + ], + }); + expect(screen.getByRole("status").textContent).toContain("unavailable until persisted"); + expect(screen.getByText("Recovering tool input…")).toBeTruthy(); + await openAllCollapses(container); + expect(screen.getByText(/Tool input is unavailable until it is persisted/)).toBeTruthy(); + expect(screen.queryByText("No arguments")).toBeNull(); + expect(screen.getByText("real output")).toBeTruthy(); + await rerender({ + recovering: false, + chunks: [ + { + seq: 1, + role: "assistant", + provisional: false, + chunk: { + type: "tool-call", + toolCallId: "missing", + toolName: "run_shell", + input: { command: "recovered command" }, + }, + }, + ], + }); + expect(screen.queryByRole("status")).toBeNull(); + expect(screen.queryByText("Recovering tool input…")).toBeNull(); +}); diff --git a/src/features/chat/ui/Composer.svelte b/src/features/chat/ui/Composer.svelte index 04c28cd..cd451bc 100644 --- a/src/features/chat/ui/Composer.svelte +++ b/src/features/chat/ui/Composer.svelte @@ -1,5 +1,8 @@ <script lang="ts"> import type { ImageInput } from "@dispatch/wire"; + import { convertToJpeg, type ConvertedJpeg, type ImageJpegEncoder } from "../adapter/image-encode"; + import { stopButtonLabels } from "../logic/composer"; + import { MAX_IMAGE_EDGE_PX, shouldConvertImage } from "../logic/image-compress"; import { computeContextUsage, formatCompactTokens } from "../../../core/metrics"; const FALLBACK_CONTEXT_WINDOW = 1_000_000; @@ -8,20 +11,56 @@ const IMAGE_ACCEPT = "image/png,image/jpeg,image/gif,image/webp"; /** Reject images larger than this before base64-encoding (keeps payloads sane). */ const MAX_IMAGE_BYTES = 8 * 1024 * 1024; + /** Tooltip on the per-image compress toggle (PNG → JPEG + resize). */ + const COMPRESS_TOOLTIP = `Re-encode PNG to JPEG and resize to ${MAX_IMAGE_EDGE_PX}px (smaller upload). Unchecked sends the original bytes.`; - /** A staged image awaiting send: a stable id + the `ImageInput` to forward. */ + /** + * A staged image awaiting send: a stable id + everything needed to resolve + * the `ImageInput` that will actually be sent. The ORIGINAL `File` (and its + * data URL) is kept so the per-image compress toggle is reversible until + * send; the async conversion result is cached so flipping the toggle never + * re-encodes. + */ interface StagedImage { readonly id: string; - readonly input: ImageInput; + /** The original untouched File — sent verbatim when compress is off. */ + readonly file: File; + /** Data URL of the original (staged immediately — conversion is async). */ + readonly originalUrl: string; + readonly originalMimeType: string | undefined; + /** Whether the converted (JPEG) variant is used for send. Default ON. */ + compress: boolean; + /** Cached conversion (data URL of the JPEG); `null` = none/failed/n.a. */ + converted: { url: string } | null; + } + + /** + * The `ImageInput` this staged image will actually SEND: the converted JPEG + * (data URL + `image/jpeg`) when compress is on AND a conversion exists, + * otherwise the original untouched bytes. + */ + function imageInput(img: StagedImage): ImageInput { + if (img.compress && img.converted !== null) { + return { url: img.converted.url, mimeType: "image/jpeg" }; + } + const { originalUrl: url, originalMimeType: mimeType } = img; + return { url, ...(mimeType ? { mimeType } : {}) }; + } + + /** Whether the compress toggle is rendered for this image (convertible formats only). */ + function compressible(img: StagedImage): boolean { + return shouldConvertImage(img.originalMimeType); } let { onSend, onQueue, onStop, + encodeJpeg = convertToJpeg, contextSize = undefined, contextWindow = undefined, status = "idle", + queuedMessageCount = undefined, }: { /** * Send a message (start a turn via `chat.send`). Carries any staged images @@ -33,12 +72,17 @@ /** * Enqueue a steering message (`chat.queue`). When provided AND the status * is `running`, the send button becomes a "Queue" button that steers the - * in-flight turn instead of starting a new one. Steering is text-only — - * it never carries images (a mid-turn injection has no image surface). + * in-flight turn instead of starting a new one. Carries staged images just + * like onSend; without this callback, in-flight input stays staged. */ - onQueue?: (text: string) => void; + onQueue?: (text: string, images?: ImageInput[]) => void; /** Stop the in-flight generation (`POST /conversations/:id/stop`). */ onStop?: () => void; + /** + * Injected compression edge (a `File` in, a maybe-smaller JPEG out). + * Defaults to the real canvas converter; tests stub this outermost edge. + */ + encodeJpeg?: ImageJpegEncoder; // Current context occupancy — updated progressively during a turn (the // latest step's input+output) and finalized to the turn's `contextSize` on // seal, or `undefined` when unknown — the status bar then shows @@ -53,6 +97,8 @@ * `running` for the send button (steer/stop). */ status?: ComposerStatus; + /** Current conversation's queued snapshot size; undefined when unavailable. */ + queuedMessageCount?: number | undefined; } = $props(); export type ComposerStatus = "idle" | "running" | "queued" | "error"; @@ -65,25 +111,23 @@ const hasText = $derived(text.trim().length > 0); const hasImages = $derived(images.length > 0); - const canSend = $derived(hasText || hasImages); + const inFlight = $derived(status === "running" || status === "queued"); + const canSend = $derived((hasText || hasImages) && (!inFlight || onQueue !== undefined)); const effectiveMax = $derived(contextWindow ?? FALLBACK_CONTEXT_WINDOW); const usage = $derived(computeContextUsage(contextSize, effectiveMax)); // One button, three modes: // - idle → "Send" (starts a turn via chat.send) - // - running/queued + text → "Queue" (steers via chat.queue — text only) + // - running/queued + text/images → "Queue" (steers via chat.queue) // - running/queued + empty → "Stop" (aborts via POST /stop) // (`queued` behaves like `running` — the turn is in flight, just waiting for a // concurrency slot; the user can still steer or stop it.) - // Steering never carries images: when running with images staged but no text, - // the images stay staged (queue is text-only). Images-without-text while running - // is an unusual case that still sends (the server auto-starts/resolves). - const inFlight = $derived(status === "running" || status === "queued"); const buttonMode = $derived.by<"send" | "queue" | "stop">(() => { if (inFlight && !hasText && !hasImages && onStop !== undefined) return "stop"; - if (inFlight && hasText && onQueue !== undefined) return "queue"; + if (inFlight) return "queue"; return "send"; }); + const stopButton = $derived(stopButtonLabels(queuedMessageCount)); const placeholder = $derived( status === "queued" ? "Queued for a slot…" @@ -120,8 +164,8 @@ resize(); }); - /** Read a File into a base64 data URL (`data:image/…;base64,…`). */ - function fileToDataUrl(file: File): Promise<string> { + /** Read a File/Blob into a base64 data URL (`data:image/…;base64,…`). */ + function fileToDataUrl(file: Blob): Promise<string> { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { @@ -142,10 +186,51 @@ // Prefer the file's declared MIME; fall back to the data URL's prefix. const mimeType = file.type || undefined; const id = `img-${Date.now()}-${imgSeq++}`; - images = [...images, { id, input: { url, ...(mimeType ? { mimeType } : {}) } }]; + images = [ + ...images, + { + id, + file, + originalUrl: url, + originalMimeType: mimeType, + compress: true, // compress is ON by default for every staged image + converted: null, + }, + ]; + // Convert async in the background — staging is never blocked on it; the + // staged input stays the original until the converted URL arrives. + if (shouldConvertImage(mimeType)) { + void runConversion(id); + } return true; } + /** + * Run the (injected) compression edge for a staged image and cache the + * result. ANY failure (rejecting encoder included) resolves to no + * conversion → the original is sent; compression never breaks staging. If + * the image was removed while converting, the result is dropped. + */ + async function runConversion(id: string): Promise<void> { + const staged = images.find((img) => img.id === id); + if (staged === undefined) return; + let result: ConvertedJpeg | null = null; + try { + result = await encodeJpeg(staged.file); + } catch { + result = null; // fall back to the original — never block or break sending + } + const entry = images.find((img) => img.id === id); + if (entry === undefined) return; // removed mid-conversion + entry.converted = result === null ? null : { url: await fileToDataUrl(result.blob) }; + } + + /** Flip the per-image compress toggle (reversible until send). */ + function setCompress(id: string, compress: boolean): void { + const entry = images.find((img) => img.id === id); + if (entry !== undefined) entry.compress = compress; + } + function removeImage(id: string): void { images = images.filter((img) => img.id !== id); } @@ -200,15 +285,15 @@ const trimmed = text.trim(); // Allow a send with images even when text is empty (an image-only turn). if (trimmed.length === 0 && !hasImages) return; - if (buttonMode === "queue") { - // Steering is text-only — never forward images. - onQueue?.(trimmed); + const toSend = hasImages ? images.map((img) => imageInput(img)) : undefined; + if (inFlight) { + if (onQueue === undefined) return; // Keep staged input if no queue port is wired. + onQueue(trimmed, toSend); } else { - const toSend: ImageInput[] | undefined = hasImages - ? images.map((img) => img.input) - : undefined; onSend(trimmed, toSend); } + // Clear only after handing the complete input to a callback. WS is still + // fire-and-forget: this does not imply backend storage has completed. text = ""; images = []; } @@ -239,7 +324,7 @@ <!-- Top bar: expanding textarea + image-attach button + single context-aware button --> <div class="flex items-end gap-2 px-4 pt-3 pb-2"> <div - class="flex-1" + class="min-w-0 flex-1" onpaste={handlePaste} class:border-2={dragOver} class:border-primary={dragOver} @@ -293,10 +378,11 @@ <button class="btn btn-error w-20 shrink-0" type="button" - aria-label="Stop generation" + aria-label={stopButton.ariaLabel} + title={stopButton.title} onclick={() => onStop?.()} > - Stop + {stopButton.text} </button> {:else} <button class="btn btn-primary w-20 shrink-0" type="submit" disabled={!canSend}> @@ -310,9 +396,10 @@ <div class="flex flex-wrap gap-2 px-4 pb-1"> {#each images as img (img.id)} <div class="group relative h-20 w-20 shrink-0 overflow-hidden rounded border border-base-300"> + <!-- Preview what will actually be sent (the converted JPEG when compress is on). --> <img - src={img.input.url} - alt={img.input.mimeType ?? "staged image"} + src={imageInput(img).url} + alt={imageInput(img).mimeType ?? "staged image"} class="h-full w-full object-cover" /> <button @@ -335,6 +422,22 @@ <line x1="6" y1="6" x2="18" y2="18"></line> </svg> </button> + <!-- Per-image compress toggle (convertible formats only; reversible until send). --> + {#if compressible(img)} + <label + class="absolute bottom-0 left-0 flex items-center gap-1 bg-base-100/80 px-1 py-0.5 text-[10px] leading-none" + title={COMPRESS_TOOLTIP} + > + <input + type="checkbox" + class="checkbox checkbox-xs" + aria-label="Compress image" + checked={img.compress} + onchange={(e) => setCompress(img.id, e.currentTarget.checked)} + /> + <span class="whitespace-nowrap">Compress</span> + </label> + {/if} </div> {/each} </div> diff --git a/src/features/chat/ui/LazyCollapse.svelte b/src/features/chat/ui/LazyCollapse.svelte new file mode 100644 index 0000000..3ce34dd --- /dev/null +++ b/src/features/chat/ui/LazyCollapse.svelte @@ -0,0 +1,46 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + + /** + * A daisyUI checkbox collapse whose BODY is rendered lazily: nothing inside + * `.collapse-content` is created until the user first opens it. A long turn + * has hundreds of collapsed tool/thinking cards, each holding a full JSON + * input / tool output / thought — building all of that DOM up front (only to + * be hidden) dominated first paint on slow devices. Once opened the body + * stays mounted, so the close animation and any inner scroll state work as + * before. Same markup + classes as the inline collapses it replaces. + */ + let { + label, + titleClass = "", + title, + children, + }: { + /** aria-label of the toggle checkbox. */ + label: string; + /** Extra classes on the `.collapse-title` row. */ + titleClass?: string; + title: Snippet; + children: Snippet; + } = $props(); + + let open = $state(false); + let everOpened = $state(false); + $effect(() => { + if (open) everOpened = true; + }); +</script> + +<!-- `.inset-well` (app.css): the recessed panel shared by thinking/tool blocks + and the sidebar views. --> +<div class="collapse inset-well w-full text-sm"> + <input type="checkbox" aria-label={label} bind:checked={open} /> + <div class="collapse-title flex min-h-0 min-w-0 items-center gap-2 py-2 whitespace-nowrap font-medium {titleClass}"> + {@render title()} + </div> + <div class="collapse-content"> + {#if open || everOpened} + {@render children()} + {/if} + </div> +</div> diff --git a/src/features/chat/ui/ModelSelector.svelte b/src/features/chat/ui/ModelSelector.svelte index 11b9feb..a9b0cc9 100644 --- a/src/features/chat/ui/ModelSelector.svelte +++ b/src/features/chat/ui/ModelSelector.svelte @@ -1,12 +1,28 @@ <script lang="ts"> import type { ModelMetadata } from "@dispatch/transport-contract"; - import { isVisionModel, joinModelName, modelKeys, modelsForKey, splitModelName } from "../model-select"; + import type { NamedAgent, NamedAgentSelectionSnapshot } from "../../named-agents"; + import { + isNamedAgentSelection, + isVisionModel, + joinModelName, + modelKeyLabel, + modelsForKey, + NAMED_AGENTS_PROVIDER, + namedAgentNames, + namedAgentSelectionLabel, + providerKeysWithNamedAgents, + splitModelName, + } from "../model-select"; let { models, selected, onSelect, modelInfo = {}, + namedAgents = [], + namedAgentSnapshot = null, + onSelectNamedAgent, + zeroProviders = false, }: { models: readonly string[]; selected: string; @@ -18,75 +34,199 @@ * Optional — absent metadata → no badge (treated as non-vision). */ modelInfo?: Readonly<Record<string, ModelMetadata>>; + /** The user-curated Named Agents (plan §6) offered under the synthetic provider. */ + namedAgents?: readonly NamedAgent[]; + /** The active conversation's Named Agent snapshot (null when not selected via one). */ + namedAgentSnapshot?: NamedAgentSelectionSnapshot | null; + /** Called when a Named Agent is selected (the selector resolves it to a raw model + snapshot). */ + onSelectNamedAgent?: (snapshot: NamedAgentSelectionSnapshot) => void; + /** + * First-boot guardrail (§7): true when the ACTIVE workspace has ZERO + * enabled providers (plan 06's `providers-status`) — renders a visible + * prompt above the picker instead of a silently empty one. + */ + zeroProviders?: boolean; } = $props(); - const keys = $derived(modelKeys(models)); + // Whether the current selection is a Named Agent snapshot (the conversation was + // selected via one; its captured raw model equals `selected`). + const currentIsNamedAgent = $derived(isNamedAgentSelection(selected, namedAgentSnapshot)); + + const providerKeys = $derived(providerKeysWithNamedAgents(models)); const current = $derived(splitModelName(selected)); const keyModels = $derived(modelsForKey(models, current.key)); + // The provider shown in the provider dropdown: the synthetic Named Agents + // provider when the selection is a Named Agent snapshot, else the raw key. + const currentProvider = $derived( + currentIsNamedAgent || current.key === NAMED_AGENTS_PROVIDER + ? NAMED_AGENTS_PROVIDER + : current.key, + ); + + const namedNames = $derived(namedAgentNames(namedAgents)); + // Whether the currently-selected full model name is vision-capable. const selectedVision = $derived(isVisionModel(modelInfo, selected)); - // Switching key jumps to the first model available under it. - function selectKey(key: string): void { + // Whether the currently-selected raw model is PRESENT in the catalog (only + // meaningful for a raw selection — a Named Agent snapshot resolves to a raw + // model that may have left the catalog). + const rawMissing = $derived( + !currentIsNamedAgent && selected !== "" && !models.includes(selected), + ); + const keyUnavailable = $derived( + !currentIsNamedAgent && selected !== "" && !providerKeys.includes(current.key), + ); + + // Switching provider. Going TO the Named Agents provider: jump to the first + // Named Agent (mirrors selectKey jumping to the first model); the model + // dropdown then lists named names. Going to a raw provider: existing behavior. + function selectProvider(key: string): void { + if (key === currentProvider) return; + if (key === NAMED_AGENTS_PROVIDER) { + selectFirstNamedAgent(); + return; + } const first = modelsForKey(models, key)[0] ?? ""; onSelect(joinModelName(key, first)); } - function selectModel(model: string): void { + // Selecting a raw model suffix under the current raw provider. + function selectRawModel(model: string): void { onSelect(joinModelName(current.key, model)); } - // The full `<key>/<model>` name for a model suffix under the current key. + // Selecting a Named Agent name (under the synthetic provider): resolve it to a + // snapshot + raw model and report via onSelectNamedAgent. + function selectNamedAgent(name: string): void { + const agent = namedAgents.find((a) => a.name === name); + if (agent === undefined) return; + onSelectNamedAgent?.({ + namedAgentId: agent.id, + namedAgentName: agent.name, + model: agent.model, + }); + } + + // Jump to the first configured Named Agent (used when the provider is switched + // to Named Agents), matching the raw-provider "jump to first model" behavior. + function selectFirstNamedAgent(): void { + const first = namedAgents[0]; + if (first === undefined) return; // no named agents — the model dropdown shows the disabled placeholder + onSelectNamedAgent?.({ + namedAgentId: first.id, + namedAgentName: first.name, + model: first.model, + }); + } + + // The full `<provider>/<model>` name for a raw model suffix under `current.key`. function fullNameFor(modelSuffix: string): string { return joinModelName(current.key, modelSuffix); } </script> <div class="flex flex-col gap-2"> + {#if zeroProviders} + <!-- First-boot guardrail (§7): a workspace with zero enabled providers + shows a visible prompt instead of a silently empty picker. --> + <div class="alert alert-warning text-sm" role="alert"> + <span> + No providers enabled for this workspace — open the workspace <strong>Plugins</strong> to + enable one. + </span> + </div> + {/if} <select class="select w-full" - value={current.key} - onchange={(e) => selectKey(e.currentTarget.value)} + value={currentProvider} + onchange={(e) => selectProvider(e.currentTarget.value)} aria-label="Key selector" > - {#each keys as key (key)} - <option value={key}>{key}</option> + {#if keyUnavailable && currentProvider !== NAMED_AGENTS_PROVIDER} + <option value={current.key} disabled>{modelKeyLabel(current.key)} (unavailable)</option> + {/if} + {#each providerKeys as key (key)} + <option value={key}> + {key === NAMED_AGENTS_PROVIDER ? "Named Agents" : modelKeyLabel(key)} + </option> {/each} </select> + <select class="select w-full" - value={current.model} - onchange={(e) => selectModel(e.currentTarget.value)} + value={currentIsNamedAgent ? (namedAgentSnapshot?.namedAgentName ?? "") : current.model} + onchange={ + currentIsNamedAgent + ? (e) => selectNamedAgent(e.currentTarget.value) + : (e) => selectRawModel(e.currentTarget.value) + } aria-label="Model selector" + disabled={currentProvider === NAMED_AGENTS_PROVIDER && namedAgents.length === 0} > - {#each keyModels as model (model)} - <option value={model}> - {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} - </option> - {/each} + {#if currentProvider === NAMED_AGENTS_PROVIDER} + {#if namedAgents.length === 0 && !currentIsNamedAgent} + <option value="" disabled>No named agents configured</option> + {:else} + {#each namedNames as name (name)} + <option value={name}>{namedAgentSelectionLabel(name)}</option> + {/each} + {#if currentIsNamedAgent && !namedNames.includes(namedAgentSnapshot?.namedAgentName ?? "")} + <!-- The selected Named Agent was renamed/removed — keep showing the + captured snapshot name rather than silently switching (plan §7). --> + <option value={namedAgentSnapshot?.namedAgentName ?? ""} disabled> + {namedAgentSelectionLabel(namedAgentSnapshot?.namedAgentName ?? "")} (unavailable) + </option> + {/if} + {/if} + {:else} + {#each keyModels as model (model)} + <option value={model}> + {model}{#if isVisionModel(modelInfo, fullNameFor(model))} · vision{/if} + </option> + {/each} + {#if rawMissing && !keyUnavailable} + <option value={current.model} disabled>{current.model} (unavailable)</option> + {/if} + {/if} </select> - {#if selectedVision} - <div class="flex items-center gap-1 text-xs text-base-content/60"> - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - fill="none" - stroke="currentColor" - stroke-width="2" - stroke-linecap="round" - stroke-linejoin="round" - class="h-3.5 w-3.5" - aria-hidden="true" - > - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> - <circle cx="12" cy="12" r="3"></circle> - </svg> - <span>Vision — this model sees images natively</span> + + {#if rawMissing} + <div class="text-xs text-warning" role="status"> + Selected model “{selected}” is unavailable — choose another credential. </div> - {:else} + {/if} + + {#if currentIsNamedAgent} <div class="text-xs text-base-content/40"> - Pasted images are auto-described (vision handoff) + Selected via <strong>Named Agents</strong> — snapshot of {namedAgentSnapshot?.namedAgentName}. </div> {/if} -</div> + + {#if !currentIsNamedAgent} + {#if selectedVision} + <div class="flex items-center gap-1 text-xs text-base-content/60"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + aria-hidden="true" + > + <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> + <circle cx="12" cy="12" r="3"></circle> + </svg> + <span>Vision — this model sees images natively</span> + </div> + {:else} + <div class="text-xs text-base-content/40"> + Pasted images are auto-described (vision handoff) + </div> + {/if} + {/if} +</div>
\ No newline at end of file diff --git a/src/features/chat/ui/PendingInputWarning.svelte b/src/features/chat/ui/PendingInputWarning.svelte new file mode 100644 index 0000000..c9e675e --- /dev/null +++ b/src/features/chat/ui/PendingInputWarning.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PendingInput } from "../pending-inputs"; + + let { + input, + onDiscard, + }: { + input: PendingInput; + onDiscard: (requestId: string) => void; + } = $props(); + + let visible = $state(false); + + // Presentation only: each keyed row gets one grace period, including restored + // or rejected inputs. Prop updates never restart it. Confirmation/discard + // removes the row, cancelling the timer along with any other unmount. + onMount(() => { + const timer = setTimeout(() => { + visible = true; + }, 1000); + return () => clearTimeout(timer); + }); +</script> + +{#if visible} + <div class="m-3 rounded border border-warning p-3 text-sm" role="status"> + <p> + {input.rejection + ? `Message rejected: ${input.rejection}` + : "Delivery unconfirmed. Your input is saved on this device."} + </p> + <p>Check the conversation history before sending again to avoid a duplicate.</p> + <textarea + class="textarea textarea-bordered mt-2 w-full" + aria-label="Saved message" + readonly + value={input.request.type === "chat.send" ? input.request.message : input.request.text} + ></textarea> + {#each input.request.images ?? [] as image} + <img class="my-2 max-h-32" src={image.url} alt="Saved attachment" /> + {/each} + <button class="btn btn-sm mt-2" onclick={() => onDiscard(input.request.requestId)} + >Discard saved copy</button + > + </div> +{/if} diff --git a/src/features/chat/ui/PendingInputs.svelte b/src/features/chat/ui/PendingInputs.svelte new file mode 100644 index 0000000..dcb7aee --- /dev/null +++ b/src/features/chat/ui/PendingInputs.svelte @@ -0,0 +1,16 @@ +<script lang="ts"> + import type { PendingInput } from "../pending-inputs"; + import PendingInputWarning from "./PendingInputWarning.svelte"; + + let { + inputs, + onDiscard, + }: { + inputs: readonly PendingInput[]; + onDiscard: (requestId: string) => void; + } = $props(); +</script> + +{#each inputs as input (input.request.requestId)} + <PendingInputWarning {input} {onDiscard} /> +{/each} diff --git a/src/features/chat/ui/PendingInputs.test.ts b/src/features/chat/ui/PendingInputs.test.ts new file mode 100644 index 0000000..cea770d --- /dev/null +++ b/src/features/chat/ui/PendingInputs.test.ts @@ -0,0 +1,217 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PendingInput } from "../pending-inputs"; +import { createChatStore } from "../store.svelte"; +import { + createFakeCache, + createFakeHistorySync, + createFakeMetricsSync, + createFakeTransport, +} from "../test-helpers"; +import PendingInputs from "./PendingInputs.svelte"; + +function input(requestId: string, type: "chat.send" | "chat.queue" = "chat.send"): PendingInput { + return { + request: { + ...(type === "chat.send" ? { type, message: requestId } : { type, text: requestId }), + conversationId: "c", + requestId, + images: [{ url: "data:image/png;base64,AAAA", mimeType: "image/png" }], + }, + }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => { + cleanup(); + const remaining = vi.getTimerCount(); + vi.useRealTimers(); + expect(remaining).toBe(0); +}); + +describe("pending input warning grace period", () => { + it.each([ + "chat.send", + "chat.queue", + ] as const)("renders no %s recovery UI before 1000 ms, then keeps its content stable", async (type) => { + const { container } = render(PendingInputs, { + inputs: [input("saved message", type)], + onDiscard: vi.fn(), + }); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + await vi.advanceTimersByTimeAsync(999); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + const warning = screen.getByRole("status"); + expect(warning).toHaveTextContent("Delivery unconfirmed. Your input is saved on this device."); + expect(warning).toHaveTextContent( + "Check the conversation history before sending again to avoid a duplicate.", + ); + expect(screen.getByRole("textbox", { name: "Saved message" })).toHaveValue("saved message"); + expect(screen.getByRole("img", { name: "Saved attachment" })).toHaveAttribute( + "src", + "data:image/png;base64,AAAA", + ); + const content = warning.innerHTML; + await vi.advanceTimersByTimeAsync(10_000); + expect(screen.getByRole("status")).toBe(warning); + expect(warning.innerHTML).toBe(content); + }); + + it.each([ + "confirmation", + "discard", + ])("%s before the threshold cancels the timer and never flashes later", async (resolution) => { + const transport = createFakeTransport(); + const save = vi.fn(); + const store = createChatStore({ + conversationId: "c", + createRequestId: () => "first", + transport: transport.impl, + historySync: createFakeHistorySync().impl, + metricsSync: createFakeMetricsSync().impl, + cache: createFakeCache().impl, + pendingInputStorage: { load: () => [], save }, + }); + try { + store.send("first"); + // Saving and transmission stay immediate, independent of warning display. + expect(save).toHaveBeenCalledExactlyOnceWith(store.pendingInputs); + expect(transport.sent).toHaveLength(1); + const { container, rerender } = render(PendingInputs, { + inputs: store.pendingInputs, + onDiscard: store.discardPendingInput, + }); + await vi.advanceTimersByTimeAsync(999); + if (resolution === "confirmation") { + store.handleDelta({ type: "chat.accepted", conversationId: "c", requestId: "first" }); + } else { + store.discardPendingInput("first"); + } + expect(store.pendingInputs).toEqual([]); + expect(save).toHaveBeenLastCalledWith([]); + await rerender({ inputs: store.pendingInputs }); + expect(vi.getTimerCount()).toBe(0); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + await vi.advanceTimersByTimeAsync(10_000); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + } finally { + store.dispose(); + } + }); + + it("hides an already visible warning immediately on confirmation", async () => { + const { container, rerender } = render(PendingInputs, { + inputs: [input("first")], + onDiscard: vi.fn(), + }); + await vi.advanceTimersByTimeAsync(1000); + expect(screen.getByRole("status")).toBeInTheDocument(); + await rerender({ inputs: [] }); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + }); + + it("preserves the discard action and hides the saved copy without another delay", async () => { + const onDiscard = vi.fn(); + const { container, rerender } = render(PendingInputs, { + inputs: [input("first")], + onDiscard, + }); + await vi.advanceTimersByTimeAsync(1000); + await fireEvent.click(screen.getByRole("button", { name: "Discard saved copy" })); + expect(onDiscard).toHaveBeenCalledExactlyOnceWith("first"); + await rerender({ inputs: [] }); + expect(container.children).toHaveLength(0); + expect(container.textContent).toBe(""); + }); + + it("cancels all waiting timers on unmount", async () => { + const { unmount } = render(PendingInputs, { + inputs: [input("first"), input("second")], + onDiscard: vi.fn(), + }); + expect(vi.getTimerCount()).toBe(2); + await vi.advanceTimersByTimeAsync(500); + unmount(); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(10_000); + expect(screen.queryByRole("status")).toBeNull(); + }); + + it("gives successive inputs independent delays without restarting existing rows", async () => { + const first = input("first"); + const second = input("second"); + const { rerender } = render(PendingInputs, { inputs: [first], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(600); + await rerender({ inputs: [second, { ...first }], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(399); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + const warning = screen.getByRole("status"); + expect(screen.getByRole("textbox")).toHaveValue("first"); + await rerender({ inputs: [{ ...second }, { ...first }], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(599); + expect(screen.getAllByRole("status")).toEqual([warning]); + await vi.advanceTimersByTimeAsync(1); + expect( + screen.getAllByRole("textbox").map((node) => (node as HTMLTextAreaElement).value), + ).toEqual(["second", "first"]); + // Removing the older warning must not re-hide/restart the newer warning. + await rerender({ inputs: [second] }); + expect(screen.getByRole("textbox")).toHaveValue("second"); + expect(vi.getTimerCount()).toBe(0); + }); + + it("starts a fresh delay after an earlier input was confirmed during its grace period", async () => { + const { rerender } = render(PendingInputs, { inputs: [input("first")], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(600); + await rerender({ inputs: [] }); + await rerender({ inputs: [input("second")] }); + await vi.advanceTimersByTimeAsync(999); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + expect(screen.getByRole("textbox")).toHaveValue("second"); + }); + + it("keeps the original deadline when rejection arrives, and updates visible content immediately", async () => { + const pending = input("first"); + const { rerender } = render(PendingInputs, { inputs: [pending], onDiscard: vi.fn() }); + await vi.advanceTimersByTimeAsync(600); + await rerender({ inputs: [{ ...pending, rejection: "disk full" }] }); + await vi.advanceTimersByTimeAsync(399); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + const warning = screen.getByRole("status"); + expect(warning).toHaveTextContent("Message rejected: disk full"); + expect(warning).not.toHaveTextContent("Delivery unconfirmed"); + await rerender({ inputs: [{ ...pending, rejection: "still rejected" }] }); + expect(screen.getByRole("status")).toBe(warning); + expect(warning).toHaveTextContent("Message rejected: still rejected"); + expect(vi.getTimerCount()).toBe(0); + }); + + it.each([ + undefined, + "disk full", + ])("restored entries (rejection: %s) use the same grace period and retain recovery content", async (rejection) => { + const restored = input("restored saved message"); + render(PendingInputs, { + inputs: [rejection === undefined ? restored : { ...restored, rejection }], + onDiscard: vi.fn(), + }); + await vi.advanceTimersByTimeAsync(999); + expect(screen.queryByRole("status")).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + expect(screen.getByRole("status")).toHaveTextContent( + rejection === undefined ? "Delivery unconfirmed" : "Message rejected: disk full", + ); + expect(screen.getByRole("textbox")).toHaveValue("restored saved message"); + expect(screen.getByRole("button", { name: "Discard saved copy" })).toBeEnabled(); + }); +}); diff --git a/src/features/chat/ui/ReasoningEffortSelector.svelte b/src/features/chat/ui/ReasoningEffortSelector.svelte index d982905..6858779 100644 --- a/src/features/chat/ui/ReasoningEffortSelector.svelte +++ b/src/features/chat/ui/ReasoningEffortSelector.svelte @@ -1,34 +1,45 @@ <script lang="ts"> import type { ReasoningEffort } from "@dispatch/transport-contract"; import { - effectiveEffort, - effortOptions, - isReasoningEffort, - type SaveReasoningEffort, + effectiveSelection, + isThinkingSelection, + selectionOptions, + type SaveThinkingSelection, + type ThinkingSelection, } from "../reasoning-effort"; let { - persisted, + persistedEffort, + persistedThinking, save, }: { - /** The conversation's persisted level, or null when never set (default applies). */ - persisted: ReasoningEffort | null; - save: SaveReasoningEffort; + /** The conversation's persisted effort level, or null when never set (default applies). */ + persistedEffort: ReasoningEffort | null; + /** + * The conversation's persisted thinking flag, or null when never set + * (thinking ON — the default). `false` ⇒ thinking disabled (the separate + * "off" axis); the effort level is preserved across an off→on toggle. + */ + persistedThinking: boolean | null; + /** Persist a thinking selection (off or a level). */ + save: SaveThinkingSelection; } = $props(); - const options = effortOptions(); + const options = selectionOptions(); - // The user's in-flight choice; null = mirror the (async-loaded) persisted prop. - // Re-mounted per conversation, so there is no cross-tab bleed. - let chosen = $state<ReasoningEffort | null>(null); + // The user's in-flight choice; null = mirror the (async-loaded) persisted + // selection. Re-mounted per conversation, so there is no cross-tab bleed. + let chosen = $state<ThinkingSelection | null>(null); let saving = $state(false); let error = $state<string | null>(null); let justSaved = $state(false); - const selected = $derived(chosen ?? effectiveEffort(persisted)); + const selected = $derived( + chosen ?? effectiveSelection(persistedEffort, persistedThinking), + ); async function handleChange(value: string) { - if (!isReasoningEffort(value) || saving) return; + if (!isThinkingSelection(value) || saving) return; chosen = value; saving = true; error = null; @@ -40,7 +51,7 @@ justSaved = true; } else { error = result.error; - chosen = null; // revert to the persisted value + chosen = null; // revert to the persisted selection } } </script> @@ -69,7 +80,7 @@ <p class="text-xs text-success">Saved — applies from the next turn.</p> {:else} <p class="text-xs opacity-50"> - How long the model thinks before answering. Changing it can re-prefill the prompt cache once. + How long the model thinks before answering. “Off” disables thinking entirely. Changing it can re-prefill the prompt cache once. </p> {/if} </div> diff --git a/src/features/chat/ui/ToolCard.svelte b/src/features/chat/ui/ToolCard.svelte new file mode 100644 index 0000000..10f8528 --- /dev/null +++ b/src/features/chat/ui/ToolCard.svelte @@ -0,0 +1,274 @@ +<script lang="ts"> + import type { ToolEntry } from "../index"; + import { viewToolEntry } from "../logic/tool-summary"; + import LazyCollapse from "./LazyCollapse.svelte"; + import { TOOL_ICON_COMPONENTS } from "./tool-icons"; + + let { entry }: { entry: ToolEntry } = $props(); + const card = $derived(viewToolEntry(entry)); + const ToolIcon = $derived(TOOL_ICON_COMPONENTS[card.icon]); + let showAll = $state(false); +</script> + +<div class="tool-card"> + <LazyCollapse label={`Toggle tool call ${card.label}`} titleClass="pe-4"> + {#snippet title()} + <div class="tool-header" class:has-earlier-call={card.callDetail.kind === "unavailable"}> + {#if card.status === "pending"} + <!-- Same in-progress animation as the streaming thinking block. All three + status icons share a fixed w-5 slot so the row never shifts on settle. --> + <span class="loading loading-dots loading-sm w-5 shrink-0" aria-label="Running"></span> + {:else if card.status === "error"} + <span class="w-5 shrink-0 text-center text-error" aria-label="Failed">✗</span> + {:else} + <span + class="w-5 shrink-0 text-center text-success" + class:opacity-60={card.callDetail.kind === "unavailable"} + aria-label="Succeeded">✓</span + > + {/if} + <!-- Per-tool Phosphor icon (key chosen by the pure view-model). --> + <ToolIcon size={16} class="shrink-0 opacity-70" aria-hidden="true" /> + <span class="tool-name truncate" title={card.name}>{card.label}</span> + <span + class="tool-summary truncate font-normal text-base-content/60" + class:earlier-history={card.callDetail.kind === "unavailable"} + title={card.callDetail.kind === "unavailable" ? card.callDetail.text : card.callSummary} + > + {card.callSummary} + </span> + <span class="tool-badges gap-1 text-xs"> + {#each card.callBadges as badge} + <span + class:text-success={badge.tone === "success"} + class:text-error={badge.tone === "error"}>{badge.label}</span + > + {/each} + </span> + {#if card.resultSummary !== null} + <span + class="tool-result text-xs" + class:text-error={card.status === "error"} + title={card.resultSummary} + > + {#if card.resultSummary === card.compactResultSummary} + <span>→ {card.resultSummary}</span> + {:else} + <span class="result-full">→ {card.resultSummary}</span> + <span class="result-compact">→ {card.compactResultSummary}</span> + {/if} + </span> + {:else} + <span class="tool-result text-xs text-base-content/60">→ running…</span> + {/if} + </div> + {/snippet} + <div class="min-w-0 space-y-4 text-xs"> + <section aria-label="Call"> + {#if card.callDetail.kind === "unavailable"} + <aside + role="note" + class="border-l-2 border-base-content/20 pl-3 italic text-base-content/60" + > + {card.callDetail.text} + </aside> + {:else} + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Call + </h3> + {/if} + {#if card.callDetail.kind === "none"} + <p class="italic text-base-content/60">No arguments</p> + {:else if card.callDetail.kind === "command"} + <pre class="overflow-x-auto">$ {card.callDetail.text}</pre> + {:else if card.callDetail.kind === "edit"} + <div class="mb-2 break-all font-mono">{card.callDetail.path}</div> + <!-- Unified-diff style: every removed/added line carries its own −/+ gutter + marker and a tinted row, so multi-line edits read as one diff block. --> + <div class="edit-diff overflow-x-auto rounded font-mono" role="group" aria-label="Edit diff"> + {#if card.callDetail.oldText !== ""} + {#each card.callDetail.oldText.split("\n") as line, i (i)} + <div class="edit-diff-row edit-diff-del"> + <span class="edit-diff-sign" aria-label="removed">−</span><span>{line}</span> + </div> + {/each} + {/if} + {#if card.callDetail.newText !== ""} + {#each card.callDetail.newText.split("\n") as line, i (i)} + <div class="edit-diff-row edit-diff-add"> + <span class="edit-diff-sign" aria-label="added">+</span><span>{line}</span> + </div> + {/each} + {/if} + </div> + {:else if card.callDetail.kind === "fields"} + <dl class="call-fields"> + {#each card.callDetail.fields as field} + <dt class="font-semibold text-base-content/85">{field.key}</dt> + <dd class="min-w-0"> + <pre + class="overflow-x-auto" + class:whitespace-pre-wrap={!field.multiline} + class:break-words={!field.multiline}>{field.value}</pre> + </dd> + {/each} + </dl> + {/if} + </section> + <section aria-label="Response"> + <h3 class="mb-2 border-b border-base-content/20 pb-1 font-semibold text-base-content"> + Response + </h3> + {#if card.output === null} + <span class="text-base-content/75">running…</span> + {:else} + <div class:text-error={card.output.isError}> + {#if card.output.kind === "full"} + <pre class="overflow-x-auto">{card.output.lines.join("\n") || "(no output)"}</pre> + {:else} + {#if !showAll} + <pre class="overflow-x-auto">{card.output.head.join("\n")}</pre> + {/if} + <div class="output-controls" class:sticky-controls={showAll}> + <button + class="btn btn-soft btn-xs focus-visible:outline-primary" + aria-expanded={showAll} + onclick={() => { + showAll = !showAll; + }} + > + {#if showAll}<span aria-hidden="true">↑</span>{/if} + {showAll ? "Show fewer lines" : `⋯ ${card.output.hiddenCount} more lines`} + </button> + </div> + {#if showAll} + <pre class="overflow-x-auto">{card.output.all.join("\n")}</pre> + {:else} + <pre class="overflow-x-auto">{card.output.tail.join("\n")}</pre> + {/if} + {/if} + </div> + {/if} + </section> + </div> + </LazyCollapse> +</div> + +<style> + .tool-card { + container-type: inline-size; + } + .tool-header { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + min-width: 0; + } + /* English names are short phrases: keep them whole and let the call + summary absorb the squeeze. The cap only bites on long MCP names. */ + .tool-name { + flex: 0 0 auto; + max-width: 60%; + } + .tool-summary { + flex: 1; + min-width: 0; + } + .earlier-history { + font-style: italic; + color: var(--color-base-content); + opacity: 0.7; + } + .tool-badges { + display: flex; + flex-shrink: 0; + } + .tool-badges:empty { + display: none; + } + .tool-result { + flex-shrink: 0; + white-space: nowrap; + } + .result-compact { + display: none; + } + .call-fields { + display: grid; + grid-template-columns: minmax(4rem, max-content) minmax(0, 1fr); + gap: 0.5rem 1rem; + align-items: baseline; + } + .call-fields dt { + max-width: 10rem; + overflow-wrap: anywhere; + } + .edit-diff { + background: var(--color-base-300); + padding-block: 0.25rem; + } + .edit-diff-row { + display: flex; + min-width: max-content; + white-space: pre; + padding-inline-end: 0.75rem; + } + .edit-diff-sign { + flex-shrink: 0; + width: 1.75rem; + text-align: center; + user-select: none; + } + .edit-diff-del { + color: var(--color-error); + background: color-mix(in oklab, var(--color-error) 12%, transparent); + } + .edit-diff-add { + color: var(--color-success); + background: color-mix(in oklab, var(--color-success) 12%, transparent); + } + .output-controls { + padding-block: 0.5rem; + color: var(--color-base-content); + /* Same fill as the LazyCollapse inset well this sits in (opaque, so + scrolled output never shows through the sticky bar). */ + background: var(--inset-well-bg, var(--color-base-200)); + } + .sticky-controls { + position: sticky; + top: 0; + z-index: 2; + border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent); + margin-bottom: 0.5rem; + } + @container (max-width: 48rem) { + .result-full { + display: none; + } + .result-compact { + display: inline; + } + } + @container (max-width: 40rem) { + .tool-badges { + display: none; + } + .has-earlier-call .tool-result { + display: none; + } + } + @container (max-width: 24rem) { + .tool-header { + gap: 0.375rem; + } + .call-fields { + column-gap: 0.75rem; + } + } + @container (max-width: 18rem) { + .tool-result { + display: none; + } + } +</style> diff --git a/src/features/chat/ui/tool-icons.ts b/src/features/chat/ui/tool-icons.ts new file mode 100644 index 0000000..1d68ebe --- /dev/null +++ b/src/features/chat/ui/tool-icons.ts @@ -0,0 +1,52 @@ +// Phosphor icon per tool-icon key (the pure view-model picks the KEY — see +// `logic/tool-summary.ts` `toolIcon`; this is the only place that knows the +// concrete components). Deep imports (`phosphor-svelte/lib/<Name>Icon`) on +// purpose: the package root re-exports ~1,500 components, and Svelte libraries +// are not prebundled in dev, so a root import would load every one of them. + +import ArrowSquareOutIcon from "phosphor-svelte/lib/ArrowSquareOutIcon"; +import BookOpenIcon from "phosphor-svelte/lib/BookOpenIcon"; +import BracketsCurlyIcon from "phosphor-svelte/lib/BracketsCurlyIcon"; +import ChatCircleTextIcon from "phosphor-svelte/lib/ChatCircleTextIcon"; +import CpuIcon from "phosphor-svelte/lib/CpuIcon"; +import EyeIcon from "phosphor-svelte/lib/EyeIcon"; +import FileTextIcon from "phosphor-svelte/lib/FileTextIcon"; +import FloppyDiskIcon from "phosphor-svelte/lib/FloppyDiskIcon"; +import ImageIcon from "phosphor-svelte/lib/ImageIcon"; +import ListChecksIcon from "phosphor-svelte/lib/ListChecksIcon"; +import MagnifyingGlassIcon from "phosphor-svelte/lib/MagnifyingGlassIcon"; +import PaperPlaneTiltIcon from "phosphor-svelte/lib/PaperPlaneTiltIcon"; +import PencilSimpleIcon from "phosphor-svelte/lib/PencilSimpleIcon"; +import ProhibitIcon from "phosphor-svelte/lib/ProhibitIcon"; +import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; +import RobotIcon from "phosphor-svelte/lib/RobotIcon"; +import TerminalWindowIcon from "phosphor-svelte/lib/TerminalWindowIcon"; +import UsersThreeIcon from "phosphor-svelte/lib/UsersThreeIcon"; +import WrenchIcon from "phosphor-svelte/lib/WrenchIcon"; +import YoutubeLogoIcon from "phosphor-svelte/lib/YoutubeLogoIcon"; +import type { Component } from "svelte"; +import type { ToolIconKey } from "../logic/tool-summary"; + +// biome-ignore lint/suspicious/noExplicitAny: Phosphor's IconComponentProps is internal; any icon accepts size/class. +export const TOOL_ICON_COMPONENTS: Readonly<Record<ToolIconKey, Component<any>>> = { + terminal: TerminalWindowIcon, + "file-read": FileTextIcon, + "file-edit": PencilSimpleIcon, + "file-write": FloppyDiskIcon, + search: MagnifyingGlassIcon, + code: BracketsCurlyIcon, + todos: ListChecksIcon, + skill: BookOpenIcon, + vision: EyeIcon, + image: ImageIcon, + youtube: YoutubeLogoIcon, + "agent-summon": RobotIcon, + "agent-send": PaperPlaneTiltIcon, + "agent-read": ChatCircleTextIcon, + "agent-open": ArrowSquareOutIcon, + "agent-cancel": ProhibitIcon, + agents: UsersThreeIcon, + models: CpuIcon, + mcp: PuzzlePieceIcon, + tool: WrenchIcon, +}; diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts index 6e6b770..9c557f5 100644 --- a/src/features/concurrency/logic/view-model.test.ts +++ b/src/features/concurrency/logic/view-model.test.ts @@ -13,6 +13,7 @@ import { parseLimitInput, pauseLabel, providerFromModel, + providerLabel, providerOptions, statusLabel, summarizeLimits, @@ -95,7 +96,7 @@ describe("cooldownLabel", () => { describe("providerFromModel", () => { it("takes the part before the first slash", () => { expect(providerFromModel("openai/gpt-4o")).toBe("openai"); - expect(providerFromModel("openai-compat/gpt-4o-mini")).toBe("openai-compat"); + expect(providerFromModel("opencode-1/gpt-4o-mini")).toBe("opencode-1"); }); it("returns the whole string when there is no slash", () => { expect(providerFromModel("umans")).toBe("umans"); @@ -127,6 +128,23 @@ describe("providerOptions", () => { }); }); +describe("providerLabel", () => { + it("labels a numbered OpenCode provider as OpenCode #N", () => { + expect(providerLabel("opencode-2")).toBe("OpenCode #2"); + expect(providerLabel("opencode-10")).toBe("OpenCode #10"); + }); + + it("leaves non-OpenCode provider ids unchanged", () => { + expect(providerLabel("umans")).toBe("umans"); + expect(providerLabel("anthropic")).toBe("anthropic"); + }); + + it("rejects noncanonical numbered ids (label passthrough)", () => { + expect(providerLabel("opencode")).toBe("opencode"); + expect(providerLabel("opencode-0")).toBe("opencode-0"); + }); +}); + // ── pauseLabel + formatPauseDuration ─────────────────────────────────────────── describe("formatPauseDuration", () => { @@ -450,12 +468,12 @@ describe("normalizeConcurrencyLimits", () => { const limits = normalizeConcurrencyLimits({ limits: [ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ], }); expect(limits).toEqual([ { providerId: "umans", limit: 4 }, - { providerId: "openai-compat", limit: 5 }, + { providerId: "opencode-1", limit: 5 }, ]); }); @@ -510,7 +528,7 @@ describe("normalizeConcurrencyStatus", () => { providers: [ { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, { - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, @@ -532,7 +550,7 @@ describe("normalizeConcurrencyStatus", () => { }); expect(first !== undefined && !("pausedUntil" in first)).toBe(true); expect(second).toEqual({ - providerId: "openai-compat", + providerId: "opencode-1", limit: 5, inFlight: 5, queued: 3, diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index 8a37198..80c155c 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -170,6 +170,18 @@ export function providerOptions( return out; } +/** + * Friendly display label for a concurrency provider id. A numbered OpenCode + * provider (`opencode-<n>`) renders as "OpenCode #<n>" (so "opencode-2" reads + * as a coherent credential identity, matching the model picker); any other id is + * shown unchanged. The RAW id is still what is sent to / displayed-with the + * backend — this only affects presentation. + */ +export function providerLabel(providerId: string): string { + const m = /^opencode-([1-9][0-9]*)$/.exec(providerId); + return m === null ? providerId : `OpenCode #${m[1]}`; +} + // ── Status → display view ────────────────────────────────────────────────────── const NO_LIMITS = "No limits configured"; diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte index bf06ac0..c8f6c8f 100644 --- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -4,6 +4,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, statusLabel, type Badge, type ConcurrencyLimitView, @@ -132,8 +133,8 @@ the buttons never wrap; tight gap + narrow inputs keep the provider name visible; the provider shrinks via flex-1 + min-w-0). --> <div class="flex flex-nowrap items-center gap-1"> - <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId} - >{limit.providerId}</span + <span class="min-w-0 flex-1 truncate font-medium" title={limit.providerId} + >{providerLabel(limit.providerId)}</span > <input type="text" diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index aadb8d1..2e23f5c 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -6,6 +6,7 @@ DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + providerLabel, providerOptions, summarizeLimits, viewConcurrencyLimits, @@ -376,7 +377,7 @@ <option value="" disabled>No providers available</option> {:else} {#each providerOpts as provider (provider)} - <option value={provider}>{provider}</option> + <option value={provider}>{providerLabel(provider)}</option> {/each} {/if} </select> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index a8163c2..e214692 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -11,8 +11,8 @@ import type { } from "../logic/types"; import ConcurrencyView from "./ConcurrencyView.svelte"; -// Available models → provider ids are "umans", "anthropic", "openai-compat". -const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "openai-compat/gpt-4o"] as const; +// Available models → provider ids are "umans", "anthropic", "opencode-1". +const MODELS = ["umans/umans-glm-5.2", "anthropic/claude-sonnet", "opencode-1/gpt-4o"] as const; // A status entry factory (defaults to a healthy limited provider). The new // concurrency-fixes fields (`cooldownMs`, `autoReduced`) are always present. diff --git a/src/features/conversation-cache/cache.test.ts b/src/features/conversation-cache/cache.test.ts index ce1c60c..72b4e20 100644 --- a/src/features/conversation-cache/cache.test.ts +++ b/src/features/conversation-cache/cache.test.ts @@ -15,8 +15,16 @@ const chunk = (seq: number, role: "user" | "assistant" = "user"): StoredChunk => */ function createFakeStore(): ConversationChunkStore { const store = new Map<string, StoredChunk[]>(); + const epochs = new Map<string, number>(); return { + async historyEpoch(id) { + return epochs.get(id); + }, + async reset(id, epoch) { + store.delete(id); + epochs.set(id, epoch); + }, async load(conversationId) { return store.get(conversationId) ?? []; }, @@ -33,6 +41,7 @@ function createFakeStore(): ConversationChunkStore { async delete(conversationId) { store.delete(conversationId); + epochs.delete(conversationId); }, async index() { @@ -195,3 +204,48 @@ describe("cache.delete", () => { expect(result).toEqual([]); }); }); + +it("serializes invalidation and rejects stale epoch commits", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("c", [chunk(1)]); // Legacy rows have no trusted epoch. + expect(await cache.commit("c", [chunk(101)], 1)).toEqual([chunk(101)]); + await Promise.all([cache.commit("c", [chunk(102)], 1), cache.reset("c", 2)]); + expect(await cache.commit("c", [chunk(103)], 1)).toEqual([]); + expect(await store.historyEpoch("c")).toBe(2); +}); + +it("replaces a confirmed full history at a lower epoch, without trusting stale commits", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await cache.commit("c", [chunk(9)], 5); + expect(await cache.replace("c", [chunk(1)], 1, 5)).toBe(true); + expect(await cache.historyEpoch("c")).toBe(1); + expect(await cache.load("c")).toEqual([chunk(1)]); + expect(await cache.commit("c", [chunk(9)], 5)).toEqual([chunk(1)]); + // A delayed full response started against epoch 5 must not restore it. + expect(await cache.replace("c", [chunk(9)], 5, 5)).toBe(false); + expect(await cache.load("c")).toEqual([chunk(1)]); +}); + +it("serializes a guarded replacement behind newer invalidation", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await cache.commit("c", [chunk(9)], 5); + const resetting = cache.reset("c", 6); + const replacing = cache.replace("c", [chunk(1)], 1, 5); + await resetting; + expect(await replacing).toBe(false); + expect(await cache.historyEpoch("c")).toBe(6); + expect(await cache.load("c")).toEqual([]); +}); + +it("allows an empty replacement and a legacy cache only if its observed epoch still matches", async () => { + const store = createFakeStore(); + const cache = createConversationCache(store); + await store.append("c", [chunk(9)]); + expect(await cache.replace("c", [], 0, undefined)).toBe(true); + expect(await cache.sinceSeq("c")).toBe(0); + expect(await cache.historyEpoch("c")).toBe(0); + expect(await cache.replace("c", [chunk(9)], 2, undefined)).toBe(false); +}); diff --git a/src/features/conversation-cache/cache.ts b/src/features/conversation-cache/cache.ts index 4953944..600a6e6 100644 --- a/src/features/conversation-cache/cache.ts +++ b/src/features/conversation-cache/cache.ts @@ -3,6 +3,22 @@ import { nextSinceSeq, reconcileCache, selectEvictions } from "./logic"; import type { ConversationChunkStore } from "./types"; export interface ConversationCache { + historyEpoch(conversationId: string): Promise<number | undefined>; + reset(conversationId: string, historyEpoch: number): Promise<void>; + + /** + * Replace from a fresh full-history response, including an epoch decrease. + * Only succeeds if the cache still has the epoch observed BEFORE that request. + * Unlike monotonic reset/commit, this is for confirmed server authority, not + * broadcasts or cursor/backfill responses. False means the response is stale. + */ + replace( + conversationId: string, + incoming: readonly StoredChunk[], + historyEpoch: number, + expectedEpoch: number | undefined, + ): Promise<boolean>; + /** Load all cached chunks for a conversation. */ load(conversationId: string): Promise<readonly StoredChunk[]>; @@ -10,7 +26,11 @@ export interface ConversationCache { * Load + reconcile + append new chunks. * Returns the merged cache (the new authoritative cache for this conversation). */ - commit(conversationId: string, incoming: readonly StoredChunk[]): Promise<readonly StoredChunk[]>; + commit( + conversationId: string, + incoming: readonly StoredChunk[], + historyEpoch?: number, + ): Promise<readonly StoredChunk[]>; /** Return the `?sinceSeq=` cursor for the next incremental sync. */ sinceSeq(conversationId: string): Promise<number>; @@ -43,21 +63,61 @@ export function createConversationCache( ): ConversationCache { const maxChunks = opts?.maxChunks ?? DEFAULT_MAX_CHUNKS; + // Serialize mutation per conversation, including invalidation, so an old fetch + // cannot resurrect rows after a compaction broadcast has invalidated them. + const pending = new Map<string, Promise<unknown>>(); + function exclusive<T>(id: string, run: () => Promise<T>): Promise<T> { + const next = (pending.get(id) ?? Promise.resolve()).catch(() => {}).then(run); + pending.set(id, next); + void next + .finally(() => { + if (pending.get(id) === next) pending.delete(id); + }) + .catch(() => {}); + return next; + } return { + historyEpoch: async (id) => { + await pending.get(id); + return store.historyEpoch(id); + }, + reset: (id, epoch) => + exclusive(id, async () => { + const current = await store.historyEpoch(id); + if (current === undefined || current < epoch) await store.reset(id, epoch); + }), + replace: (id, incoming, epoch, expectedEpoch) => + exclusive(id, async () => { + if ((await store.historyEpoch(id)) !== expectedEpoch) return false; + await store.reset(id, epoch); + if (incoming.length > 0) await store.append(id, incoming); + return true; + }), async load(conversationId) { + await pending.get(conversationId); return store.load(conversationId); }, - async commit(conversationId, incoming) { - const cached = await store.load(conversationId); - const { merged, toAppend } = reconcileCache(cached, incoming); - if (toAppend.length > 0) { - await store.append(conversationId, toAppend); - } - return merged; + commit(conversationId, incoming, epoch) { + return exclusive(conversationId, async () => { + if (epoch !== undefined) { + const current = await store.historyEpoch(conversationId); + // Once an epoch is known, only explicit invalidation or guarded + // full-history replacement may change it (in either direction). + if (current !== undefined && current !== epoch) return store.load(conversationId); + if (current !== epoch) await store.reset(conversationId, epoch); + } + const cached = await store.load(conversationId); + const { merged, toAppend } = reconcileCache(cached, incoming); + if (toAppend.length > 0) { + await store.append(conversationId, toAppend); + } + return merged; + }); }, async sinceSeq(conversationId) { + await pending.get(conversationId); const cached = await store.load(conversationId); return nextSinceSeq(cached); }, @@ -72,7 +132,7 @@ export function createConversationCache( }, async delete(conversationId) { - await store.delete(conversationId); + await exclusive(conversationId, () => store.delete(conversationId)); }, }; } diff --git a/src/features/conversation-cache/logic.test.ts b/src/features/conversation-cache/logic.test.ts index 880c32e..7a31783 100644 --- a/src/features/conversation-cache/logic.test.ts +++ b/src/features/conversation-cache/logic.test.ts @@ -138,3 +138,26 @@ describe("selectEvictions", () => { expect(result).toEqual(["b"]); }); }); + +describe("reconcileCache — turnId provenance upgrade", () => { + it("upgrades a cached row lacking turnId when the incoming copy carries it", () => { + const cached: StoredChunk[] = [{ seq: 1, role: "user", chunk: { type: "text", text: "hi" } }]; + const incoming: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + { seq: 2, role: "assistant", chunk: { type: "text", text: "yo" }, turnId: "t1" }, + ]; + const { merged, toAppend } = reconcileCache(cached, incoming); + expect(merged.map((c) => c.turnId)).toEqual(["t1", "t1"]); + expect(toAppend.map((c) => c.seq)).toEqual([1, 2]); + }); + + it("does not rewrite a cached row that already has a turnId", () => { + const cached: StoredChunk[] = [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]; + const { toAppend } = reconcileCache(cached, [ + { seq: 1, role: "user", chunk: { type: "text", text: "hi" }, turnId: "t1" }, + ]); + expect(toAppend).toEqual([]); + }); +}); diff --git a/src/features/conversation-cache/logic.ts b/src/features/conversation-cache/logic.ts index 3cb23e2..344a061 100644 --- a/src/features/conversation-cache/logic.ts +++ b/src/features/conversation-cache/logic.ts @@ -5,27 +5,35 @@ import type { ConversationCacheIndexEntry, ReconcileResult } from "./types"; * Merge authoritative seq-keyed chunks with cached chunks. * * Deduplicates by `seq`, produces seq-monotonic order. - * `toAppend` = the incoming chunks whose `seq` is not already in `cached` - * (exactly what to persist). Idempotent; tolerant of out-of-order/overlapping `incoming`. + * `toAppend` = the incoming chunks whose `seq` is not already in `cached`, + * plus cached rows UPGRADED with a `turnId` the incoming copy carries and the + * cached copy lacks (exactly what to persist — the store upserts by seq). Idempotent; tolerant of out-of-order/overlapping `incoming`. */ export function reconcileCache( cached: readonly StoredChunk[], incoming: readonly StoredChunk[], ): ReconcileResult { - const seen = new Set<number>(); + const bySeq = new Map<number, StoredChunk>(); for (const chunk of cached) { - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); } const toAppend: StoredChunk[] = []; for (const chunk of incoming) { - if (!seen.has(chunk.seq)) { + const have = bySeq.get(chunk.seq); + if (have === undefined) { toAppend.push(chunk); - seen.add(chunk.seq); + bySeq.set(chunk.seq, chunk); + } else if (have.turnId === undefined && chunk.turnId !== undefined) { + // Upgrade a cached row written before the server stamped `turnId` + // (seqs are immutable, so only this provenance field can differ). + const upgraded = { ...have, turnId: chunk.turnId }; + toAppend.push(upgraded); + bySeq.set(chunk.seq, upgraded); } } - const merged = [...cached, ...toAppend].sort((a, b) => a.seq - b.seq); + const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq); return { merged, toAppend }; } diff --git a/src/features/conversation-cache/types.ts b/src/features/conversation-cache/types.ts index 345ab71..a273f34 100644 --- a/src/features/conversation-cache/types.ts +++ b/src/features/conversation-cache/types.ts @@ -17,6 +17,10 @@ export interface ConversationCacheIndexEntry { * All methods MUST be idempotent on `seq`: re-appending an existing seq is a no-op. */ export interface ConversationChunkStore { + historyEpoch(conversationId: string): Promise<number | undefined>; + /** Atomically replace cached rows with empty history at this epoch. */ + reset(conversationId: string, historyEpoch: number): Promise<void>; + /** Load all cached chunks for a conversation, seq-ordered. Returns [] if absent. */ load(conversationId: string): Promise<readonly StoredChunk[]>; diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts index 3d3d525..83cec74 100644 --- a/src/features/heartbeat/logic/types.ts +++ b/src/features/heartbeat/logic/types.ts @@ -26,6 +26,15 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped"; export interface HeartbeatConfig { /** Whether the autonomous loop is enabled (running on the interval). */ readonly enabled: boolean; + /** + * When true (the default), the heartbeat SKIPS a fire whenever the configured + * workspace has any active agents (a conversation whose persisted status is + * `"active"` or `"queued"`) — it stays quiet while the user is actively + * working and only fires when the workspace is idle. When false, the heartbeat + * fires unconditionally on every interval. The heartbeat-spawned conversation + * lives in a dedicated workspace, so an in-flight run never self-blocks. + */ + readonly inactiveOnly: boolean; readonly systemPrompt: string; readonly taskPrompt: string; /** Minutes between runs. */ @@ -46,6 +55,7 @@ export interface HeartbeatConfig { */ export interface HeartbeatConfigPatch { readonly enabled?: boolean; + readonly inactiveOnly?: boolean; readonly systemPrompt?: string; readonly taskPrompt?: string; readonly intervalMinutes?: number; diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts index aca0aa6..c9ef118 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -39,6 +39,7 @@ const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({ const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({ enabled: false, + inactiveOnly: true, systemPrompt: "be helpful", taskPrompt: "check status", intervalMinutes: 15, @@ -254,6 +255,40 @@ describe("config form", () => { const f = formFromConfig(c); expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form }); + + it("emptyForm defaults inactiveOnly to true (on by default)", () => { + expect(emptyForm().inactiveOnly).toBe(true); + }); + + it("formFromConfig carries inactiveOnly through verbatim", () => { + expect(formFromConfig(config({ inactiveOnly: true })).inactiveOnly).toBe(true); + expect(formFromConfig(config({ inactiveOnly: false })).inactiveOnly).toBe(false); + }); + + it("formFromConfig coerces a missing/malformed inactiveOnly to the default (true)", () => { + // A legacy config (undefined) or a non-boolean is read as ON (true) — matches + // normalizeHeartbeatConfig's default and the backend's "on by default". + const f = formFromConfig(config({ inactiveOnly: undefined as unknown as boolean })); + expect(f.inactiveOnly).toBe(true); + }); + + it("patchFromForm carries inactiveOnly", () => { + expect(patchFromForm(formFromConfig(config({ inactiveOnly: false }))).inactiveOnly).toBe(false); + expect(patchFromForm(formFromConfig(config({ inactiveOnly: true }))).inactiveOnly).toBe(true); + }); + + it("formDiffers is true after toggling inactiveOnly", () => { + const c = config({ inactiveOnly: true }); + const f = formFromConfig(c); + f.inactiveOnly = false; + expect(formDiffers(f, c)).toBe(true); + }); + + it("formDiffers is false for a form seeded from the config (inactiveOnly unchanged)", () => { + const c = config({ inactiveOnly: false }); + const f = formFromConfig(c); + expect(formDiffers(f, c)).toBe(false); + }); }); describe("system-prompt inheritance (override ⇄ global default)", () => { @@ -321,6 +356,7 @@ describe("normalizeHeartbeatConfig", () => { it("passes through a well-formed config", () => { const c = normalizeHeartbeatConfig({ enabled: true, + inactiveOnly: false, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 20, @@ -329,6 +365,7 @@ describe("normalizeHeartbeatConfig", () => { }); expect(c).toEqual({ enabled: true, + inactiveOnly: false, systemPrompt: "sys", taskPrompt: "task", intervalMinutes: 20, @@ -339,10 +376,12 @@ describe("normalizeHeartbeatConfig", () => { it("coerces a malformed body safely (never throws, never undefined)", () => { const c = normalizeHeartbeatConfig({ enabled: "yes", + inactiveOnly: "yes", intervalMinutes: -3, reasoningEffort: "bogus", }); expect(c.enabled).toBe(false); + expect(c.inactiveOnly).toBe(true); // non-boolean → default ON expect(c.intervalMinutes).toBe(1); expect(c.reasoningEffort).toBeNull(); expect(c.systemPrompt).toBe(""); @@ -355,12 +394,30 @@ describe("normalizeHeartbeatConfig", () => { it("handles null / non-object input", () => { const c = normalizeHeartbeatConfig(null); expect(c.enabled).toBe(false); + expect(c.inactiveOnly).toBe(true); // default ON for an absent config expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES); expect(c.model).toBe(""); }); it("clamps a huge interval", () => { expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440); }); + it("inactiveOnly defaults to true when absent (legacy config → on by default)", () => { + // A config persisted by an older backend (no inactiveOnly field) reads back + // as true — the feature is ON by default for everyone. + expect(normalizeHeartbeatConfig({}).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: undefined }).inactiveOnly).toBe(true); + }); + it("inactiveOnly passes through an explicit false (opt-out)", () => { + expect(normalizeHeartbeatConfig({ inactiveOnly: false }).inactiveOnly).toBe(false); + expect(normalizeHeartbeatConfig({ inactiveOnly: true }).inactiveOnly).toBe(true); + }); + it("inactiveOnly treats only an explicit boolean false as false (not 0, not null)", () => { + // The wire contract requires a JSON boolean; a non-boolean (0, null, "no") + // is treated as the default (true) rather than silently misbehaving. + expect(normalizeHeartbeatConfig({ inactiveOnly: 0 }).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: null }).inactiveOnly).toBe(true); + expect(normalizeHeartbeatConfig({ inactiveOnly: "false" }).inactiveOnly).toBe(true); + }); }); describe("normalizeHeartbeatRuns", () => { diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts index e91febd..4a5ba7f 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -220,6 +220,7 @@ export function approximateNextRunEpoch( */ export interface HeartbeatFormState { enabled: boolean; + inactiveOnly: boolean; systemPrompt: string; taskPrompt: string; intervalHours: number; @@ -254,6 +255,7 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState { const { hours, minutes } = splitInterval(config.intervalMinutes); return { enabled: config.enabled === true, + inactiveOnly: config.inactiveOnly !== false, systemPrompt: config.systemPrompt ?? "", taskPrompt: config.taskPrompt ?? "", intervalHours: hours, @@ -268,6 +270,7 @@ export function emptyForm(): HeartbeatFormState { const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES); return { enabled: false, + inactiveOnly: true, systemPrompt: "", taskPrompt: "", intervalHours: hours, @@ -295,6 +298,7 @@ export function normalizeInterval(value: unknown): number { export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch { return { enabled: form.enabled, + inactiveOnly: form.inactiveOnly, systemPrompt: form.systemPrompt, taskPrompt: form.taskPrompt, intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes), @@ -308,6 +312,7 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): const { hours, minutes } = splitInterval(config.intervalMinutes); return ( form.enabled !== config.enabled || + form.inactiveOnly !== config.inactiveOnly || form.systemPrompt !== (config.systemPrompt ?? "") || form.taskPrompt !== (config.taskPrompt ?? "") || form.intervalHours !== hours || @@ -390,6 +395,10 @@ export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig { const effort = d.reasoningEffort; return { enabled: d.enabled === true, + // Default ON (true): a missing/falsey-but-not-false field (a legacy config + // persisted before the field shipped) reads back as inactiveOnly: true — + // the feature is on by default for everyone. Only an explicit `false` opts out. + inactiveOnly: d.inactiveOnly !== false, systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "", taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "", intervalMinutes: normalizeInterval(d.intervalMinutes), diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte index 5f262f8..7f95c40 100644 --- a/src/features/heartbeat/ui/HeartbeatView.svelte +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -140,6 +140,31 @@ } } + // The inactive-only checkbox is a save-on-change control (like the enable + // toggle): a partial PUT { inactiveOnly } — no need to round-trip the rest + // of the form. The heartbeat then skips a fire whenever the workspace has + // active agents (a conversation whose status is "active" or "queued"). + async function handleToggleInactiveOnly(): Promise<void> { + if (saving) return; + const next = !form.inactiveOnly; + form = { ...form, inactiveOnly: next }; + saving = true; + saveError = null; + justSaved = false; + const result = await saveConfig({ inactiveOnly: next }); + saving = false; + if (result === null) return; + if (result.ok) { + form = formFromConfig(result.config); + loadedConfig = formFromConfig(result.config); + justSaved = true; + } else { + saveError = result.error; + // Revert the checkbox to the last-known state. + form = { ...form, inactiveOnly: loadedConfig.inactiveOnly }; + } + } + // ── Runs list (polls while mounted) ─────────────────────────────────────── let runs = $state<readonly HeartbeatRunView[]>([]); /** The raw backend runs (carry `triggeredAt`), kept for the next-run @@ -323,6 +348,27 @@ {#if configError} <p class="text-xs text-error">{configError}</p> {:else} + <!-- Inactive-only (skip fires while the workspace has active agents) --> + <section class="flex flex-col gap-1"> + <label class="flex items-start gap-2 text-sm"> + <input + type="checkbox" + class="checkbox checkbox-sm checkbox-primary mt-0.5" + checked={form.inactiveOnly} + disabled={saving || configLoading} + onchange={handleToggleInactiveOnly} + aria-label="Only run the heartbeat when the workspace is idle" + /> + <span class="flex flex-col gap-0.5"> + <span>Only run when idle</span> + <span class="text-xs opacity-50"> + Skip heartbeat fires while agents are active in this workspace. When off, the + heartbeat runs on every interval regardless of activity. + </span> + </span> + </label> + </section> + <!-- Prompts (open the full-page editor) --> <section class="flex flex-col gap-1"> <span class="text-xs font-semibold uppercase opacity-60">Prompts</span> diff --git a/src/features/heartbeat/ui/HeartbeatView.test.ts b/src/features/heartbeat/ui/HeartbeatView.test.ts new file mode 100644 index 0000000..89eeee3 --- /dev/null +++ b/src/features/heartbeat/ui/HeartbeatView.test.ts @@ -0,0 +1,203 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { LoadSystemPrompt, LoadSystemPromptVariables } from "../../system-prompt"; +import type { + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatConfigResult, + HeartbeatNextRunResult, + HeartbeatStopResult, + LoadHeartbeatConfig, + LoadHeartbeatNextRun, + LoadHeartbeatRuns, + SaveHeartbeatConfig, + StopHeartbeatRun, +} from "../logic/types"; +import HeartbeatView from "./HeartbeatView.svelte"; + +// ── Fakes for the injected ports ───────────────────────────────────────────── +// Only the OUTERMOST edges are faked (the save/load ports); no sibling module is +// mocked. Mirrors the PromptEditor test's fake-port pattern. + +function makeConfig(over: Partial<HeartbeatConfig> = {}): HeartbeatConfig { + return { + enabled: false, + inactiveOnly: true, + systemPrompt: "", + taskPrompt: "", + intervalMinutes: 30, + model: "", + reasoningEffort: null, + ...over, + }; +} + +/** A capturing saveConfig that echoes a merged config (so the form re-seeds). */ +function fakeSaveConfig(initial: HeartbeatConfig): { + calls: HeartbeatConfigPatch[]; + impl: SaveHeartbeatConfig; +} { + const calls: HeartbeatConfigPatch[] = []; + let current = initial; + const impl: SaveHeartbeatConfig = async (patch) => { + calls.push(patch); + // Echo the merged config so the component re-seeds from the server response. + current = { ...current, ...patch }; + return { ok: true, config: current } satisfies HeartbeatConfigResult; + }; + return { calls, impl }; +} + +function fakeLoadConfig(config: HeartbeatConfig): LoadHeartbeatConfig { + return vi.fn(async () => ({ ok: true, config }) as const); +} + +function fakeLoadRuns(): LoadHeartbeatRuns { + return vi.fn(async () => ({ ok: true, runs: [] }) as const); +} + +function fakeStopRun(): StopHeartbeatRun { + return vi.fn(async () => ({ ok: true }) as const satisfies HeartbeatStopResult); +} + +function fakeLoadNextRun(): LoadHeartbeatNextRun { + // No scheduled run (heartbeat disabled in the default config) → no countdown. + return vi.fn( + async () => ({ ok: true, nextRunAt: null }) as const satisfies HeartbeatNextRunResult, + ); +} + +function fakeLoadVariables(): LoadSystemPromptVariables { + return vi.fn(async () => ({ ok: true, variables: [] }) as const); +} + +function fakeLoadDefaultPrompt(): LoadSystemPrompt { + return vi.fn(async () => ({ ok: true, template: "" }) as const); +} + +const baseProps = (overrides: Record<string, unknown> = {}) => ({ + models: [] as readonly string[], + loadConfig: fakeLoadConfig(makeConfig()), + saveConfig: fakeSaveConfig(makeConfig()).impl, + loadVariables: fakeLoadVariables(), + loadDefaultPrompt: fakeLoadDefaultPrompt(), + loadRuns: fakeLoadRuns(), + stopRun: fakeStopRun(), + loadNextRun: fakeLoadNextRun(), + onOpenRun: vi.fn(), + ...overrides, +}); + +// HeartbeatView sets up polling intervals (runs + next-run + clock) on mount. +// Clear any stray timers between tests so a later test never hangs on a leaked +// interval (the $effect cleanup clears them on unmount; this is belt+suspenders). +afterEach(() => { + vi.clearAllTimers(); +}); + +describe("HeartbeatView — inactive-only checkbox", () => { + it("renders checked when the loaded config has inactiveOnly: true (the default)", async () => { + const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: true })); + render(HeartbeatView, { + props: baseProps({ loadConfig }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + }); + + it("renders unchecked when the loaded config has inactiveOnly: false", async () => { + const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: false })); + render(HeartbeatView, { + props: baseProps({ loadConfig }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).not.toBeChecked(); + }); + + it("toggling the checkbox persists a PARTIAL patch { inactiveOnly } and re-seeds", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: true }); + const save = fakeSaveConfig(initial); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: save.impl }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + + // The save port was called with ONLY { inactiveOnly: false } — a partial + // update, not the whole config (mirrors the enable toggle's partial PUT). + await vi.waitFor(() => { + expect(save.calls).toHaveLength(1); + }); + expect(save.calls[0]).toEqual({ inactiveOnly: false }); + + // After the save resolves, the checkbox reflects the server response (unchecked). + await vi.waitFor(() => { + expect(checkbox).not.toBeChecked(); + }); + }); + + it("toggling back on sends { inactiveOnly: true }", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: false }); + const save = fakeSaveConfig(initial); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: save.impl }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).not.toBeChecked(); + + await user.click(checkbox); + + await vi.waitFor(() => { + expect(save.calls).toHaveLength(1); + }); + expect(save.calls[0]).toEqual({ inactiveOnly: true }); + await vi.waitFor(() => { + expect(checkbox).toBeChecked(); + }); + }); + + it("a failed save reverts the checkbox to the last-known state", async () => { + const user = userEvent.setup(); + const initial = makeConfig({ inactiveOnly: true }); + const failingSave: SaveHeartbeatConfig = async () => ({ + ok: false, + error: "boom", + }); + const loadConfig = fakeLoadConfig(initial); + render(HeartbeatView, { + props: baseProps({ loadConfig, saveConfig: failingSave }), + }); + + const checkbox = await screen.findByLabelText( + "Only run the heartbeat when the workspace is idle", + ); + expect(checkbox).toBeChecked(); + + await user.click(checkbox); + + // The failed save surfaces the error AND reverts the checkbox (stays checked). + await vi.waitFor(() => { + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + expect(checkbox).toBeChecked(); + }); +}); diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts index 284b319..c4bd3b8 100644 --- a/src/features/heartbeat/ui/PromptEditor.test.ts +++ b/src/features/heartbeat/ui/PromptEditor.test.ts @@ -29,6 +29,7 @@ function fakeSaveConfig(): { // Echo a config that reflects the persisted patch (so onSaved sync is realistic). const config = { enabled: false, + inactiveOnly: true, systemPrompt: patch.systemPrompt ?? "", taskPrompt: patch.taskPrompt ?? "", intervalMinutes: 30, diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte index 92068ae..12e248a 100644 --- a/src/features/heartbeat/ui/RunModal.svelte +++ b/src/features/heartbeat/ui/RunModal.svelte @@ -154,6 +154,7 @@ {:else} <ChatView chunks={chat.chunks} + recovering={chat.recovering} turnMetrics={chat.turnMetrics} hasEarlier={chat.hasEarlier} onShowEarlier={chat.showEarlier} diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts new file mode 100644 index 0000000..24f9c94 --- /dev/null +++ b/src/features/layout/adapter/abi-port.test.ts @@ -0,0 +1,97 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; +import { abiTree, createLayoutAbiPort } from "./abi-port"; + +/** + * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition + * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the + * outermost edges; nothing internal is mocked — asymmetric testing). + */ + +function fakeStore( + overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {}, +): LayoutStore { + return { + get tree(): LayoutTree { + return DEFAULT_LAYOUT; + }, + get providerState(): LayoutProviderState { + return "present"; + }, + get activeWorkspaceId(): string { + return "ws-a"; + }, + probe: vi.fn(async () => {}), + onWorkspaceChanged: vi.fn(), + onServerTree: vi.fn(), + onTreeChange: vi.fn(), + resetToDefault: vi.fn(async () => null), + onExtensionsChanged: vi.fn(), + onWorkspacePluginsChanged: vi.fn(), + ...overrides, + } as LayoutStore; +} + +describe("abiTree — the getTree decision (pure)", () => { + it("a PRESENT provider exposes the tree", () => { + expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT); + }); + + it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => { + expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull(); + }); + + it("an unknown (not-yet-probed) provider exposes NULL too", () => { + expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull(); + }); +}); + +describe("createLayoutAbiPort — applyOps → the ops route", () => { + it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => { + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const edited: LayoutTree = { ...DEFAULT_LAYOUT }; + const store = fakeStore(); + const seen: string[] = []; + const port = createLayoutAbiPort({ + store, + postOps: async (workspaceId, o) => { + seen.push(workspaceId); + return { ok: true, tree: edited, applied: o.length }; + }, + }); + + const result = await port.applyOps(ops); + expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time + expect(result).toEqual({ ok: true, tree: edited, applied: 1 }); + // The successful apply echoes the tree into the store (instant local + // re-render; the WS broadcast echo is idempotent with this). + expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited); + }); + + it("a failed batch (typed no-provider error) does NOT touch the store", async () => { + const store = fakeStore({ providerState: "absent" }); + const port = createLayoutAbiPort({ + store, + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + + const result = await port.applyOps([{ type: "removeNode", target: "side" }]); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + expect(store.onServerTree).not.toHaveBeenCalled(); + }); + + it("getTree returns the active tree while the provider is present, null when absent", () => { + const port = createLayoutAbiPort({ + store: fakeStore({ providerState: "present" }), + postOps: async () => ({ ok: false, error: "unused" }), + }); + expect(port.getTree()).toBe(DEFAULT_LAYOUT); + + const absent = createLayoutAbiPort({ + store: fakeStore({ providerState: "absent" }), + postOps: async () => ({ ok: false, error: "no layout provider active" }), + }); + expect(absent.getTree()).toBeNull(); + }); +}); diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts new file mode 100644 index 0000000..69576c6 --- /dev/null +++ b/src/features/layout/adapter/abi-port.ts @@ -0,0 +1,60 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutProviderState, LayoutStore } from "../store.svelte"; + +/** + * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5). + * The composition root builds it with {@link createLayoutAbiPort} and the + * plugin host hands it to every `activate(api)` — plugins never see the + * store or the HTTP edge, only THIS. + */ +export interface LayoutAbiPort { + /** + * Apply op-vocabulary edits to the ACTIVE workspace's tree through the + * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider + * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`. + */ + readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; + /** The ACTIVE workspace's tree; `null` in no-provider mode. */ + readonly getTree: () => LayoutTree | null; +} + +export interface LayoutAbiPortDeps { + /** The layout store (the active workspace's tree + provider state). */ + readonly store: LayoutStore; + /** The ops HTTP edge — bound per CALL to the then-active workspace. */ + readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>; +} + +/** + * Build the ABI's `layout` port over the layout store + the ops HTTP edge + * (pure composition — no ambient state; both deps injected). A successful + * apply feeds the returned tree back through `onServerTree` so the local + * client re-renders immediately (the server's WS broadcast is the + * authoritative echo for every client, this one included — applying it twice + * is idempotent). The workspace is read at CALL time, so a workspace switch + * between calls targets the right tree; a switch mid-flight is guarded by + * `onServerTree`'s own workspace filter. + */ +export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort { + return { + async applyOps(ops): Promise<ApplyOpsResult> { + const workspaceId = deps.store.activeWorkspaceId; + const result = await deps.postOps(workspaceId, ops); + if (result.ok) { + deps.store.onServerTree(workspaceId, result.tree); + } + return result; + }, + getTree(): LayoutTree | null { + // `present` = an active provider is serving the active workspace. In + // no-provider (and not-yet-probed) mode the local tree is a fallback, + // not shared state — null tells the plugin there is nothing to read. + return abiTree(deps.store.providerState, deps.store.tree); + }, + }; +} + +/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */ +export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null { + return state === "present" ? tree : null; +} diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts new file mode 100644 index 0000000..30e194a --- /dev/null +++ b/src/features/layout/adapter/http.test.ts @@ -0,0 +1,182 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { createLayoutHttp } from "./http"; + +/** + * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake: + * a scripted `fetchImpl` returning canned Responses. No internal mocks. + */ + +const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout"; + +function fetchReturning( + status: number, + body: unknown, + capture?: { url?: string; init?: RequestInit | undefined }, +): typeof fetch { + return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => { + if (capture !== undefined) { + capture.url = + typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + capture.init = init; + } + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + }; +} + +describe("createLayoutHttp — getTree", () => { + it("GETs the well-known route and unwraps { tree }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.url).toBe(LAYOUT_URL); + expect(capture.init?.method).toBeUndefined(); // GET is the default + }); + + it("maps a 404 to the no-provider signal (notFound: true)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(404, { error: "No layout provider is active" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + }); + + it("maps a 500 to a non-notFound failure carrying the body's error", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(500, { error: "Layout storage failure" }), + ); + const result = await http.getTree("ws-a"); + expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" }); + }); + + it("a network failure degrades to notFound (the no-provider mode)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("Failed to fetch"); + }); + const result = await http.getTree("ws-a"); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.notFound).toBe(true); + expect(result.error).toContain("Failed to fetch"); + } + }); + + it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { chunks: [], historyEpoch: 0, latestSeq: 0 }), + ); + const result = await http.getTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — putTree", () => { + it("PUTs { tree } as JSON and unwraps the echo", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("PUT"); + expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT })); + }); + + it("maps a 400 { error } to a validation failure (not notFound)", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "root: split has no children" }), + ); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toEqual({ + ok: false, + notFound: false, + error: "root: split has no children", + }); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.putTree("ws-a", DEFAULT_LAYOUT); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — deleteTree", () => { + it("DELETEs and unwraps the fresh seed", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture), + ); + const result = await http.deleteTree("ws-a"); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT }); + expect(capture.init?.method).toBe("DELETE"); + }); + + it("maps a 404 to the no-provider signal", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.deleteTree("ws-a"); + expect(result).toMatchObject({ ok: false, notFound: true }); + }); +}); + +describe("createLayoutHttp — postOps (plan 04 §4.2)", () => { + it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => { + const capture: { url?: string; init?: RequestInit } = {}; + const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const; + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture), + ); + const result = await http.postOps("ws-a", ops); + expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 }); + expect(capture.url).toBe(`${LAYOUT_URL}/ops`); + expect(capture.init?.method).toBe("POST"); + expect(capture.init?.body).toBe(JSON.stringify({ ops })); + }); + + it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(404, {})); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "no layout provider active" }); + }); + + it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => { + const http = createLayoutHttp( + "http://backend.test", + fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }), + ); + const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]); + expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 }); + }); + + it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => { + const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 })); + const result = await http.postOps("ws-a", []); + expect(result).toMatchObject({ ok: false }); + }); + + it("a network failure degrades to a typed failure (never throws)", async () => { + const http = createLayoutHttp("http://backend.test", async () => { + throw new TypeError("fetch failed"); + }); + const result = await http.postOps("ws-a", []); + expect(result).toEqual({ ok: false, error: "fetch failed" }); + }); +}); diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts new file mode 100644 index 0000000..ff15a70 --- /dev/null +++ b/src/features/layout/adapter/http.ts @@ -0,0 +1,175 @@ +import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract"; + +/** + * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the + * ACTIVE layout provider's well-known routes: + * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace) + * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }` + * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; the plugins + * modal's "Reset layout to default" button — plan 04 §4.4) + * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }` + * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE + * ABI's `layout.applyOps` door) + * + * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). The 404 is a SIGNAL, not an error: it means + * no layout provider is active, and the store renders its local default with + * ephemeral edits (never an error UI). A network failure degrades the same way. + */ + +/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */ +export type LayoutHttpResult = + | { readonly ok: true; readonly tree: LayoutTree } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface LayoutHttp { + getTree(workspaceId: string): Promise<LayoutHttpResult>; + putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>; + deleteTree(workspaceId: string): Promise<LayoutHttpResult>; + /** + * The plan-04 ops door (§4.2): apply op-vocabulary edits through the + * provider. The result IS the contract's `ApplyOpsResult` — the 404 + * (no provider) maps to the typed `{ ok: false, error: "no layout + * provider active" }` failure the ABI documents. + */ + postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a 404 — never poisons the store. */ +function malformed(): LayoutHttpResult { + return { ok: false, notFound: true, error: "Malformed layout response" }; +} + +export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp { + return { + async getTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async putTree(workspaceId, tree): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tree }), + }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async deleteTree(workspaceId): Promise<LayoutHttpResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`, + { method: "DELETE" }, + ); + if (res.status === 404) { + return { ok: false, notFound: true, error: "No layout provider is active" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { tree?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return malformed(); + return { ok: true, tree: body.tree as LayoutTree }; + } catch (err) { + return { + ok: false, + notFound: true, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + + async postOps(workspaceId, ops): Promise<ApplyOpsResult> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ops }), + }, + ); + // The no-provider 404 is the ABI's typed signal (plan 04 §4.5). + if (res.status === 404) { + return { ok: false, error: "no layout provider active" }; + } + if (!res.ok) return await opsError(res); + const body = (await res.json()) as { tree?: unknown; applied?: unknown }; + if (body === null || typeof body !== "object" || body.tree === undefined) + return { ok: false, error: "Malformed layout response" }; + return { + ok: true, + tree: body.tree as LayoutTree, + applied: typeof body.applied === "number" ? body.applied : ops.length, + }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Layout request failed", + }; + } + }, + }; +} + +/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */ +async function opsError(res: Response): Promise<ApplyOpsResult> { + try { + const body = (await res.json()) as { error?: unknown; failedOp?: unknown }; + const error = + typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`; + return { + ok: false, + error, + ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}), + }; + } catch { + return { ok: false, error: `HTTP ${res.status}` }; + } +} diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts new file mode 100644 index 0000000..8a464fe --- /dev/null +++ b/src/features/layout/index.ts @@ -0,0 +1,51 @@ +/** + * The layout feature — a generic tile-tree renderer + the view registry. + * + * The frontend stops *having* a layout and starts *rendering* one: the tree is + * data (`@dispatch/layout-contract`), the renderer walks it, and every view a + * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays + * OUTSIDE the tree — the tree owns the content region. + */ + +export { + abiTree, + createLayoutAbiPort, + type LayoutAbiPort, + type LayoutAbiPortDeps, +} from "./adapter/abi-port"; +export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http"; +export { + bootDefaultOpen, + type CollapseState, + type CollapseStore, + type CollapseStoreOptions, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./logic/collapse"; +export { migrationTree, sidePanelViews, storedPanelViews } from "./logic/migration"; +export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit"; +export { + getView, + listPanelViews, + listViews, + type RegisteredView, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewComponent, + type ViewRegistration, + viewRegistry, +} from "./logic/registry"; +export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed"; +export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query"; +export { createLayoutStore, type LayoutProviderState, type LayoutStore } from "./store.svelte"; +export { default as TileRoot } from "./ui/TileRoot.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "layout", + description: "Tile-tree renderer + view registry", +} as const; diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts new file mode 100644 index 0000000..77e9f3c --- /dev/null +++ b/src/features/layout/logic/collapse.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + bootDefaultOpen, + createCollapseStore, + parsePersistedOpen, + SIDEBAR_OPEN_KEY, + WIDE_BREAKPOINT, +} from "./collapse"; + +function fakeStorage(): { storage: Storage; raw(key: string): string | null } { + const map = new Map<string, string>(); + const storage: Storage = { + get length() { + return map.size; + }, + clear: () => map.clear(), + getItem: (key) => map.get(key) ?? null, + key: () => null, + removeItem: (key) => map.delete(key), + setItem: (key, value) => void map.set(key, value), + }; + return { storage, raw: (key) => map.get(key) ?? null }; +} + +describe("bootDefaultOpen", () => { + it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => { + expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true); + expect(bootDefaultOpen(1440)).toBe(true); + expect(bootDefaultOpen(1023)).toBe(false); + expect(bootDefaultOpen(0)).toBe(false); + }); + + it("defaults to open when the viewport is unknown (SSR/tests)", () => { + expect(bootDefaultOpen(undefined)).toBe(true); + }); +}); + +describe("parsePersistedOpen", () => { + it("accepts today's bare JSON boolean format", () => { + expect(parsePersistedOpen("true")).toBe(true); + expect(parsePersistedOpen("false")).toBe(false); + }); + + it("ignores null, malformed JSON, and non-boolean values", () => { + expect(parsePersistedOpen(null)).toBeNull(); + expect(parsePersistedOpen("{")).toBeNull(); + expect(parsePersistedOpen('"true"')).toBeNull(); + expect(parsePersistedOpen("{}")).toBeNull(); + }); +}); + +describe("createCollapseStore", () => { + it("uses the viewport default when nothing is persisted", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + expect(store.isOpen("side")).toBe(true); + const narrow = createCollapseStore({ viewportWidth: 800 }); + expect(narrow.isOpen("side")).toBe(false); + }); + + it("restores the persisted boolean over the viewport default", () => { + const { storage } = fakeStorage(); + storage.setItem(SIDEBAR_OPEN_KEY, "false"); + const store = createCollapseStore({ storage, viewportWidth: 1440 }); + expect(store.isOpen("side")).toBe(false); + }); + + it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => { + const { storage, raw } = fakeStorage(); + const store = createCollapseStore({ storage, viewportWidth: 1024 }); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("false"); + store.toggle("side"); + expect(raw(SIDEBAR_OPEN_KEY)).toBe("true"); + }); + + it("tracks per-node state reactively", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + const seen: boolean[] = []; + const unsubscribe = store.subscribe((state) => + seen.push(state.openByNode.side ?? state.defaultOpen), + ); + try { + store.setOpen("side", false); + store.setOpen("side", true); + } finally { + unsubscribe(); + } + expect(seen).toEqual([true, false, true]); + expect(store.isOpen("side")).toBe(true); + }); + + it("an untracked node falls back to the default", () => { + const store = createCollapseStore({ viewportWidth: 800 }); + expect(store.isOpen("other")).toBe(false); + }); + + it("survives storage failures (quota errors are swallowed)", () => { + const broken: Storage = { + get length() { + return 0; + }, + clear: () => undefined, + getItem: () => null, + key: () => null, + removeItem: () => undefined, + setItem: () => { + throw new Error("quota exceeded"); + }, + }; + const store = createCollapseStore({ storage: broken, viewportWidth: 1024 }); + expect(() => store.toggle("side")).not.toThrow(); + expect(store.isOpen("side")).toBe(false); + }); + + it("works without storage (session-only chrome state)", () => { + const store = createCollapseStore({ viewportWidth: 1024 }); + store.toggle("side"); + expect(store.isOpen("side")).toBe(false); + }); +}); diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts new file mode 100644 index 0000000..28e90b4 --- /dev/null +++ b/src/features/layout/logic/collapse.ts @@ -0,0 +1,95 @@ +/** + * Collapse chrome state — the open/closed state of the tree's collapsible + * split children (today: the sidebar). Renderer chrome, NOT tree data + * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store + * owns the per-node state, persisted device-locally. + * + * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open` + * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px, + * Tailwind `lg`). Today's UI has exactly one collapsible node, and the + * persisted boolean tracks the toggled node's state — identical bytes and + * behavior. A future with several collapsible nodes migrates the format in + * plan 02, not here. + */ + +import { type Readable, writable } from "svelte/store"; + +/** The localStorage key — unchanged from today (no migration). */ +export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open"; +/** Tailwind `lg`: today's boot default is open on wide screens. */ +export const WIDE_BREAKPOINT = 1024; + +export interface CollapseState { + /** Explicit per-node open state (node id → open). Absent = the default. */ + readonly openByNode: Readonly<Record<string, boolean>>; + /** The boot default (persisted boolean, else viewport width >= 1024). */ + readonly defaultOpen: boolean; +} + +export interface CollapseStore extends Readable<CollapseState> { + /** Is the named node open? (explicit state, else the default) */ + isOpen(nodeId: string): boolean; + setOpen(nodeId: string, open: boolean): void; + toggle(nodeId: string): void; +} + +export interface CollapseStoreOptions { + /** Injected storage (the app store's `Storage`; undefined → session-only). */ + readonly storage?: Storage | undefined; + /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */ + readonly viewportWidth?: number | undefined; + /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */ + readonly key?: string | undefined; +} + +/** The boot default: open on wide screens (narrow screens start closed). */ +export function bootDefaultOpen(viewportWidth: number | undefined): boolean { + if (viewportWidth === undefined) return true; + return viewportWidth >= WIDE_BREAKPOINT; +} + +/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */ +export function parsePersistedOpen(raw: string | null): boolean | null { + if (raw === null) return null; + try { + const parsed: unknown = JSON.parse(raw); + return typeof parsed === "boolean" ? parsed : null; + } catch { + return null; + } +} + +export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore { + const key = options?.key ?? SIDEBAR_OPEN_KEY; + const storage = options?.storage; + const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key)); + const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth); + + let openByNode: Record<string, boolean> = {}; + const store = writable<CollapseState>({ openByNode, defaultOpen }); + + function set(nodeId: string, open: boolean): void { + openByNode = { ...openByNode, [nodeId]: open }; + store.set({ openByNode, defaultOpen }); + // Persist the toggled node's state as today's bare boolean. With one + // collapsible node (always, today) this is exactly today's behavior. + if (storage !== undefined) { + try { + storage.setItem(key, JSON.stringify(open)); + } catch { + // Swallow quota / write errors — persistence is best-effort. + } + } + } + + function isOpen(nodeId: string): boolean { + return openByNode[nodeId] ?? defaultOpen; + } + + return { + subscribe: store.subscribe, + isOpen, + setOpen: (nodeId, open) => set(nodeId, open), + toggle: (nodeId) => set(nodeId, !isOpen(nodeId)), + }; +} diff --git a/src/features/layout/logic/marker.test.ts b/src/features/layout/logic/marker.test.ts new file mode 100644 index 0000000..53b91b0 --- /dev/null +++ b/src/features/layout/logic/marker.test.ts @@ -0,0 +1,29 @@ +import type { TileMarker } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { markerOutline, markerStyle } from "./marker"; + +describe("markerOutline — the diagnostic tile-border mapping", () => { + it("main → a 4px solid red outline (the main tile's marker)", () => { + expect(markerOutline("main")).toBe("4px solid red"); + }); + + it("side → a 4px solid green outline (the side tile's marker)", () => { + expect(markerOutline("side")).toBe("4px solid green"); + }); + + it("no marker → no outline (existing trees render exactly as before)", () => { + expect(markerOutline(undefined)).toBeUndefined(); + }); +}); + +describe("markerStyle — the complete inline style (outline + inset offset)", () => { + it("insets the outline so it draws INSIDE the tile's box (never clipped at screen edges)", () => { + expect(markerStyle("main")).toBe("outline: 4px solid red; outline-offset: -4px"); + expect(markerStyle("side")).toBe("outline: 4px solid green; outline-offset: -4px"); + }); + + it("no/unknown marker → NO style at all (unmarked nodes carry zero outline CSS)", () => { + expect(markerStyle(undefined)).toBeUndefined(); + expect(markerStyle("purple" as unknown as TileMarker)).toBeUndefined(); + }); +}); diff --git a/src/features/layout/logic/marker.ts b/src/features/layout/logic/marker.ts new file mode 100644 index 0000000..46259e0 --- /dev/null +++ b/src/features/layout/logic/marker.ts @@ -0,0 +1,46 @@ +/** + * Diagnostic tile-border markers (the contract's additive `TileMarker` + * presentation field) → the inline outline the renderer draws. Pure + * decision logic — the .svelte tile components just apply the returned + * style value. + * + * INLINE STYLES, deliberately: a marker value arrives in TREE DATA at + * runtime, and Tailwind only generates classes it sees in source files — a + * dynamic class from tree data would silently render nothing. An outline + * (not a border) is used so the diagnostic draws WITHOUT changing any + * tile's layout/size (4px solid, clearly visible). + * + * The outline is INSET (`outline-offset: -4px`) so it draws INSIDE the + * tile's box: tiles span their containers edge-to-edge, so the default + * (outside) outline overflows the visible area and is clipped away at the + * screen edges. The views inside the tiles have no opaque full-bleed + * background, so the inset ring stays on top of the tile's content. + * + * The marker is purely presentational: unknown/absent → no outline + * (existing trees render exactly as before), and no other behavior is + * attached to it anywhere in the renderer. + */ + +import type { TileMarker } from "@dispatch/layout-contract"; + +/** The diagnostic outline style for a marker — `undefined` = draw nothing. */ +export function markerOutline(marker: TileMarker | undefined): string | undefined { + if (marker === "main") return "4px solid red"; + if (marker === "side") return "4px solid green"; + return undefined; +} + +/** The inset that keeps the outline INSIDE the tile's box (see the header note). */ +export const MARKER_OUTLINE_OFFSET = "-4px"; + +/** + * The COMPLETE inline style for a marked element — outline + inset offset in + * one declaration — or `undefined` when unmarked/unknown, so unmarked nodes + * carry no outline CSS at all (existing trees render exactly as before). + */ +export function markerStyle(marker: TileMarker | undefined): string | undefined { + const outline = markerOutline(marker); + return outline === undefined + ? undefined + : `outline: ${outline}; outline-offset: ${MARKER_OUTLINE_OFFSET}`; +} diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts new file mode 100644 index 0000000..9eeeda0 --- /dev/null +++ b/src/features/layout/logic/migration.test.ts @@ -0,0 +1,68 @@ +import { DEFAULT_LAYOUT } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { migrationTree, sidePanelViews, storedPanelViews } from "./migration"; +import { overlaySidePanels } from "./seed"; + +const KNOWN = new Set(["tabs", "model", "extensions", "vision", "tasks"]); + +describe("storedPanelViews", () => { + it("parses a stored array of view ids, filtered to known views, order kept", () => { + expect(storedPanelViews(["vision", "junk", "tasks"], KNOWN)).toEqual(["vision", "tasks"]); + }); + + it("returns null for absent/junk/non-array values", () => { + expect(storedPanelViews(null, KNOWN)).toBeNull(); + expect(storedPanelViews(undefined, KNOWN)).toBeNull(); + expect(storedPanelViews("extensions", KNOWN)).toBeNull(); + expect(storedPanelViews({ 0: "tabs" }, KNOWN)).toBeNull(); + }); + + it("returns null when nothing stored is a known view", () => { + expect(storedPanelViews(["deleted-view", "another-gone"], KNOWN)).toBeNull(); + expect(storedPanelViews([], KNOWN)).toBeNull(); + }); +}); + +describe("sidePanelViews", () => { + it("reads the default tree's side panels", () => { + expect(sidePanelViews(DEFAULT_LAYOUT)).toEqual(["tabs", "model"]); + }); + + it("returns null for a tree without a 'side' panels node", () => { + expect(sidePanelViews({ version: 1, root: { kind: "leaf", view: "chat" } })).toBeNull(); + }); +}); + +describe("migrationTree (§2.7 — one-way, best-effort)", () => { + it("imports the stored selection when the fetched tree is the untouched default seed", () => { + const stored = JSON.stringify(["extensions", "tasks"]); + const migrated = migrationTree(JSON.parse(stored), KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])); + // The rest of the tree is untouched — only the side panels changed. + expect(migrated?.root).toEqual( + overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])?.root, + ); + }); + + it("returns null when the key is absent / junk / has no known views (nothing to import)", () => { + expect(migrationTree(null, KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree("junk", KNOWN, DEFAULT_LAYOUT)).toBeNull(); + expect(migrationTree(["gone-view"], KNOWN, DEFAULT_LAYOUT)).toBeNull(); + }); + + it("returns null when the provider's tree is NOT the default seed (custom layout wins)", () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + expect(migrationTree(["extensions"], KNOWN, custom)).toBeNull(); + }); + + it("returns null when the fetched tree has no side panels node at all", () => { + expect( + migrationTree(["extensions"], KNOWN, { version: 1, root: { kind: "leaf", view: "chat" } }), + ).toBeNull(); + }); + + it("a stored selection identical to the default still migrates (a harmless idempotent PUT)", () => { + const migrated = migrationTree(["tabs", "model"], KNOWN, DEFAULT_LAYOUT); + expect(migrated).toEqual(DEFAULT_LAYOUT); + }); +}); diff --git a/src/features/layout/logic/migration.ts b/src/features/layout/logic/migration.ts new file mode 100644 index 0000000..e0ed6fd --- /dev/null +++ b/src/features/layout/logic/migration.ts @@ -0,0 +1,76 @@ +/** + * localStorage → provider migration (plan 02 §2.7, D-L4; one-way, best-effort). + * + * `dispatch.sidebar.views` is retired as a source of truth: on boot WITH a + * provider present, if the key exists AND the fetched tree's `side` panels + * equal DEFAULT_LAYOUT's (the workspace still has the untouched default + * seed), the stored panel selection is imported once via a PUT. When the + * provider's tree differs from the default (e.g. a calendar provider), the + * workspace already has a custom layout — importing the old sidebar override + * would be wrong. Regardless of outcome the key is never read again (the + * bytes stay, but nothing consumes them). + * + * Pure decision logic: the store owns the effects (the localStorage read + + * the PUT); this module decides IF and WHAT. + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutTree, + type PanelsNode, +} from "@dispatch/layout-contract"; +import { overlaySidePanels } from "./seed"; + +/** + * The stored selection's usable view ids: an array of strings filtered to the + * known (registered) views, in stored order. null when nothing usable is + * stored (absent key, junk, or no known ids) — nothing to import. + */ +export function storedPanelViews( + raw: unknown, + knownViews: ReadonlySet<string>, +): readonly string[] | null { + if (!Array.isArray(raw)) return null; + const known = raw.filter((v): v is string => typeof v === "string" && knownViews.has(v)); + return known.length > 0 ? known : null; +} + +/** + * The view ids of a tree's `"side"` panels node (the default tree's swappable + * sidebar stack), or null when the tree has no such node. + */ +export function sidePanelViews(tree: LayoutTree): readonly string[] | null { + const found = findById(tree, "side"); + if (found === null || found.node.kind !== "panels") return null; + return (found.node as PanelsNode).panels.map((p) => p.view); +} + +function samePanels(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((v, i) => v === b[i]); +} + +/** + * The one-way migration decision (§2.7): given the RAW stored value, the + * known view ids, and the tree fetched from the provider, return the tree to + * PUT once — or null when there is nothing to migrate: + * - the stored key holds no usable selection, or + * - the provider's tree is NOT the untouched default seed (its side panels + * differ from DEFAULT_LAYOUT's — the workspace already has a custom layout). + */ +export function migrationTree( + storedRaw: unknown, + knownViews: ReadonlySet<string>, + fetched: LayoutTree, +): LayoutTree | null { + const stored = storedPanelViews(storedRaw, knownViews); + if (stored === null) return null; + const fetchedPanels = sidePanelViews(fetched); + const defaultPanels = sidePanelViews(DEFAULT_LAYOUT); + if (fetchedPanels === null || defaultPanels === null) return null; + if (!samePanels(fetchedPanels, defaultPanels)) return null; + // Overlay the stored selection onto the fetched tree (identical to the + // default here by the guard above — the seed overlay keeps the rest of the + // tree shared, not copied). + return overlaySidePanels(fetched, stored); +} diff --git a/src/features/layout/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts new file mode 100644 index 0000000..b45671a --- /dev/null +++ b/src/features/layout/logic/panel-keys.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys"; + +describe("initialPanelKeys", () => { + it("numbers the panels in order and seeds past them", () => { + expect(initialPanelKeys(["tabs", "model"])).toEqual({ + views: ["tabs", "model"], + keys: [0, 1], + seed: 2, + }); + }); +}); + +describe("reconcilePanelKeys", () => { + const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2 + + it("closing the MIDDLE panel drops the middle key (not the last)", () => { + const next = reconcilePanelKeys(start, ["tabs", "usage"]); + expect(next.keys).toEqual([0, 2]); + expect(next.seed).toBe(3); + }); + + it("adding a panel gives only the new one a fresh key", () => { + const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]); + expect(next.keys).toEqual([0, 1, 2, 3]); + expect(next.seed).toBe(4); + }); + + it("a dropdown swap keeps every key positional", () => { + const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]); + expect(next.keys).toEqual([0, 1, 2]); + }); + + it("handles duplicate views and never reuses a key", () => { + const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2 + const removed = reconcilePanelKeys(dup, ["a", "a"]); + expect(removed.keys).toEqual([0, 2]); + const added = reconcilePanelKeys(removed, ["a", "a", "a"]); + expect(added.keys).toEqual([0, 2, 3]); + expect(new Set(added.keys).size).toBe(3); + }); + + it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => { + const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2 + expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint + expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]); + }); + + it("ignores a stale hint that does not describe the change", () => { + // The tree changed some other way (e.g. a remote edit) — fall back to LCS. + expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]); + expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]); + }); + + it("an unrelated replacement of different length gets all-fresh keys", () => { + const next = reconcilePanelKeys(start, ["x"]); + expect(next.keys).toEqual([3]); + }); +}); + +describe("swappedPanels", () => { + it("reports a surviving panel whose view changed", () => { + const prev = initialPanelKeys(["tabs", "model"]); + const next = reconcilePanelKeys(prev, ["tabs", "usage"]); + expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]); + }); + + it("does not report adds or removes", () => { + const prev = initialPanelKeys(["tabs", "model", "usage"]); + expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0); + expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0); + }); +}); diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts new file mode 100644 index 0000000..76a053f --- /dev/null +++ b/src/features/layout/logic/panel-keys.ts @@ -0,0 +1,118 @@ +/** + * Stable panel keys — the PURE identity scheme behind the panel height + * animation. + * + * Panel leaves carry no required id, so the panels list can't be keyed by the + * tree. Keying by INDEX is wrong for removal: closing the middle of three + * panels would re-use the middle DOM node for the third view and animate the + * LAST panel out. This reconciles a stable numeric key per panel across tree + * updates, from the view ids alone: + * + * - same length → keys stay positional (a dropdown SWAP keeps its panel — + * the panel morphs its height to the new view instead of leaving). + * - length changed → the longest common subsequence of view ids keeps its + * keys; every unmatched new panel gets a fresh key (an ADD), every + * unmatched old key disappears (a REMOVE). + * + * Zero Svelte, zero DOM. + */ + +export interface PanelKeys { + /** View id per panel, in order. */ + readonly views: readonly string[]; + /** Stable key per panel, aligned with `views`. */ + readonly keys: readonly number[]; + /** The next fresh key. */ + readonly seed: number; +} + +/** Keys for a freshly mounted panels list. */ +export function initialPanelKeys(views: readonly string[]): PanelKeys { + return { views: [...views], keys: views.map((_, i) => i), seed: views.length }; +} + +/** + * Reconcile `prev` against the next view list (see the module doc). + * + * `removedIndex` is an optional HINT from a local ✕ click: when the next list + * is exactly `prev` minus that index, that exact panel is the one removed. + * Without it, closing one of two identical adjacent views is ambiguous to the + * subsequence match (it would drop the later one). + */ +export function reconcilePanelKeys( + prev: PanelKeys, + nextViews: readonly string[], + removedIndex?: number, +): PanelKeys { + if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) { + return { + views: [...nextViews], + keys: prev.keys.filter((_, i) => i !== removedIndex), + seed: prev.seed, + }; + } + if (nextViews.length === prev.views.length) { + return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed }; + } + const matched = lcsMatch(prev.views, nextViews); + let seed = prev.seed; + const keys = nextViews.map((_, j) => { + const i = matched.get(j); + if (i !== undefined) return prev.keys[i] ?? seed++; + return seed++; + }); + return { views: [...nextViews], keys, seed }; +} + +/** + * The views whose panel SURVIVED but now mounts a different view (a swap): + * key → { from, to }. Adds/removes are not swaps. + */ +export function swappedPanels( + prev: PanelKeys, + next: PanelKeys, +): Map<number, { from: string; to: string }> { + const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""])); + const out = new Map<number, { from: string; to: string }>(); + next.keys.forEach((k, i) => { + const from = before.get(k); + const to = next.views[i] ?? ""; + if (from !== undefined && from !== to) out.set(k, { from, to }); + }); + return out; +} + +function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean { + if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false; + return next.every((v, j) => v === prev[j < index ? j : j + 1]); +} + +/** LCS alignment: next index → prev index for every matched element. */ +function lcsMatch(a: readonly string[], b: readonly string[]): Map<number, number> { + const n = a.length; + const m = b.length; + // dp[i][j] = LCS length of a[i..] and b[j..] + const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + const row = dp[i] as number[]; + const below = dp[i + 1] as number[]; + row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0); + } + } + const out = new Map<number, number>(); + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.set(j, i); + i++; + j++; + } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) { + i++; + } else { + j++; + } + } + return out; +} diff --git a/src/features/layout/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts new file mode 100644 index 0000000..b4c3099 --- /dev/null +++ b/src/features/layout/logic/panels-edit.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit"; + +describe("panel edit → op mapping", () => { + it("maps a dropdown swap to a replaceView on the panel leaf's path", () => { + expect(swapPanelOp([1], 2, "model")).toEqual({ + type: "replaceView", + target: [1, 2], + view: "model", + }); + }); + + it("maps the + button to an append insertPanel", () => { + expect(addPanelOp(["titlebar"], "tasks")).toEqual({ + type: "insertPanel", + panels: ["titlebar"], + view: "tasks", + }); + }); + + it("maps the ✕ button to a removeNode on the panel leaf's path", () => { + expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] }); + }); + + it("the + button pre-selects the first eligible view (or null when none)", () => { + expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs"); + expect(firstPanelView([])).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts new file mode 100644 index 0000000..8a56859 --- /dev/null +++ b/src/features/layout/logic/panels-edit.ts @@ -0,0 +1,33 @@ +/** + * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels' + * dropdown swap / add / remove buttons. The component builds the ops; the + * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero + * DOM. + */ + +import type { LayoutOp, Path } from "@dispatch/layout-contract"; + +/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */ +export function swapPanelOp(path: Path, index: number, view: string): LayoutOp { + return { type: "replaceView", target: [...path, index], view }; +} + +/** Add a panel mounting `view` (appended to the panels node at `path`). */ +export function addPanelOp(path: Path, view: string): LayoutOp { + return { type: "insertPanel", panels: path, view }; +} + +/** Remove the panel at `index` from the panels node at `path`. */ +export function removePanelOp(path: Path, index: number): LayoutOp { + return { type: "removeNode", target: [...path, index] }; +} + +/** + * The view a new panel starts with. The tree schema has no "empty panel" + * state (a leaf's `view` is required — today's "Select a view" placeholder has + * no tree representation), so the + button pre-selects the first panel-eligible + * view. Returns null when nothing is eligible (the + button hides). + */ +export function firstPanelView(views: readonly { readonly id: string }[]): string | null { + return views[0]?.id ?? null; +} diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts new file mode 100644 index 0000000..e5e947c --- /dev/null +++ b/src/features/layout/logic/registry.test.ts @@ -0,0 +1,148 @@ +import type { Component } from "svelte"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getView, + listPanelViews, + listViews, + registerPluginView, + registerView, + unregisterView, + unregisterViewsByPlugin, + type ViewRegistration, + viewRegistry, +} from "./registry"; + +const fakeComponent: Component = () => { + throw new Error("fixture component should never be constructed"); +}; + +function registration(overrides?: Partial<ViewRegistration>): ViewRegistration { + return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides }; +} + +const registeredIds = (): string[] => listViews().map((v) => v.id); + +afterEach(() => { + for (const id of registeredIds()) unregisterView(id); +}); + +describe("view registry", () => { + it("registers a view and exposes it via getView/listViews", () => { + registerView(registration()); + expect(getView("test-view")?.label).toBe("Test View"); + expect(registeredIds()).toContain("test-view"); + }); + + it("throws loudly on a duplicate id", () => { + registerView(registration()); + expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/); + }); + + it("normalizes panelEligible to true by default and respects false", () => { + registerView(registration({ id: "a" })); + registerView(registration({ id: "b", panelEligible: false })); + expect(getView("a")?.panelEligible).toBe(true); + expect(getView("b")?.panelEligible).toBe(false); + expect(listPanelViews().map((v) => v.id)).toContain("a"); + expect(listPanelViews().map((v) => v.id)).not.toContain("b"); + }); + + it("keeps pluginId when given", () => { + registerView(registration({ id: "plugin-view", pluginId: "my-plugin" })); + expect(getView("plugin-view")?.pluginId).toBe("my-plugin"); + }); + + it("unregisterView removes the view (idempotent for unknown ids)", () => { + registerView(registration({ id: "gone" })); + unregisterView("gone"); + expect(getView("gone")).toBeUndefined(); + expect(() => unregisterView("never-registered")).not.toThrow(); + }); + + it("is reactive: subscribers see registrations and unregistrations", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id))); + try { + registerView(registration({ id: "reactive-a" })); + registerView(registration({ id: "reactive-b" })); + unregisterView("reactive-a"); + } finally { + unsubscribe(); + } + expect(seen[0]).toEqual([]); + expect(seen.at(-1)).toEqual(["reactive-b"]); + }); + + it("preserves registration order in listViews", () => { + registerView(registration({ id: "z-first" })); + registerView(registration({ id: "a-second" })); + const ids = registeredIds(); + expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second")); + }); +}); + +// ── Plugin-view registration (plan 03) — forced attribution + the sweep ───── + +describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => { + it("forces the importing plugin's pluginId attribution", () => { + const dispose = registerPluginView( + { id: "abi-view", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + expect(getView("abi-view")?.pluginId).toBe("my-plugin"); + dispose(); + expect(getView("abi-view")).toBeUndefined(); + }); + + it("returns a working per-view disposer (the plugin deactivate contract)", () => { + const dispose = registerPluginView( + { id: "abi-view-2", label: "ABI View", component: fakeComponent }, + "my-plugin", + ); + dispose(); + dispose(); // idempotent + expect(getView("abi-view-2")).toBeUndefined(); + }); + + it("the sweep removes exactly that plugin's views (registry attribution)", () => { + registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1"); + registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2"); + registerView(registration({ id: "builtin" })); // no pluginId — never swept + + unregisterViewsByPlugin("plugin-1"); + expect(getView("p1-a")).toBeUndefined(); + expect(getView("p1-b")).toBeUndefined(); + expect(getView("p2-a")).not.toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + + unregisterViewsByPlugin("plugin-2"); + expect(getView("p2-a")).toBeUndefined(); + expect(getView("builtin")).not.toBeUndefined(); + }); + + it("the sweep is idempotent and a no-op for an unknown plugin", () => { + unregisterViewsByPlugin("nobody"); + registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3"); + unregisterViewsByPlugin("plugin-3"); + expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow(); + }); + + it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => { + registerView(registration({ id: "taken" })); + expect(() => + registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"), + ).toThrow(/duplicate view id/); + }); + + it("sweep removals are visible to registry subscribers (reactive)", () => { + const seen: string[][] = []; + const unsubscribe = viewRegistry.subscribe((views) => { + seen.push(views.map((v) => v.id)); + }); + registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p"); + unregisterViewsByPlugin("p"); + unsubscribe(); + expect(seen.at(-1)).not.toContain("reactive"); + }); +}); diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts new file mode 100644 index 0000000..bcd49e0 --- /dev/null +++ b/src/features/layout/logic/registry.ts @@ -0,0 +1,139 @@ +/** + * The view registry — the map from view id → component that tile leaves mount. + * + * Every view the renderer can place (the sidebar views + chat / composer / + * message-queue today; plugin views from plan 03 later) registers here. The + * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render + * open dropdowns, and the tree renderer resolves leaves through it per render. + * + * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02) + * can name views a given client lacks; the renderer shows a fallback tile. + * + * This module holds a small amount of reactive state by design (the plan places + * the registry in `logic/` with Svelte-store reactivity). It is NOT decision + * logic — no DOM, no effects. + */ + +import type { Component } from "svelte"; +import { type Readable, writable } from "svelte/store"; + +/** + * The props type of a registered view component. The composition root hands + * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`); + * fixture/generic components may ignore it. + * + * The registry is a HETEROGENEOUS component bag (the plan types it `Component` + * outright), so its props are opaque `Record<string, any>` — the wrappers + * re-narrow to their concrete props at the composition-root seam. + */ +export type ViewComponent = + // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag + Component<Record<string, any>>; + +export interface ViewRegistration { + /** Registry id — the same id a leaf's `view` field names. */ + readonly id: string; + /** Human label shown in panel dropdowns. */ + readonly label: string; + /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */ + readonly component: ViewComponent; + /** Whether the view may appear in panel dropdowns (default true). */ + readonly panelEligible?: boolean; + /** The plugin that contributed the view (plan 03 requires it for those). */ + readonly pluginId?: string; +} + +/** A registered view with `panelEligible` normalized (default true). */ +export interface RegisteredView { + readonly id: string; + readonly label: string; + readonly component: ViewComponent; + readonly panelEligible: boolean; + readonly pluginId: string | undefined; +} + +const byId = new Map<string, RegisteredView>(); +const store = writable<readonly RegisteredView[]>([]); + +function publish(): void { + store.set([...byId.values()]); +} + +/** + * Register a view. A duplicate id throws LOUDLY at dev time — two components + * claiming one id is a composition-root bug, never a runtime fallback case. + */ +export function registerView(registration: ViewRegistration): void { + if (byId.has(registration.id)) { + throw new Error(`registerView: duplicate view id "${registration.id}"`); + } + byId.set(registration.id, { + id: registration.id, + label: registration.label, + component: registration.component, + panelEligible: registration.panelEligible ?? true, + pluginId: registration.pluginId, + }); + publish(); +} + +/** Remove a view. Idempotent (removing an unknown id is a no-op). */ +export function unregisterView(id: string): void { + if (byId.delete(id)) publish(); +} + +/** + * Register a PLUGIN view (plan 03) — the ABI-facing registration: the + * `pluginId` attribution is FORCED to the importing plugin (plugins cannot + * attribute views to another plugin or to no plugin), which is what makes + * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view + * disposer (the plugin's deactivate contract). A duplicate id throws + * LOUDLY — the driver catches it and marks the plugin's FE half failed. + */ +export function registerPluginView( + registration: Omit<ViewRegistration, "pluginId">, + pluginId: string, +): () => void { + registerView({ ...registration, pluginId }); + return () => unregisterView(registration.id); +} + +/** + * Remove every view attributed to one plugin (the plan-03 unload sweep). + * Idempotent; the reactive registry re-renders open dropdowns, and tiles + * holding removed views fall back to `FallbackTile` (never a crash). + */ +export function unregisterViewsByPlugin(pluginId: string): void { + let changed = false; + for (const [id, view] of byId) { + if (view.pluginId === pluginId) { + byId.delete(id); + changed = true; + } + } + if (changed) publish(); +} + +/** Look a view up by id (undefined → the renderer shows its fallback tile). */ +export function getView(id: string): RegisteredView | undefined { + return byId.get(id); +} + +/** All registered views, in registration order (snapshot). */ +export function listViews(): readonly RegisteredView[] { + return [...byId.values()]; +} + +/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */ +export function listPanelViews(): readonly RegisteredView[] { + return listViews().filter((v) => v.panelEligible); +} + +/** + * Reactive registry contents (registration order). Subscribe in components — + * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03) + * re-render open dropdowns. + */ +export const viewRegistry: Readable<readonly RegisteredView[]> = { + subscribe: store.subscribe, +}; diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts new file mode 100644 index 0000000..dfe166a --- /dev/null +++ b/src/features/layout/logic/seed.test.ts @@ -0,0 +1,99 @@ +import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract"; +import { describe, expect, it } from "vitest"; +import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed"; +import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query"; + +function sideViews(tree: LayoutTree): string[] { + const root = tree.root as SplitNode; + const side = root.children[1]?.node; + return side?.kind === "panels" ? side.panels.map((p) => p.view) : []; +} + +describe("seedLayout", () => { + it("uses the default panels when nothing is stored", () => { + const tree = seedLayout(null, ["tabs", "model", "settings"]); + expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS); + }); + + it("overlays the stored selection, dropping unknown/removed kinds", () => { + const tree = seedLayout( + ["extensions", "ghost-view", "model"], + ["tabs", "model", "extensions", "settings"], + ); + expect(sideViews(tree)).toEqual(["extensions", "model"]); + }); + + it("falls back to the defaults when the stored selection is all-unknown or empty", () => { + expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("falls back to the defaults when the stored value is not a string array", () => { + expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]); + expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]); + }); + + it("preserves the default tree's ids and the titlebar-unset invariant", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + expect((tree.root as SplitNode).id).toBe("root"); + expect(tree.titlebar).toBeUndefined(); + }); +}); + +describe("overlaySidePanels", () => { + it("replaces only the side panels of the given base tree", () => { + const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]); + expect(sideViews(tree)).toEqual(["settings", "tasks"]); + // The rest of the tree is untouched. + expect((tree.root as SplitNode).children[0]?.node).toEqual( + (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node, + ); + }); +}); + +describe("tree queries", () => { + it("firstPanelsNode finds the side panels with its path", () => { + const tree = seedLayout(["model"], ["tabs", "model"]); + const found = firstPanelsNode(tree); + expect(found?.path).toEqual([1]); + expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]); + }); + + it("firstCollapsibleNodeId returns the default tree's side node id", () => { + expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side"); + }); + + it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [ + { size: 1, node: { kind: "leaf", view: "chat" } }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] }, + }, + ], + }, + }; + // The collapsible child sits at root path index 1 → key "1". + expect(firstCollapsibleNodeId(tree)).toBe("1"); + }); + + it("firstCollapsibleNodeId returns null when nothing is collapsible", () => { + const tree: LayoutTree = { + version: 1, + root: { + kind: "split", + dir: "row", + children: [{ size: 1, node: { kind: "leaf", view: "chat" } }], + }, + }; + expect(firstCollapsibleNodeId(tree)).toBeNull(); + }); +}); diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts new file mode 100644 index 0000000..7cd0ba3 --- /dev/null +++ b/src/features/layout/logic/seed.ts @@ -0,0 +1,78 @@ +/** + * Boot-time tree seeding: the default layout with today's sidebar selection + * overlaid from localStorage. Byte-compatible with today's boot behavior + * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree + * schema has no "unknown view" panel state, so a stale id is skipped exactly + * like a view that no longer exists). + */ + +import { + DEFAULT_LAYOUT, + findById, + type LayoutNode, + type LayoutTree, + type LeafNode, + type PanelsNode, + type Path, +} from "@dispatch/layout-contract"; + +/** + * Today's default sidebar selection (the moved-from-top tab bar + the model + * controls) — the fallback when nothing (or nothing valid) is stored. + */ +export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"]; + +/** + * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed + * `dispatch.sidebar.views` value, or null when absent) overlaid onto the + * default tree's panels node. + * + * - null / non-array / array without any known view → the defaults. + * - Known views are kept in the stored order; unknown ids are dropped. + */ +export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree { + const known = new Set(panelViewIds); + const stored = Array.isArray(storedViews) + ? storedViews.filter((v): v is string => typeof v === "string") + : []; + const panels = stored.filter((v) => known.has(v)); + const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v)); + return overlaySidePanels(DEFAULT_LAYOUT, selected); +} + +/** + * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting + * `views` (in order). Targets the panels node by id (falls back to no-op when + * the base has none); the rest of the tree is shared, not copied. + */ +export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree { + const found = findById(base, "side"); + if (found === null || found.node.kind !== "panels") return base; + const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view })); + const next: PanelsNode = { ...found.node, panels }; + return { version: 1, root: replaceChild(base.root, found.path, next) }; +} + +/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */ +function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode { + if (path.length === 0) return replacement; + const [head, ...rest] = path; + if (typeof head !== "number") return node; + if (node.kind === "split") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + if (node.kind === "stack") { + return { + ...node, + children: node.children.map((child, i) => + i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child, + ), + }; + } + return node; +} diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts new file mode 100644 index 0000000..aa8dfe9 --- /dev/null +++ b/src/features/layout/logic/tree-query.ts @@ -0,0 +1,60 @@ +/** + * Tree queries the chrome needs — pure walks over the layout tree. + */ + +import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract"; + +/** The first panels node in the tree (the panel-selection owner), if any. */ +export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null { + const seen: { node: PanelsNode; path: Path }[] = []; + const visit = (node: LayoutNode, path: Path): void => { + if (seen.length > 0) return; + if (node.kind === "panels") { + seen.push({ node, path }); + return; + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) visit(child, [...path, i]); + }; + visit(tree.root, []); + return seen[0] ?? null; +} + +/** + * The node id of the tree's FIRST collapsible split child — the one the chrome + * top bar's toggle reads/writes (`findById("side")` on the default tree; + * generically the first collapsible child, root-first then titlebar). Falls + * back to a path-derived key when the child's node has no id. Null → the tree + * has nothing collapsible (the toggle button hides). + */ +export function firstCollapsibleNodeId(tree: LayoutTree): string | null { + const visit = (node: LayoutNode, path: Path): string | null => { + if (node.kind === "split") { + for (const [i, child] of node.children.entries()) { + if (!child.collapsible) continue; + if (child.node.id !== undefined) return child.node.id; + return [...path, i].map((s) => String(s)).join(":"); + } + } + const kids: readonly LayoutNode[] = + node.kind === "split" + ? node.children.map((c) => c.node) + : node.kind === "stack" + ? node.children.map((c) => c.node) + : []; + for (const [i, child] of kids.entries()) { + const found = visit(child, [...path, i]); + if (found !== null) return found; + } + return null; + }; + const inRoot = visit(tree.root, []); + if (inRoot !== null) return inRoot; + if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]); + return null; +} diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts new file mode 100644 index 0000000..29e2fa1 --- /dev/null +++ b/src/features/layout/store.svelte.ts @@ -0,0 +1,316 @@ +import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { migrationTree } from "./logic/migration"; + +/** + * Layout store (plan 02 §2.6) — a thin reactive wrapper over the injected + * `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's + * tree and the provider lifecycle: + * + * - **Provider probe triggers** — boot, every workspace switch, every + * `extensions-changed`, and every change to the ACTIVE workspace's + * per-workspace plugin state (a layout plugin enabled/disabled in the + * workspace plugins modal starts/stops serving; the probe picks it up + * without a reload). + * - **No-provider mode** — a 404 (or network failure) keeps the tree on its + * local default; edits still work but are EPHEMERAL (in-memory only — + * D-L4 retired the localStorage write path); logged once, no error UI. + * - **WS push** — `onServerTree(workspaceId, tree)` applies a `layout` + * broadcast, ignoring non-active workspaces (the server fans out to all + * clients; the client filters). + * - **User edits persist through the provider** — `onTreeChange` applies the + * edit locally immediately, then PUTs the WHOLE tree (decision 7 — no + * patch protocol) lightly debounced; on a failed PUT the tree rolls back + * to the last server-known tree with a quiet console warning. + * - **Collapse state stays local** (decision 3) — `dispatch.sidebar.open` + * is the collapse store's concern, untouched here. + * - **One-way localStorage migration** (§2.7) — on the FIRST successful + * provider read of the boot session, if `dispatch.sidebar.views` exists + * AND the fetched tree is the untouched default seed, the stored panel + * selection is imported with one PUT; the key is then never read again. + * + * No ambient state: one instance per composition root, passed explicitly; + * no module-level mutables. + */ + +/** Whether an active provider is serving the active workspace. */ +export type LayoutProviderState = "unknown" | "present" | "absent"; + +export interface LayoutStore { + /** The rendered tree for the ACTIVE workspace (starts at the local default). */ + readonly tree: LayoutTree; + /** The active provider's state (absent = the no-provider degrade path). */ + readonly providerState: LayoutProviderState; + /** The workspace the tree currently belongs to. */ + readonly activeWorkspaceId: string; + /** + * Probe the provider for `workspaceId`'s tree. `boot: true` (the composition + * root's boot call) also arms the one-way localStorage migration (§2.7), + * which consumes itself on the first successful provider read regardless of + * outcome. A probe is the missed-push catch-up (§2.5). + */ + probe(workspaceId: string, opts?: { readonly boot?: boolean }): Promise<void>; + /** Workspace switch: reset to the local default for the new workspace + probe. */ + onWorkspaceChanged(workspaceId: string): void; + /** Server `layout` broadcast (§2.5). Non-active workspaces are ignored. */ + onServerTree(workspaceId: string, tree: LayoutTree): void; + /** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */ + onTreeChange(next: LayoutTree): void; + /** + * Reset the workspace's layout to the default seed (plan 04 §4.4 — the + * plugins modal's "Reset layout to default" affordance: + * `DELETE /workspaces/:id/layout` → re-seed → broadcast). Resolves to + * `null` on success (the fresh seed is applied locally + pushed via WS to + * every client) or to the FAILURE REASON — the no-provider 404 resolves + * to the user-facing "no layout plugin enabled" (the button's gate renders + * the same reason before this runs). + */ + resetToDefault(workspaceId: string): Promise<string | null>; + /** Multi-client coherence (§11): re-probe on `extensions-changed`. */ + onExtensionsChanged(): void; + /** + * The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace + * plugins modal toggled a layout plugin's enablement — the per-workspace + * activation rule): re-probe so the newly-enabled provider's tree (or the + * no-provider degrade) lands without a reload. A NON-active workspace's + * toggle is ignored — its tree is fetched on its next probe (workspace + * switch); there is nothing to re-render here. + */ + onWorkspacePluginsChanged(workspaceId: string): void; +} + +export interface LayoutStoreOptions { + readonly http: LayoutHttp; + /** The boot workspace (its URL slug); "default" if absent. */ + readonly workspaceId?: string; + /** + * Injected localStorage — read ONLY by the one-way §2.7 migration (the + * write path is retired; collapse state has its own store). Undefined → + * migration never applies (tests / storageless contexts). + */ + readonly storage?: Storage | undefined; + /** The known (registered) view ids, for the migration's unknown-id filter. */ + readonly knownViews?: ReadonlySet<string> | readonly string[]; + /** + * The localStorage key the §2.7 migration reads (default + * `dispatch.sidebar.views`; tests override). + */ + readonly sidebarViewsKey?: string; + /** Edit-PUT debounce window in ms (default 300; tests shrink it). */ + readonly debounceMs?: number; + /** The quiet-failure hook (default `console.warn` — never an error UI). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_SIDEBAR_VIEWS_KEY = "dispatch.sidebar.views"; +const DEFAULT_DEBOUNCE_MS = 300; + +function toKnownSet(known: LayoutStoreOptions["knownViews"]): ReadonlySet<string> { + if (known === undefined) return new Set(); + return known instanceof Set ? known : new Set(known); +} + +export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore { + const http = opts.http; + const storage = opts.storage; + const sidebarViewsKey = opts.sidebarViewsKey ?? DEFAULT_SIDEBAR_VIEWS_KEY; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const knownViews = toKnownSet(opts.knownViews); + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + let tree = $state<LayoutTree>(DEFAULT_LAYOUT); + let providerState = $state<LayoutProviderState>("unknown"); + let activeWorkspaceId = $state<string>(opts.workspaceId ?? "default"); + + /** The last tree known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerTree: LayoutTree | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the no-provider log (reset when a provider appears). */ + let loggedAbsent = false; + /** + * The §2.7 migration is armed by the BOOT probe and consumed by the first + * successful provider read — regardless of outcome (read-once semantics). + */ + let migrationArmed = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `layout: no layout provider active (${reason}) — rendering the built-in default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerTree(next: LayoutTree): void { + lastServerTree = next; + tree = next; + } + + /** The §2.7 one-way migration: at most one read + one PUT per session. */ + async function maybeMigrate(): Promise<void> { + if (!migrationArmed || storage === undefined) return; + migrationArmed = false; // consumed regardless of outcome (stop reading the key) + let raw: string | null = null; + try { + raw = storage.getItem(sidebarViewsKey); + } catch { + return; // storage unavailable — best-effort, done + } + if (raw === null) return; // nothing stored — nothing to import + let parsed: unknown = null; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return; // junk — best-effort, done + } + const migrated = migrationTree(parsed, knownViews, tree); + if (migrated === null) return; // custom layout already / nothing usable + const result = await http.putTree(activeWorkspaceId, migrated); + if (result.ok) { + // The server's canonical copy (also echoed back via the WS broadcast). + applyServerTree(result.tree); + } else if (!result.notFound) { + warn(`layout: localStorage migration PUT failed — ${result.error}`); + } + } + + async function runProbe(workspaceId: string, boot: boolean): Promise<void> { + if (boot) migrationArmed = true; + const result: LayoutHttpResult = await http.getTree(workspaceId); + // A switch may have happened while the fetch was in flight — only the + // CURRENT workspace's result lands (the refreshProviderStatus guard). + if (workspaceId !== activeWorkspaceId) return; + if (result.ok) { + notePresent(); + applyServerTree(result.tree); + await maybeMigrate(); + } else if (result.notFound) { + noteAbsent(result.error); + } else { + // A real failure (5xx, malformed) degrades like absent — quietly. + noteAbsent(result.error); + } + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(workspaceId: string, pending: LayoutTree): Promise<void> { + putTimer = null; + // Read the CURRENT tree at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const target = tree === pending ? pending : tree; + const result = await http.putTree(workspaceId, target); + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerTree = result.tree; + return; + } + if (result.notFound) { + // The provider vanished: keep the local edit (ephemeral), no rollback — + // there is no server tree to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT provider: roll back to the last server + // tree, quiet console warning (never an error UI). + warn( + `layout: persisting the tree failed (${result.error}) — rolled back to the last server tree`, + ); + if (lastServerTree !== null) tree = lastServerTree; + } + + return { + get tree() { + return tree; + }, + get providerState() { + return providerState; + }, + get activeWorkspaceId() { + return activeWorkspaceId; + }, + + async probe(workspaceId, probeOpts) { + await runProbe(workspaceId, probeOpts?.boot === true); + }, + + onWorkspaceChanged(workspaceId) { + if (workspaceId === activeWorkspaceId) return; + activeWorkspaceId = workspaceId; + // The tree belongs to the OLD workspace now: reset to the local default + // while the probe resolves (never flash the previous workspace's tree), + // and drop any queued edit — it was for the old workspace. + clearPutTimer(); + lastServerTree = null; + tree = DEFAULT_LAYOUT; + providerState = "unknown"; + void runProbe(workspaceId, false); + }, + + onServerTree(workspaceId, next) { + if (workspaceId !== activeWorkspaceId) return; // clients filter (§2.5) + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerTree(next); + }, + + onTreeChange(next) { + tree = next; // the edit applies locally IMMEDIATELY (the UI never waits) + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut(activeWorkspaceId, next).catch(() => { + // Network-grade failure: roll back to the last server tree, quietly. + warn("layout: persisting the tree failed — rolled back to the last server tree"); + if (lastServerTree !== null) tree = lastServerTree; + }); + }, debounceMs); + }, + + async resetToDefault(workspaceId): Promise<string | null> { + const result = await http.deleteTree(workspaceId); + if (result.ok) { + // The server re-seeded and broadcast (every client resets); this + // client applies the fresh seed immediately too (idempotent with the + // WS echo). Only the ACTIVE workspace's tree is ours to update. + notePresent(); + if (workspaceId === activeWorkspaceId) applyServerTree(result.tree); + return null; + } + // The no-provider 404: the user-facing reason the plan specifies. + if (result.notFound) return "no layout plugin enabled"; + return result.error; + }, + + onExtensionsChanged() { + void runProbe(activeWorkspaceId, false).catch(() => { + // runProbe never throws for HTTP failures; this guard is for the + // migration path. Staying silent keeps the degrade quiet. + }); + }, + + onWorkspacePluginsChanged(workspaceId) { + // The per-workspace activation rule: only the ACTIVE workspace's tree + // can change what is rendered here — another workspace's toggle is + // picked up by its own probe (boot/workspace-switch/§2.6 triggers). + if (workspaceId !== activeWorkspaceId) return; + void runProbe(activeWorkspaceId, false).catch(() => { + // Same quiet-degrade guard as onExtensionsChanged. + }); + }, + }; +} diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts new file mode 100644 index 0000000..331af95 --- /dev/null +++ b/src/features/layout/store.test.ts @@ -0,0 +1,529 @@ +import { + type ApplyOpsResult, + DEFAULT_LAYOUT, + type LayoutOp, + type LayoutTree, +} from "@dispatch/layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { LayoutHttp, LayoutHttpResult } from "./adapter/http"; +import { overlaySidePanels } from "./logic/seed"; +import { createLayoutStore } from "./store.svelte"; + +/** + * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the + * injected `LayoutHttp` is the store's outermost boundary; nothing internal + * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit + * PUT debounce deterministically. + */ + +/** A recording fake of the LayoutHttp edge, scripted per test. */ +interface HttpFake extends LayoutHttp { + getCalls: string[]; + putCalls: Array<{ workspaceId: string; tree: LayoutTree }>; + deleteCalls: string[]; + getResult: (workspaceId: string) => LayoutHttpResult; + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult; + deleteResult: (workspaceId: string) => LayoutHttpResult; + opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult; +} + +function fakeHttp( + getResult: (workspaceId: string) => LayoutHttpResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }), + putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({ + ok: true, + tree, + }), +): HttpFake { + const getCalls: string[] = []; + const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = []; + const deleteCalls: string[] = []; + const fake: HttpFake = { + getCalls, + putCalls, + deleteCalls, + getResult, + putResult, + // Re-scriptable outcome (default: the fresh seed a reset returns). + deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }), + // Re-scriptable outcome (default: the typed no-provider failure the ABI + // documents; tests re-assign mid-test like putResult). + opsResult: () => ({ ok: false, error: "no layout provider active" }), + // Delegate to the CURRENT properties so a test can re-script outcomes + // mid-test (http.putResult = …). + async getTree(workspaceId) { + getCalls.push(workspaceId); + return fake.getResult(workspaceId); + }, + async putTree(workspaceId, tree) { + putCalls.push({ workspaceId, tree }); + return fake.putResult(workspaceId, tree); + }, + async deleteTree(workspaceId) { + deleteCalls.push(workspaceId); + return fake.deleteResult(workspaceId); + }, + async postOps(workspaceId, ops) { + return fake.opsResult(workspaceId, ops); + }, + }; + return fake; +} + +function storageWith(views: string[] | null): Storage { + const map = new Map<string, string>(); + if (views !== null) map.set("test.sidebar.views", JSON.stringify(views)); + return { + get length() { + return map.size; + }, + clear() { + map.clear(); + }, + getItem: (k) => map.get(k) ?? null, + key: () => null, + removeItem: (k) => map.delete(k), + setItem: (k, v) => map.set(k, v), + }; +} + +const KNOWN = ["tabs", "model", "extensions", "vision"]; + +function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) { + return createLayoutStore({ + http, + workspaceId: "ws-a", + knownViews: KNOWN, + sidebarViewsKey: "test.sidebar.views", + debounceMs: 100, + warn: (message) => warnings.push(message), + ...extra, + }); +} + +let warnings: string[] = []; + +beforeEach(() => { + vi.useFakeTimers(); + warnings = []; +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("layout store — no-provider fallback (§2.6)", () => { + it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => { + const http = fakeHttp(); + const store = makeStore(http); + + await store.probe("ws-a", { boot: true }); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("no layout provider"); + + // A re-probe (extensions-changed while still absent) does not re-warn. + await store.probe("ws-a"); + expect(warnings).toHaveLength(1); + }); + + it("a network failure degrades like a 404 (quiet, no error UI)", async () => { + const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" })); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); + }); + + it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => { + const http = fakeHttp(); + const store = makeStore(http); + await store.probe("ws-a"); // absent now + expect(store.providerState).toBe("absent"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + expect(store.tree).toEqual(edited); + + vi.advanceTimersByTime(500); + expect(http.putCalls).toEqual([]); // ephemeral: no write attempted + // Quiet = the single absence log (from the probe), nothing more. + expect(warnings).toHaveLength(1); + }); + + it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => { + const http = fakeHttp(); + // The PUT is a 404 (the probe will confirm: no provider). + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const store = makeStore(http); + + void store.probe("ws-a"); // in flight (microtask pending) + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); // lands BEFORE the probe resolves + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted + expect(store.providerState).toBe("absent"); + expect(store.tree).toEqual(edited); // no rollback — there is no server tree + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — provider present", () => { + it("a 200 probe replaces the tree and marks the provider present", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + expect(warnings).toEqual([]); + }); +}); + +describe("layout store — WS push (§2.5)", () => { + it("a layout push for the ACTIVE workspace replaces the tree", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-a", pushed); + expect(store.tree).toEqual(pushed); + }); + + it("a push for a NON-active workspace is ignored", async () => { + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + store.onServerTree("ws-b", other); + expect(store.tree).toEqual(served); + }); + + it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => { + const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + let served = servedA; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + served = servedB; + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(0); // the probe's promise + expect(store.tree).toEqual(servedB); + + // A late push for the OLD workspace must not land. + const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]); + store.onServerTree("ws-a", stale); + expect(store.tree).toEqual(servedB); + }); +}); + +describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => { + it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp(() => ({ ok: true, tree: served })); + const store = makeStore(http); + await store.probe("ws-a"); + + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + // Not yet — debounced. + expect(http.putCalls).toEqual([]); + await vi.advanceTimersByTimeAsync(150); + expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]); + expect(warnings).toEqual([]); + }); + + it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(50); + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + await vi.advanceTimersByTimeAsync(200); + + expect(http.putCalls).toHaveLength(1); + expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + }); + + it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => { + const served = DEFAULT_LAYOUT; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("present"); + + const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(bad); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(served); // rolled back + expect(store.providerState).toBe("present"); // the provider is still there + expect(warnings.some((w) => w.includes("rolled back"))).toBe(true); + }); + + it("a 5xx failure rolls back too (present provider, transient error)", async () => { + const served = DEFAULT_LAYOUT; + let failPut = false; + const http = fakeHttp( + () => ({ ok: true, tree: served }), + () => + failPut + ? { ok: false, notFound: false, error: "storage down" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + failPut = true; + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + await vi.advanceTimersByTimeAsync(200); + expect(store.tree).toEqual(served); + }); + + it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + + // The provider unloads before the edit's PUT fires. + http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" }); + const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + store.onTreeChange(edited); + await vi.advanceTimersByTimeAsync(200); + + expect(store.tree).toEqual(edited); // the edit STAYS (in-memory) + expect(store.providerState).toBe("absent"); + expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more + }); +}); + +describe("layout store — probe triggers (§2.6)", () => { + it("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled in THIS workspace + store.onWorkspacePluginsChanged("ws-a"); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here + store.onWorkspacePluginsChanged("ws-other"); + await vi.advanceTimersByTimeAsync(0); + expect(http.getCalls).toEqual(["ws-a"]); // no second probe + expect(store.providerState).toBe("absent"); + }); + + it("extensions-changed re-probes the active workspace", async () => { + let absent = true; + const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: served }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + expect(store.providerState).toBe("absent"); + + absent = false; // a layout plugin got enabled on the server + store.onExtensionsChanged(); + await vi.advanceTimersByTimeAsync(0); + expect(store.providerState).toBe("present"); + expect(store.tree).toEqual(served); + }); + + it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => { + const http = fakeHttp((ws) => + ws === "ws-b" + ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http); + await store.probe("ws-a"); + + // Queue an edit for ws-a, then switch before the debounce fires. + store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"])); + store.onWorkspaceChanged("ws-b"); + await vi.advanceTimersByTimeAsync(300); + + expect(store.activeWorkspaceId).toBe("ws-b"); + expect(http.getCalls).toEqual(["ws-a", "ws-b"]); + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"])); + // The queued edit never PUTs — it belonged to the old workspace. + expect(http.putCalls).toEqual([]); + }); + + it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http); + await store.probe("ws-a"); + store.onWorkspaceChanged("ws-a"); + expect(http.getCalls).toEqual(["ws-a"]); + }); +}); + +describe("layout store — one-way localStorage migration (§2.7)", () => { + it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => { + const storage = storageWith(["extensions", "vision"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) }, + ]); + // The migrated tree is now the rendered tree. + expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"])); + + // The key is never read again: a later probe PUTs nothing. + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => { + const storage = storageWith(null); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + }); + + it("a custom provider tree (panels differ) is NOT migrated over", async () => { + const storage = storageWith(["extensions"]); + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]); + const http = fakeHttp(() => ({ ok: true, tree: custom })); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + expect(store.tree).toEqual(custom); + }); + + it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + const store = makeStore(http, { storage }); + await store.probe("ws-a"); + expect(http.putCalls).toEqual([]); + }); + + it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => { + const storage = storageWith(["extensions"]); + const http = fakeHttp( + () => ({ ok: true, tree: DEFAULT_LAYOUT }), + () => ({ ok: false, notFound: false, error: "invalid tree" }), + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toHaveLength(1); + expect(warnings.some((w) => w.includes("migration"))).toBe(true); + // The rendered tree is untouched (the PUT failed). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + await store.probe("ws-a"); + expect(http.putCalls).toHaveLength(1); + }); + + it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => { + const storage = storageWith(["extensions"]); + let absent = true; + const http = fakeHttp(() => + absent + ? { ok: false, notFound: true, error: "No layout provider is active" } + : { ok: true, tree: DEFAULT_LAYOUT }, + ); + const store = makeStore(http, { storage }); + await store.probe("ws-a", { boot: true }); + expect(http.putCalls).toEqual([]); + + absent = false; + await store.probe("ws-a"); // extensions-changed probe — first success + expect(http.putCalls).toEqual([ + { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) }, + ]); + }); +}); + +describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => { + it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => { + const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]); + const http = fakeHttp(); + // The DELETE returns the FRESH SEED (the server re-seeded). + http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT }); + const store = makeStore(http); + + // Get to a NON-default tree first: a present provider + a pushed edit. + http.getResult = () => ({ ok: true, tree: custom }); + await store.probe("ws-a"); + expect(store.tree).toEqual(custom); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBeNull(); + expect(http.deleteCalls).toEqual(["ws-a"]); + // The fresh seed is applied locally (the WS broadcast echo is idempotent). + expect(store.tree).toEqual(DEFAULT_LAYOUT); + expect(store.providerState).toBe("present"); + }); + + it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => { + const http = fakeHttp(); + http.deleteResult = () => ({ + ok: false, + notFound: true, + error: "No layout provider is active", + }); + const store = makeStore(http); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("no layout plugin enabled"); + expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default + }); + + it("another failure (5xx on a present provider) resolves to its error message", async () => { + const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT })); + http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" }); + const store = makeStore(http); + await store.probe("ws-a"); + + const failure = await store.resetToDefault("ws-a"); + expect(failure).toBe("storage write failed"); + }); +}); diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte new file mode 100644 index 0000000..8fafa7f --- /dev/null +++ b/src/features/layout/ui/FallbackTile.svelte @@ -0,0 +1,13 @@ +<script lang="ts"> + /** + * Shown when a leaf names a view this client has not registered — the tree + * is data and can arrive from a backend (plan 02) that knows views this + * build lacks. A titled placeholder, never a crash. + */ + let { viewId }: { viewId: string } = $props(); +</script> + +<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile"> + <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3> + <p class="text-sm opacity-60">View not registered.</p> +</div> diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte new file mode 100644 index 0000000..0fd6430 --- /dev/null +++ b/src/features/layout/ui/TileLeaf.svelte @@ -0,0 +1,44 @@ +<script lang="ts"> + /** + * Mounts a registered view for a leaf node. Resolution goes through the + * REACTIVE registry, so a late registration (plan 03) swaps the tile in + * without a tree change. An unknown view id renders a fallback tile — + * NEVER a crash (backend-owned trees can name views this client lacks). + * + * A diagnostic `marker` on the leaf draws an outline around it — the only + * node kind without a natural tile wrapper, so the wrapper exists only + * when marked (unmarked leaves render exactly as before). + */ + import type { TileMarker } from "@dispatch/layout-contract"; + import { viewRegistry, type RegisteredView } from "../logic/registry"; + import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker"; + import FallbackTile from "./FallbackTile.svelte"; + + let { + node, + ports, + }: { + node: { readonly view: string; readonly marker?: TileMarker }; + ports?: unknown; + } = $props(); + + const entry: RegisteredView | undefined = $derived( + $viewRegistry.find((v) => v.id === node.view), + ); + const View = $derived(entry?.component); + const outline = $derived(markerOutline(node.marker)); +</script> + +{#snippet content()} + {#if View} + <View {ports} /> + {:else} + <FallbackTile viewId={node.view} /> + {/if} +{/snippet} + +{#if outline !== undefined} + <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div> +{:else} + {@render content()} +{/if} diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte new file mode 100644 index 0000000..5ed8a89 --- /dev/null +++ b/src/features/layout/ui/TileNode.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + /** + * The recursive tile dispatcher: renders the node for its `kind` and recurses + * through the container components. Leaf nodes resolve through the view + * registry (TileLeaf). + */ + import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileLeaf from "./TileLeaf.svelte"; + import TilePanels from "./TilePanels.svelte"; + import TileSplit from "./TileSplit.svelte"; + import TileStack from "./TileStack.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: LayoutNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +{#if node.kind === "split"} + <TileSplit {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "stack"} + <TileStack {node} {path} {ports} {collapse} {onEdit} /> +{:else if node.kind === "panels"} + <TilePanels {node} {path} {ports} {onEdit} /> +{:else if node.kind === "leaf"} + <TileLeaf {node} {ports} /> +{/if} diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte new file mode 100644 index 0000000..cc5fa89 --- /dev/null +++ b/src/features/layout/ui/TilePanels.svelte @@ -0,0 +1,185 @@ +<script lang="ts"> + /** + * A scrolling stack of swappable panels — the generalized ViewSidebar. The + * panel list comes from the tree node; dropdown options from the (reactive) + * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`; + * the OWNER (App today, the backend store in plan 02) applies them and + * decides persistence. + */ + import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract"; + import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import { untrack } from "svelte"; + import { viewRegistry } from "../logic/registry"; + import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit"; + import { + initialPanelKeys, + type PanelKeys, + reconcilePanelKeys, + swappedPanels, + } from "../logic/panel-keys"; + import { markerStyle } from "../logic/marker"; + import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion"; + import TileLeaf from "./TileLeaf.svelte"; + + let { + node, + path, + ports, + onEdit, + renderMarker = false, + }: { + node: PanelsNode; + path: Path; + ports?: unknown; + onEdit: (ops: readonly LayoutOp[]) => void; + /** + * Whether to draw the node's diagnostic marker on this root. Default NO: + * a panels node is the scrolled CONTENT of a split's collapsible child, + * so an outline here wraps the full scrollable region and scrolls with + * it — the split draws the marker on the aside (the fixed viewport) + * instead. Pass `true` only for a panels node used outside a split. + */ + renderMarker?: boolean; + } = $props(); + + // Panel-eligible views only (decision 5): chat/composer/message-queue never + // appear in the dropdowns. Reactive so late registrations re-render options. + const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible)); + const addView = $derived(firstPanelView(panelViews)); + + // Stable per-panel keys (pure reconcile, memoised across tree updates) so a + // closed panel is the one that animates out, not whichever is last. + // A local ✕ click hints WHICH panel left (consumed by the next tree change; + // ignored if that change isn't exactly this removal, e.g. a remote edit). + let keyMemo: PanelKeys | null = null; + let removeHint: number | undefined; + const keyed = $derived.by(() => { + const views = node.panels.map((p) => p.view); + keyMemo = + keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint); + removeHint = undefined; + return keyMemo; + }); + + function removePanel(idx: number): void { + removeHint = idx; + onEdit([removePanelOp(path, idx)]); + } + + // View SWAP: the panel BOX (the well) animates from the old view's height + // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are + // the per-panel in:/out: transitions below.) + let columnEl = $state<HTMLDivElement | undefined>(); + /** The keys the DOM currently shows (set after each update). */ + let shownKeys: PanelKeys | null = null; + let before: { keys: PanelKeys; heights: Map<number, number> } | null = null; + $effect.pre(() => { + const next = keyed; + untrack(() => { + const heights = new Map<number, number>(); + for (const el of panelEls()) { + const well = wellOf(el); + if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight); + } + before = { keys: shownKeys ?? next, heights }; + }); + }); + $effect(() => { + const now = keyed; + untrack(() => { + const prev = before; + before = null; + shownKeys = now; + if (prev === null) return; + for (const key of swappedPanels(prev.keys, now).keys()) { + const el = panelEls().find((e) => Number(e.dataset.panelKey) === key); + const well = el === undefined ? null : wellOf(el); + const from = prev.heights.get(key); + if (well !== null && from !== undefined) morphPanelHeight(well, from); + } + }); + }); + + function wellOf(panel: HTMLElement): HTMLElement | null { + return panel.querySelector<HTMLElement>(":scope > [data-panel-well]"); + } + + function panelEls(): HTMLElement[] { + return columnEl === undefined + ? [] + : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")]; + } +</script> + +<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its + natural height — if it shrank to the scroller (min-h-0 in a flex column), + its children overflowed it and the trailing margin was never scrollable. --> +<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so + it grows/shrinks WITH the panel's height animation (a flex gap would jump). --> +<div + bind:this={columnEl} + class="flex min-h-0 shrink-0 flex-col" + style={renderMarker ? markerStyle(node.marker) : undefined} + data-panels +> + {#each node.panels as panel, idx (keyed.keys[idx])} + <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap, + so panels below slide smoothly); a view swap animates the WELL's + height. Either way only a height is tweened (clipped) — the contents + keep their natural size. `.inset-well` (app.css): recessed into the + sidebar, like the chat's thinking/tool blocks. --> + <div + class="shrink-0 pb-2" + data-panel-key={keyed.keys[idx]} + in:panelGrowIn + out:panelShrinkOut + > + <div class="inset-well flex flex-col p-3" data-panel-well> + <div class="flex items-center gap-1"> + <select + class="select select-bordered select-sm flex-1" + aria-label="Select a view" + value={panel.view} + onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])} + > + <option value="" disabled>Select a view</option> + {#each panelViews as view (view.id)} + <option value={view.id}>{view.label}</option> + {/each} + </select> + {#if idx > 0} + <button + type="button" + class="btn btn-square btn-ghost btn-sm shrink-0" + aria-label="Remove view" + onclick={() => removePanel(idx)} + > + ✕ + </button> + {/if} + </div> + + <div class="mt-2"> + <TileLeaf node={panel} {ports} /> + </div> + </div> + </div> + {/each} + + {#if addView !== null} + <!-- `.raised-btn` (app.css): the mirror of the view wells above it — + pops out where they sink in; pressed, it sinks in like them. Height = + DaisyUI's default button size (`--size-field` × 10), as the `btn` it + replaced; `shrink-0` so the column can never squash it. The last panel's + `pb-2` is the gap above it; `mb-2` = the same gap below, so it + doesn't sit tight to the end. --> + <button + type="button" + class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0" + aria-label="Add view" + onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])} + > + <PlusIcon size={18} weight="bold" aria-hidden="true" /> + </button> + {/if} +</div> diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte new file mode 100644 index 0000000..82ad430 --- /dev/null +++ b/src/features/layout/ui/TileRoot.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + /** + * The tile-tree root. Renders the tree (titlebar slot first — unset renders + * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the + * new tree via `onTreeChange`. The OWNER decides what persistence means + * (App/localStorage today; the backend layout store in plan 02). + */ + import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import TileNode from "./TileNode.svelte"; + + let { + tree, + onTreeChange, + ports, + collapse, + }: { + tree: LayoutTree; + onTreeChange?: (tree: LayoutTree) => void; + ports?: unknown; + collapse: CollapseStore; + } = $props(); + + function handleEdit(ops: readonly LayoutOp[]): void { + const result = applyOps(tree, ops); + if (result.ok) { + onTreeChange?.(result.tree); + } else { + // A panel edit that fails validation (e.g. removing the last panel) is + // refused loudly in dev — the tree keeps its previous state. + console.error(`TileRoot: layout edit refused: ${result.error}`); + } + } +</script> + +{#if tree.titlebar !== undefined} + <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} /> +{/if} +<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} /> diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts new file mode 100644 index 0000000..4b404a1 --- /dev/null +++ b/src/features/layout/ui/TileRoot.test.ts @@ -0,0 +1,394 @@ +import type { LayoutTree, SplitNode } from "@dispatch/layout-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { gsap } from "gsap"; +import { describe, expect, it, vi } from "vitest"; +import { createCollapseStore, registerView, unregisterView } from "../index"; +import FixtureA from "./fixtures/FixtureA.svelte"; +import FixtureB from "./fixtures/FixtureB.svelte"; +import FixtureC from "./fixtures/FixtureC.svelte"; +import TileRoot from "./TileRoot.svelte"; + +// Fixture registry: two panel-eligible views + one content-only view (a THIRD +// component, so every fixture testid is unique in the rendered tree). +registerView({ id: "fix-a", label: "Alpha View", component: FixtureA }); +registerView({ id: "fix-b", label: "Beta View", component: FixtureB }); +registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false }); + +function leaf(view: string): { kind: "leaf"; view: string } { + return { kind: "leaf", view }; +} + +/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */ +function defaultTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +/** A bare panels root — panel EDITS without the collapsible chrome in the way. */ +function panelsOnlyTree(...views: string[]): LayoutTree { + return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } }; +} + +/** The default test tree WITH the diagnostic markers on its two root tiles. */ +function markedTestTree(): LayoutTree { + return { + version: 1, + root: { + kind: "split", + id: "root", + dir: "row", + children: [ + { + size: 1, + node: { + kind: "stack", + id: "main", + marker: "main", + children: [{ grow: 1, node: leaf("fix-a") }], + }, + }, + { + size: 0, + basis: 320, + collapsible: true, + node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] }, + }, + ], + }, + }; +} + +function sidePanelsOf(tree: LayoutTree | null): string[] { + if (tree === null) return []; + const side = (tree.root as SplitNode).children?.[1]?.node; + return side?.kind === "panels" + ? side.panels.map((p) => p.view) + : tree.root.kind === "panels" + ? tree.root.panels.map((p) => p.view) + : []; +} + +describe("TileRoot", () => { + it("renders every node kind: split, stack, panels, leaf", () => { + // A FRESH collapse store per test — the store is stateful chrome. + render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + // Stack children mount their registered components. + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-c")).toBeInTheDocument(); + // The panels node renders its panel leaf + the dropdown. + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument(); + }); + + it("shows a fallback tile (never a crash) for an unknown view id", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "not-registered-anywhere" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fallback = screen.getByTestId("fallback-tile"); + expect(fallback).toHaveTextContent("not-registered-anywhere"); + expect(fallback).toHaveTextContent("View not registered."); + }); + + it("renders nothing for the titlebar slot while it is unset (D-L5)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0); + }); + + it("a set titlebar renders its node (the slot works; nothing populates it today)", () => { + const tree: LayoutTree = { + version: 1, + titlebar: { kind: "leaf", view: "fix-a" }, + root: { kind: "leaf", view: "fix-b" }, + }; + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + expect(screen.getByTestId("fixture-a")).toBeInTheDocument(); + expect(screen.getByTestId("fixture-b")).toBeInTheDocument(); + }); + + it("the collapsible child reflects the collapse store and toggles through it", async () => { + const collapse = createCollapseStore({ viewportWidth: 1024 }); + const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } }); + const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']"); + expect(aside).not.toBeNull(); + // Wide viewport default: open → full basis width. + expect(aside).toHaveAttribute("data-open", "true"); + expect(aside?.style.width).toBe("320px"); + + await collapse.toggle("side"); + expect(aside).toHaveAttribute("data-open", "false"); + expect(aside?.style.width).toBe("0px"); + }); + + it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a"); + expect(sidePanelsOf(changed)).toEqual(["fix-a"]); + // TileRoot is a CONTROLLED component: applying the op is the owner's job + // (App re-renders with the new tree; the test doesn't feed it back). + }); + + it("the + button emits insertPanel pre-selecting the first eligible view", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + await user.click(screen.getByRole("button", { name: "Add view" })); + expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]); + }); + + it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => { + const user = userEvent.setup(); + let changed: LayoutTree | null = null; + render(TileRoot, { + props: { + tree: panelsOnlyTree("fix-b", "fix-a"), + collapse: createCollapseStore(), + onTreeChange: (t) => (changed = t), + }, + }); + + // Only panels after the first can be removed — like ViewSidebar today. + const removeButtons = screen.getAllByRole("button", { name: "Remove view" }); + expect(removeButtons).toHaveLength(1); + await user.click(removeButtons[0] as HTMLElement); + expect(sidePanelsOf(changed)).toEqual(["fix-b"]); + }); + + it("an invalid edit is refused: the first panel has no remove button", () => { + const tree = panelsOnlyTree("fix-b"); + let changed: LayoutTree | null = null; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { + props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) }, + }); + expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull(); + expect(changed).toBeNull(); + } finally { + consoleError.mockRestore(); + } + }); + + it("a stack child with grow renders in a flex-grown wrapper; others natural", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0"); + expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%"); + const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0"); + expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto"); + }); + + it("the stack column is full-height so a grow child fills it even when empty", () => { + // jsdom can't compute flex layout — the honest seam is the CSS class. + // WITHOUT `h-full` the stack column's height is content-based, so the + // grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf + // collapses to ~0 and the composer below it rides to the top (instead of + // staying pinned at the bottom of the column). + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stackColumn).toHaveClass("h-full"); + }); + + it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => { + // The inline outline IS the honest seam: marker values arrive in runtime + // tree data (Tailwind can't see them — dynamic classes would silently + // render nothing), so the renderer applies an inline style, and the test + // asserts exactly what lands on the tile wrapper. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline: 4px solid red"); + // The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed, + // opaquely-painted viewport (the aside's bg is transparent; this div's + // bg-base-100 would cover an outline drawn on the aside). The panels + // root is its SCROLLED content, so an outline there would wrap the + // whole scrollable region and scroll along with it. + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => { + // Tiles span their containers edge-to-edge, so a default (outside) + // outline overflows the visible area and is clipped away — the offset + // pulls the ring inside the box. Pin it on both marked tiles. + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col"); + expect(stack?.getAttribute("style")).toContain("outline-offset: -4px"); + const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']"); + expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px"); + }); + + it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => { + const { container } = render(TileRoot, { + props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]"); + expect(panels?.getAttribute("style") ?? "").not.toContain("outline"); + }); + + it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => { + const { container } = render(TileRoot, { + props: { tree: defaultTestTree(), collapse: createCollapseStore() }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => { + // Both root tiles marked with a value the renderer doesn't know. + const tree = markedTestTree(); + const root = tree.root; + if (root.kind !== "split") return; + const junked = root.children.map((child) => ({ + ...child, + node: { ...child.node, marker: "purple" } as unknown as typeof child.node, + })); + const marked: LayoutTree = { version: 1, root: { ...root, children: junked } }; + const { container } = render(TileRoot, { + props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) }, + }); + expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0); + }); + + it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => { + const tree: LayoutTree = { + version: 1, + root: { kind: "leaf", view: "fix-a", marker: "side" }, + }; + const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const fixture = container.querySelector("[data-testid='fixture-a']"); + expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green"); + }); + + it("the dropdown lists panel-eligible views only (decision 5)", () => { + render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } }); + const select = screen.getByRole("combobox", { name: "Select a view" }); + const labels = [...select.querySelectorAll("option")].map((o) => o.textContent); + expect(labels).toContain("Alpha View"); + expect(labels).toContain("Beta View"); + expect(labels).not.toContain("Content"); + }); + + it("an unregistered view id inside a panel renders the fallback (not a crash)", () => { + render(TileRoot, { + props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() }, + }); + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + }); +}); + +describe("TileRoot registry cleanup", () => { + it("unregisters the fixture views (the registry is module-global per test file)", () => { + unregisterView("fix-a"); + unregisterView("fix-b"); + unregisterView("fix-content"); + const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } }; + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + // Unregistered → the fallback tile, not a crash. + expect(screen.getByTestId("fallback-tile")).toBeInTheDocument(); + } finally { + consoleError.mockRestore(); + } + }); +}); + +describe("TilePanels — panel identity + height animation", () => { + const wrappers = (): HTMLElement[] => [ + ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"), + ]; + + it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() }, + }); + const [first, , third] = wrappers(); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() }); + // Identity, not position: the third panel's node survived (index keying + // would have reused the middle node and dropped the last one). + expect(wrappers()).toEqual([first, third]); + expect(screen.queryByTestId("fixture-a")).toBeNull(); + }); + + it("✕ on one of two IDENTICAL views removes exactly that panel", async () => { + const user = userEvent.setup(); + const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a"); + const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } }); + const [first, , third] = wrappers(); + // Click the MIDDLE panel's ✕ (the first panel has none), then the owner + // applies the edit — the two fix-a panels are indistinguishable by data. + const [middleClose] = screen.getAllByRole("button", { name: "Remove view" }); + await user.click(middleClose as HTMLElement); + await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }); + expect(wrappers()).toEqual([first, third]); + }); + + it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false }) as unknown as MediaQueryList), + ); + try { + const { rerender } = render(TileRoot, { + props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() }, + }); + const closing = wrappers()[1]; + await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() }); + // jsdom's stubbed Element.animate never finishes, so the outro holds. + expect(closing?.isConnected).toBe(true); + expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1); + } finally { + vi.unstubAllGlobals(); + } + }); +}); diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte new file mode 100644 index 0000000..84fa075 --- /dev/null +++ b/src/features/layout/ui/TileSplit.svelte @@ -0,0 +1,130 @@ +<script lang="ts"> + /** + * A flex row/column of child subtrees. Child sizing comes from the tree: + * `size` → flex-grow (+ optional `basis`), and `collapsible` children get + * today's EXACT sidebar behavior — width/translate transition, `lg:relative` + * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state + * is chrome (per-node-id), owned by the injected collapse store. + */ + import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: SplitNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); + + const state = $derived($collapse); + + /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */ + function keyOf(i: number, id: string | undefined): string { + return id ?? [...path, i].map((s) => String(s)).join(":"); + } + + function isOpen(i: number, id: string | undefined): boolean { + const key = keyOf(i, id); + return state.openByNode[key] ?? state.defaultOpen; + } + + /** + * The scroll container's inline style: the fixed sidebar width + slide + * transform + any diagnostic marker (inset) — none when unmarked. + * + * The marker goes on THIS div, not the aside: the inner div paints an + * OPAQUE full-height background (bg-base-100) that would cover an outline + * drawn on the aside. This div's own box never moves — only its contents + * scroll — and overflow-y-auto clips descendants, never an element's own + * outline, so the marker frames exactly the visible sidebar. + */ + function scrollViewportStyle( + basis: number | undefined, + open: boolean, + marker: TileMarker | undefined, + ): string { + const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`]; + const markerCss = markerStyle(marker); + if (markerCss !== undefined) parts.push(markerCss); + return parts.join("; "); + } +</script> + +{#if node.dir === "col"} + <div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} + </div> +{:else} + <div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}> + {#each node.children as child, i (i)} + {@const key = keyOf(i, child.node.id)} + {#if child.collapsible} + {@const open = isOpen(i, child.node.id)} + <!-- + A diagnostic marker on a collapsible child is drawn on the SCROLL + CONTAINER below — the sidebar's fixed, opaquely-painted viewport — + NOT on the child's own root element: that root renders INSIDE the + scroll container, so an outline there would wrap the full + scrollable content and scroll along with it instead of framing the + visible panel. + --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode + (< lg) — inline (≥ lg) the chat column's gutter already separates the + views, but the overlay's left edge is the panel's own edge. --> + <aside + class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative" + style="width: {open ? (child.basis ?? 320) : 0}px" + data-testid="collapsible-child" + data-open={open} + > + <div + class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 max-lg:pl-3 transition-transform duration-300 ease-out" + style={scrollViewportStyle(child.basis, open, child.node.marker)} + data-testid="sidebar-scroll-viewport" + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + </aside> + {#if open} + <!-- Backdrop: only on narrow screens (overlay mode), click to close. --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="fixed inset-0 z-20 bg-black/30 lg:hidden" + role="button" + tabindex="0" + aria-label="Close sidebar" + onclick={() => collapse.setOpen(key, false)} + onkeydown={(e) => { + if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false); + }} + ></div> + {/if} + {:else} + <div + class="min-h-0 min-w-0" + style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")} + > + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/if} + {/each} + </div> +{/if} diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte new file mode 100644 index 0000000..c8f8cae --- /dev/null +++ b/src/features/layout/ui/TileStack.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + /** + * A plain vertical container (today's left column). `grow` children flex + * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like + * today's `flex-1`); children without grow take natural height. + * + * `h-full` keeps the container's main size DEFINITE (the parent is the + * split-child wrapper, cross-stretched to the full-height split row). With + * an indefinite height, `flex: 1 1 0%` degenerates to content sizing — an + * EMPTY grow child collapses to ~0 and the siblings below it (composer) + * ride to the top instead of staying pinned at the bottom. + */ + import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract"; + import type { CollapseStore } from "../logic/collapse"; + import { markerStyle } from "../logic/marker"; + import TileNode from "./TileNode.svelte"; + + let { + node, + path, + ports, + collapse, + onEdit, + }: { + node: StackNode; + path: Path; + ports?: unknown; + collapse: CollapseStore; + onEdit: (ops: readonly LayoutOp[]) => void; + } = $props(); +</script> + +<div + class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden" + style={markerStyle(node.marker)} +> + {#each node.children as child, i (i)} + <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}> + <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} /> + </div> + {/each} +</div> diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte new file mode 100644 index 0000000..e55ba2a --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureA.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Alpha" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-a">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte new file mode 100644 index 0000000..1c48584 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureB.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Beta" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-b">{label}</div> diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte new file mode 100644 index 0000000..2bceb67 --- /dev/null +++ b/src/features/layout/ui/fixtures/FixtureC.svelte @@ -0,0 +1,6 @@ +<script lang="ts"> + // A fixture view for renderer tests — ignores any props it receives. + let { label = "Gamma" }: { label?: string } = $props(); +</script> + +<div data-testid="fixture-c">{label}</div> diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts new file mode 100644 index 0000000..7fc5edf --- /dev/null +++ b/src/features/layout/ui/panel-motion.ts @@ -0,0 +1,83 @@ +import { gsap } from "gsap"; +import type { TransitionConfig } from "svelte/transition"; + +/** + * GSAP height animations for sidebar panels — the DOM EFFECT half (stable + * panel identity lives in `../logic/panel-keys.ts`). + * + * Every animation changes ONLY the panel wrapper's height (clipped with + * `overflow: hidden`); the panel's contents keep their natural size the whole + * time — the wrapper reveals / hides them like a drawer, it never scales + * them. The wrapper's bottom padding is the gap between panels, so it grows / + * shrinks with the panel and the panels below slide smoothly. + * + * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto. + * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0 + * while staying in flow (Svelte keeps the node for the returned duration). + * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its + * edges move with it — no empty gap below a shrinking box) animates from + * the old view's height to the new one's. + * + * Svelte's `in:` does not run on first mount, so loading the page never + * animates. Disabled when the user prefers reduced motion (or the + * environment can't tell, e.g. jsdom) — then every change is instant. + */ + +const DURATION = 0.35; +const EASE = "power2.inOut"; + +export function panelMotionAllowed(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +export function panelGrowIn(node: HTMLElement): TransitionConfig { + if (!panelMotionAllowed()) return { duration: 0 }; + gsap.killTweensOf(node); + gsap.fromTo( + node, + { height: 0, overflow: "hidden" }, + { + height: "auto", + duration: DURATION, + ease: EASE, + clearProps: "height,overflow", + }, + ); + return { duration: DURATION * 1000 }; +} + +export function panelShrinkOut(node: HTMLElement): TransitionConfig { + if (!panelMotionAllowed()) return { duration: 0 }; + // e.g. closed mid-grow / mid-swap: shrink from wherever it is. + gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]); + gsap.to(node, { + height: 0, + overflow: "hidden", + duration: DURATION, + ease: EASE, + }); + return { duration: DURATION * 1000 }; +} + +/** + * Animate `node` from `fromHeight` (its height before the view swapped) to + * its new natural height. Call AFTER the DOM shows the new view. + */ +export function morphPanelHeight(node: HTMLElement, fromHeight: number): void { + if (!panelMotionAllowed()) return; + gsap.killTweensOf(node); + gsap.fromTo( + node, + { height: fromHeight, overflow: "hidden" }, + { + height: "auto", + duration: DURATION, + ease: EASE, + clearProps: "height,overflow", + }, + ); +} diff --git a/src/features/markdown/adapter/text-file.test.ts b/src/features/markdown/adapter/text-file.test.ts new file mode 100644 index 0000000..2597271 --- /dev/null +++ b/src/features/markdown/adapter/text-file.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it, vi } from "vitest"; +import type { TextFileState } from "../types"; +import { createTextFileClient } from "./text-file"; + +const response = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +const file = (text = "# Plan") => ({ + path: "/project/a.md", + text, + format: "markdown", + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, +}); +const flush = async () => { + for (let i = 0; i < 20; i++) await Promise.resolve(); +}; +describe("context-bound live-file client", () => { + it("preflights once, sends both guards and encodes literal path once; dedups repeated references", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + response(String(url).endsWith("-context") ? context : file()), + ); + const client = createTextFileClient("/api", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + const offA = client.subscribe("notes/100%.md", (s) => a.push(s)); + const offB = client.subscribe("notes/100%.md", (s) => b.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(2); + const url = new URL(String(fetcher.mock.calls[1]?.[0]), "http://localhost"); + expect(Object.fromEntries(url.searchParams)).toEqual({ + path: "notes/100%.md", + expectedCwd: "/project", + expectedComputerId: "", + }); + expect(fetcher.mock.calls[1]?.[1]?.cache).toBe("no-store"); + expect(a.at(-1)?.kind).toBe("ready"); + expect(b.at(-1)?.kind).toBe("ready"); + offA(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(false); + offB(); + expect(fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + client.subscribe("notes/100%.md", () => {}); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("never reads a file before context, handles remote inheritance explicitly", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValue(response({ ...context, computerId: "remote" })); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("/same-local-path.md", (s) => states.push(s)); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(states.at(-1)).toMatchObject({ + kind: "error", + message: expect.stringContaining("remote computers"), + }); + client.dispose(); + }); + it.each([ + 400, 404, 409, 413, 422, + ])("reports HTTP %i then Retry refreshes authoritative context and live file", async (status) => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response({ error: `failure ${status}` }, status)) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("changed"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)).toEqual({ kind: "error", message: `failure ${status}` }); + client.refresh("a.md"); + await flush(); + expect(states.at(-1)).toMatchObject({ kind: "ready", file: { text: "changed" } }); + expect(fetcher).toHaveBeenCalledTimes(4); + client.dispose(); + }); + it("guards late responses after refresh and disposal", async () => { + let finish: (r: Response) => void = () => {}; + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ) + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("new"))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + client.refresh("a.md"); + await flush(); + finish(response(file("old"))); + await flush(); + expect(states.at(-1)).toMatchObject({ file: { text: "new" } }); + client.dispose(); + expect(fetcher.mock.calls[3]?.[1]?.signal?.aborted).toBe(true); + }); + it("bounds active cache to32 files/8MiB source and concurrency to4, disposes queue", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockImplementation(async (url) => + String(url).endsWith("-context") ? response(context) : new Promise(() => {}), + ); + const client = createTextFileClient("", "c", fetcher); + const overflow: TextFileState[] = []; + for (let i = 0; i < 40; i++) + client.subscribe(`${i}.md`, (s) => { + if (i >= 32) overflow.push(s); + }); + await flush(); + expect(fetcher).toHaveBeenCalledTimes(5); + expect(overflow).toHaveLength(8); + expect(overflow[0]).toMatchObject({ kind: "error" }); + client.dispose(); + for (const call of fetcher.mock.calls) expect(call[1]?.signal?.aborted).toBe(true); + }); + it("rejects oversized or malformed success data instead of truncating", async () => { + const fetcher = vi + .fn<typeof fetch>() + .mockResolvedValueOnce(response(context)) + .mockResolvedValueOnce(response(file("a".repeat(262145)))); + const client = createTextFileClient("", "c", fetcher); + const states: TextFileState[] = []; + client.subscribe("a.md", (s) => states.push(s)); + await flush(); + expect(states.at(-1)?.kind).toBe("error"); + client.dispose(); + }); +}); + +it("invalidates every live document and reloads guards before publishing refreshed context", async () => { + let generation = 0; + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => { + if (String(url).endsWith("-context")) + return response({ ...context, cwd: generation ? "/new" : "/project" }); + const params = new URL(String(url), "http://localhost").searchParams; + return response({ + ...file(generation ? "new" : "old"), + path: `${params.get("expectedCwd")}/a.md`, + }); + }); + const client = createTextFileClient("", "c", fetcher); + const a: TextFileState[] = [], + b: TextFileState[] = []; + client.subscribe("a.md", (state) => a.push(state)); + client.subscribe("b.md", (state) => b.push(state)); + await flush(); + generation++; + client.refresh("a.md"); + expect(a.at(-1)?.kind).toBe("loading"); + expect(b.at(-1)?.kind).toBe("loading"); + await flush(); + expect(a.at(-1)).toMatchObject({ kind: "ready", file: { path: "/new/a.md", text: "new" } }); + expect(b.at(-1)).toMatchObject({ kind: "ready", file: { text: "new" } }); + expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith("-context"))).toHaveLength(2); + client.dispose(); +}); diff --git a/src/features/markdown/adapter/text-file.ts b/src/features/markdown/adapter/text-file.ts new file mode 100644 index 0000000..05ddb23 --- /dev/null +++ b/src/features/markdown/adapter/text-file.ts @@ -0,0 +1,171 @@ +import type { + TextFileContextResponse, + TextFileQuery, + TextFileResponse, +} from "@dispatch/transport-contract"; +import type { TextFileClient, TextFileState } from "../types"; + +const MAX_BYTES = 262144; +const MAX_ENTRIES = 32; +const MAX_PARALLEL = 4; +interface Entry { + readonly listeners: Set<(state: TextFileState) => void>; + state: TextFileState; + controller: AbortController; +} +/** Per-mounted-chat, bounded cache: shared only by live references, never persisted. */ +export function createTextFileClient( + apiBase: string, + conversationId: string, + fetchImpl: typeof fetch, +): TextFileClient { + const entries = new Map<string, Entry>(); + let disposed = false; + let contextController = new AbortController(); + let context: Promise<TextFileContextResponse> | null = null; + let active = 0; + const queue = new Map<string, () => void>(); + const endpoint = `${apiBase.replace(/\/$/, "")}/conversations/${encodeURIComponent(conversationId)}`; + async function json(url: string, signal: AbortSignal): Promise<unknown> { + const response = await fetchImpl(url, { signal, cache: "no-store" }); + const data: unknown = await response.json(); + if (!response.ok) { + const error = data as { error?: string }; + throw new Error(error.error ?? `Text file request failed (HTTP ${response.status})`); + } + return data; + } + function getContext(): Promise<TextFileContextResponse> { + context ??= json(`${endpoint}/text-file-context`, contextController.signal).then((data) => { + const c = data as TextFileContextResponse; + if ( + c.conversationId !== conversationId || + !(c.cwd === null || typeof c.cwd === "string") || + !(c.computerId === null || typeof c.computerId === "string") + ) { + throw new Error("Invalid text file context response"); + } + return c; + }); + return context; + } + function publish(entry: Entry, state: TextFileState): void { + entry.state = state; + for (const listener of entry.listeners) listener(state); + } + async function load(path: string, entry: Entry, controller: AbortController): Promise<void> { + try { + if (controller.signal.aborted || disposed) return; + const c = await getContext(); + if (controller.signal.aborted || disposed) return; + if (c.computerId !== null) + throw new Error( + "Text-file embeds are not available for remote computers. No local file was read.", + ); + const query: TextFileQuery = { + path, + expectedCwd: c.cwd ?? "", + expectedComputerId: c.computerId ?? "", + }; + const params = new URLSearchParams(query as unknown as Record<string, string>); + const data = (await json( + `${endpoint}/text-file?${params}`, + controller.signal, + )) as TextFileResponse; + if ( + typeof data.text !== "string" || + typeof data.path !== "string" || + !data.path.startsWith("/") || + !["markdown", "text"].includes(data.format) || + data.maxBytes !== MAX_BYTES || + !Number.isInteger(data.bytes) || + data.bytes < 0 || + data.bytes > MAX_BYTES || + new TextEncoder().encode(data.text).length !== data.bytes + ) { + throw new Error("Invalid or oversized text file response (maximum 256 KiB)"); + } + if (!controller.signal.aborted && !disposed) publish(entry, { kind: "ready", file: data }); + } catch (error) { + if (!controller.signal.aborted && !disposed) + publish(entry, { + kind: "error", + message: error instanceof Error ? error.message : "Unable to read text file", + }); + } + } + function drain(): void { + while (active < MAX_PARALLEL && queue.size) { + const next = queue.entries().next().value; + if (!next) return; + queue.delete(next[0]); + next[1](); + } + } + function start(path: string, entry: Entry): void { + entry.controller.abort(); + const controller = new AbortController(); + entry.controller = controller; + publish(entry, { kind: "loading" }); + queue.set(path, () => { + if (controller.signal.aborted || disposed) return; + active++; + void load(path, entry, controller).finally(() => { + active--; + drain(); + }); + }); + drain(); + } + return { + subscribe(path, listener) { + if (disposed || (!entries.has(path) && entries.size >= MAX_ENTRIES)) { + listener({ + kind: "error", + message: "Too many text-file embeds (maximum 32 active files).", + }); + return () => {}; + } + let entry = entries.get(path); + if (!entry) { + entry = { + listeners: new Set(), + state: { kind: "loading" }, + controller: new AbortController(), + }; + entries.set(path, entry); + start(path, entry); + } + entry.listeners.add(listener); + listener(entry.state); + const current = entry; + return () => { + current.listeners.delete(listener); + if (!current.listeners.size) { + current.controller.abort(); + entries.delete(path); + queue.delete(path); + } + if (!entries.size) { + contextController.abort(); + contextController = new AbortController(); + context = null; + } + }; + }, + refresh(_path) { + // Refresh can reveal a context change: invalidate ALL live content before rereading. + contextController.abort(); + contextController = new AbortController(); + context = null; + for (const [path, entry] of entries) start(path, entry); + }, + dispose() { + disposed = true; + contextController.abort(); + for (const entry of entries.values()) entry.controller.abort(); + entries.clear(); + queue.clear(); + }, + }; +} diff --git a/src/features/markdown/index.ts b/src/features/markdown/index.ts index ff3aefa..2b50ab1 100644 --- a/src/features/markdown/index.ts +++ b/src/features/markdown/index.ts @@ -6,3 +6,6 @@ export const manifest = { name: "markdown", description: "Renders assistant messages as sanitized Markdown (GFM + syntax highlighting)", } as const; + +export { createTextFileClient } from "./adapter/text-file"; +export type { TextFileClient, TextFileContext, TextFileState } from "./types"; diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 54b0086..f186da9 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { renderMarkdown } from "./markdown"; +import { renderMarkdown, renderMarkdownTail } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** The single rendered `<img>` src, or null. */ +function imageSrc(html: string): string | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.querySelector("img")?.getAttribute("src") ?? null; +} describe("renderMarkdown", () => { it("renders GFM markdown (headings, emphasis)", () => { @@ -25,6 +32,46 @@ describe("renderMarkdown", () => { expect(html).toContain("<b>"); }); + describe("links open in a new tab", () => { + function anchors(html: string): HTMLAnchorElement[] { + const doc = new DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("a")); + } + + it.each([ + ["a markdown link", "See [the docs](https://example.com/docs)."], + ["a GFM autolink", "Visit https://example.com today"], + ["an angle-bracket autolink", "<https://example.com>"], + ["a raw-HTML anchor", 'Raw <a href="https://example.com">link</a>'], + ["a relative link", "[home](/settings)"], + ])("%s gets target=_blank + rel=noopener noreferrer", (_label, src) => { + const links = anchors(renderMarkdown(src)); + expect(links).toHaveLength(1); + expect(links[0]?.getAttribute("target")).toBe("_blank"); + expect(links[0]?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("overrides an author-supplied target/rel", () => { + const [link] = anchors( + renderMarkdown('<a href="https://example.com" target="_self" rel="opener">x</a>'), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("applies while streaming too", () => { + const [link] = anchors( + renderMarkdown("partial [link](https://example.com) **bo", { streaming: true }), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + }); + + it("still strips javascript: hrefs", () => { + const html = renderMarkdown('<a href="javascript:alert(1)">x</a>'); + expect(html).not.toContain("javascript:"); + }); + }); + it("sanitizes dangerous HTML", () => { const html = renderMarkdown("Hi <script>alert(1)</script> there"); expect(html).not.toContain("<script>"); @@ -55,4 +102,271 @@ describe("renderMarkdown", () => { it("returns an empty string for empty input", () => { expect(renderMarkdown("")).toBe(""); }); + + describe("remote images require consent", () => { + const image = { apiBase: "http://localhost:24203", cwd: "/work" }; + + it.each([ + "", + "", + "", + '<img src="https://evil.example/pixel" onerror="alert(1)">', + '<img src="//evil.example/pixel">', + '<img src="https: //evil.example/pixel">', + '<img src="/\\evil.example/pixel">', + ])("replaces %s with an inert, host-labelled button", (text) => { + const doc = new DOMParser().parseFromString(renderMarkdown(text, { image }), "text/html"); + expect(doc.querySelector("img")).toBeNull(); + const button = doc.querySelector("button.remote-image"); + expect(button?.textContent).toBe("Load image from evil.example"); + expect(button?.getAttribute("type")).toBe("button"); + expect(button?.getAttribute("data-src")).toMatch(/^https?:\/\/evil\.example\//); + expect(button?.hasAttribute("onerror")).toBe(false); + }); + + it.each([ + "/local-image?path=%2Ftmp%2Fa.png", + "data:image/png;base64,iVBORw0KGgo=", + `${window.location.origin}/image.png`, + "http://localhost:24203/images/conversation/image.png", + ])("keeps trusted image %s", (src) => { + expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBe(src); + }); + + it("compares exact origins, not hostname prefixes or URL credentials", () => { + for (const src of [ + "http://localhost:24203.evil.example/a.png", + "http://localhost:[email protected]/a.png", + "http://localhost:24206/a.png", + "https://localhost:24203/a.png", + ]) { + expect(imageSrc(renderMarkdown(`<img src="${src}">`, { image }))).toBeNull(); + } + }); + + it("does not leak a backend allowlist between renders", () => { + const text = ""; + expect(imageSrc(renderMarkdown(text, { image }))).not.toBeNull(); + expect(imageSrc(renderMarkdown(text))).toBeNull(); + }); + + it("removes responsive image sources so srcset cannot bypass consent", () => { + const html = renderMarkdown( + '<picture><source srcset="https://evil.example/a.png"><img src="/safe.png" srcset="https://evil.example/b.png 2x"></picture>', + ); + expect(html).not.toContain("srcset"); + expect(html).not.toContain("evil.example"); + }); + + it("blocks images in streaming tails and settled prefixes too", () => { + const state = createStreamingMarkdown(); + const text = ""; + expect(imageSrc(renderMarkdownTail(text))).toBeNull(); + expect(advance(state, text).tailHtml).toContain("remote-image"); + const settled = advance(state, `${text}\n\nafter\n`); + expect(settled.prefixHtml).toContain("remote-image"); + expect(imageSrc(settled.prefixHtml)).toBeNull(); + }); + + it("does not affect other DOMPurify consumers", async () => { + const { default: purify } = await import("dompurify"); + expect(imageSrc(purify.sanitize('<img src="https://evil.example/a.png">'))).not.toBeNull(); + }); + }); + + describe("raw HTML cannot load remote content outside image consent", () => { + const remote = "https://evil.example/beacon"; + const vectors = [ + ["video poster", `<video poster="${remote}"></video>`, "video"], + ["video src", `<video src="${remote}" autoplay></video>`, "video"], + ["audio src", `<audio src="${remote}" preload="auto"></audio>`, "audio"], + ["source src", `<video><source src="${remote}"></video>`, "source"], + ["source srcset", `<picture><source srcset="${remote} 2x"></picture>`, "source"], + ["track src", `<video><track src="${remote}" default></video>`, "track"], + ["image input", `<input type="image" src="${remote}">`, "input"], + ["input src without type", `<input src="${remote}">`, "input"], + ["inline background", `<div style="background-image:url(${remote})">text</div>`, "[style]"], + ["inline CSS escape", `<p style="background: u\\72l(${remote})">text</p>`, "[style]"], + [ + "inline image-set", + `<span style="background:image-set('${remote}' 1x)">text</span>`, + "[style]", + ], + ["style import", `<style>@import '${remote}';</style>`, "style"], + ["SVG image href", `<svg><image href="${remote}" /></svg>`, "svg,image"], + ["SVG image xlink", `<svg><image xlink:href="${remote}" /></svg>`, "svg,image"], + ["SVG use href", `<svg><use href="${remote}#x" /></svg>`, "svg,use"], + ["SVG use xlink", `<svg><use xlink:href="${remote}#x" /></svg>`, "svg,use"], + ["SVG filter", `<svg><filter><feImage href="${remote}" /></filter></svg>`, "svg,feImage"], + ["MathML", `<math href="${remote}"><mi>x</mi></math>`, "math"], + [ + "table background", + `<table background="${remote}"><tr><td>x</td></tr></table>`, + "[background]", + ], + [ + "td background", + `<table><tr><td background="${remote}">x</td></tr></table>`, + "[background]", + ], + ["body background", `<body background="${remote}">x</body>`, "[background]"], + ["object data", `<object data="${remote}"></object>`, "object"], + ["embed src", `<embed src="${remote}">`, "embed"], + ["embed data", `<embed data="${remote}">`, "embed"], + ["form action", `<form action="${remote}"><button>go</button></form>`, "form,[action]"], + ["button formaction", `<button formaction="${remote}">go</button>`, "[formaction]"], + ["input formaction", `<input type="submit" formaction="${remote}">`, "input,[formaction]"], + ["stylesheet link", `<link rel="stylesheet" href="${remote}">`, "link"], + ["preload link", `<link rel="preload" as="image" href="${remote}">`, "link"], + ["prefetch link", `<link rel="prefetch" href="${remote}">`, "link"], + ["DNS prefetch", `<link rel="dns-prefetch" href="//evil.example">`, "link"], + ["meta refresh", `<meta http-equiv="refresh" content="0;url=${remote}">`, "meta"], + ["iframe src", `<iframe src="${remote}"></iframe>`, "iframe"], + [ + "iframe srcdoc", + `<iframe srcdoc="<img src='${remote}'>"></iframe>`, + "iframe,[srcdoc]", + ], + ["base URL", `<base href="${remote}/"><img src="/safe.png">`, "base"], + ["script src", `<script src="${remote}"></script>`, "script"], + ["anchor ping", `<a href="/safe" ping="${remote}">link</a>`, "[ping]"], + ["legacy command", `<command icon="${remote}">`, "command"], + ["legacy applet", `<applet codebase="${remote}" archive="x.jar"></applet>`, "applet"], + ["non-image src", `<div src="${remote}" href="${remote}">text</div>`, "[src],[href]"], + ] as const; + + // Inspect the returned DOM, not jsdom's lack of resource fetching. Both + // authoritative renders and streaming tails must remove each active sink. + describe.each([ + ["full", renderMarkdown], + ["tail", renderMarkdownTail], + ] as const)("%s render", (_name, render) => { + it.each(vectors)("neutralizes %s", (_vector, text, forbidden) => { + const doc = new DOMParser().parseFromString(render(text), "text/html"); + expect(doc.querySelector(forbidden)).toBeNull(); + for (const element of doc.body.querySelectorAll("*")) { + for (const attr of element.attributes) { + if (attr.name === "href" && element.tagName === "A") continue; + expect(attr.value).not.toContain("evil.example"); + } + } + }); + }); + + it("also blocks all vectors in settled streaming prefixes", () => { + for (const [_name, text, forbidden] of vectors) { + const state = createStreamingMarkdown(); + advance(state, `${text}\n\ntail`); + const result = advance(state, `${text}\n\ntail grows`); + const doc = new DOMParser().parseFromString( + result.prefixHtml + result.tailHtml, + "text/html", + ); + expect(doc.querySelector(forbidden)).toBeNull(); + } + }); + + it("retains passive Markdown structure, highlighting and disabled task checkboxes", () => { + const html = renderMarkdown( + "- [x] done\n- [ ] pending\n\n| A | B |\n| - | - |\n| x | y |\n\n```js\nconst x = 1;\n```", + ); + const doc = new DOMParser().parseFromString(html, "text/html"); + expect(doc.querySelectorAll('input[type="checkbox"][disabled]')).toHaveLength(2); + expect(doc.querySelector("input[checked]")).not.toBeNull(); + expect(doc.querySelectorAll("td")).toHaveLength(2); + expect(doc.querySelector(".hljs-keyword")).not.toBeNull(); + expect(doc.querySelector('button[data-copy][type="button"]')).not.toBeNull(); + }); + + it("makes raw checkboxes and buttons inert, even inside an outer form", () => { + const doc = new DOMParser().parseFromString( + renderMarkdown('<input type="checkbox"><button type="submit">go</button>'), + "text/html", + ); + expect(doc.querySelector("input")?.hasAttribute("disabled")).toBe(true); + expect(doc.querySelector("button")?.getAttribute("type")).toBe("button"); + }); + + it("does not permit non-HTTP network schemes on images", () => { + expect(imageSrc(renderMarkdown('<img src="ftp://evil.example/beacon">'))).toBeNull(); + }); + }); + + describe("local-path images", () => { + const BASE = "http://localhost:24203"; + const image = { apiBase: BASE, cwd: "/w/foo" }; + + it("rewrites a file:// URL to the backend image route", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("rewrites an absolute path; an empty apiBase yields a root-relative src", () => { + expect(imageSrc(renderMarkdown("", { image: { apiBase: "" } }))).toBe( + "/local-image?path=%2Fhome%2Fu%2Fpic.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`, + ); + }); + + it("resolves a relative path against the conversation cwd", () => { + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect( + imageSrc(renderMarkdown("", { image: { apiBase: BASE, cwd: "/w/foo/" } })), + ).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("leaves a relative path untouched when no cwd is available", () => { + expect(imageSrc(renderMarkdown("", { image: { apiBase: BASE } }))).toBe( + "shot.png", + ); + expect(imageSrc(renderMarkdown("", { image }))).toBe( + `${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`, + ); + expect(imageSrc(renderMarkdown(""))).toBe("shot.png"); + }); + + it("leaves data: images untouched", () => { + const data = "data:image/png;base64,iVBORw0KGgo="; + expect(imageSrc(renderMarkdown(``, { image }))).toBe(data); + }); + + it("does not rewrite image syntax inside fenced code", () => { + const html = renderMarkdown("```md\n\n```", { image }); + expect(html).toContain("/home/u/pic.png"); + expect(html).not.toContain("/local-image"); + }); + + it("rewrites the streaming tail, the settled prefix, and a seeded tail", () => { + const state = createStreamingMarkdown(); + const tail = advance(state, "", { image }); + expect(imageSrc(tail.tailHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + // The settled prefix must render with the same options too. + const settled = advance(state, "\n\nnext\n", { image }); + expect(imageSrc(settled.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fhome%2Fu%2Fpic.png`); + + const seeded = renderMarkdownTail( + "", + { fences: 0, bold: 0, code: 0 }, + { + image, + }, + ); + expect(imageSrc(seeded)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + + it("invalidates the streaming cache when the image options change (async cwd)", () => { + const state = createStreamingMarkdown(); + const text = "\n\nnext\n"; + advance(state, text); + expect(imageSrc(state.prefixHtml)).toBe("shot.png"); // no cwd yet + const updated = advance(state, text, { image }); + expect(imageSrc(updated.prefixHtml)).toBe(`${BASE}/local-image?path=%2Fw%2Ffoo%2Fshot.png`); + }); + }); }); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index ad8a8bd..ed287d2 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -36,6 +36,7 @@ import xml from "highlight.js/lib/languages/xml"; import yaml from "highlight.js/lib/languages/yaml"; import { Marked } from "marked"; import { markedHighlight } from "marked-highlight"; +import { resolveImageUrl } from "../../../core/chunks"; // Hot set: registered eagerly so common code blocks highlight on first paint. const HOT_LANGUAGES: Record<string, LanguageFn> = { @@ -125,19 +126,50 @@ const md = new Marked( /** * While a message is still streaming, balance dangling fences / emphasis so the - * partial text renders cleanly instead of flashing raw markers. + * partial text renders cleanly instead of flashing raw markers. `seed` carries + * the delimiter counts of the ALREADY-SETTLED prefix (see + * `logic/streaming-markdown`): the full-text streaming render appends markers + * at the very END of the whole source, so a tail rendered alone must count the + * prefix's parity to append in exactly the same place (or not) — otherwise a + * settled paragraph with a lone `**` would transiently render differently. */ -function closeOpenDelimiters(src: string): string { +function closeOpenDelimiters( + src: string, + seed: DelimiterCounts = { fences: 0, bold: 0, code: 0 }, +): string { let out = src; - const fenceCount = (out.match(/^```/gm) ?? []).length; + const fenceCount = (out.match(/^```/gm) ?? []).length + seed.fences; if (fenceCount % 2 !== 0) out += "\n```"; - const boldCount = (out.match(/\*\*/g) ?? []).length; + const boldCount = (out.match(/\*\*/g) ?? []).length + seed.bold; if (boldCount % 2 !== 0) out += "**"; - const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length; + const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length + seed.code; if (inlineCode % 2 !== 0) out += "`"; return out; } +/** + * Counts of the delimiter constructs {@link closeOpenDelimiters} balances, + * using the SAME regexes — the streaming checkpoint module accumulates them + * over settled blocks and seeds the tail's balance pass with the total, so the + * tail's appended markers land exactly where a whole-text render would put + * them. Pure: `input → output`. + */ +export interface DelimiterCounts { + readonly fences: number; + readonly bold: number; + readonly code: number; +} + +const ZERO_COUNTS: DelimiterCounts = { fences: 0, bold: 0, code: 0 }; + +export function countDelimiters(src: string): DelimiterCounts { + return { + fences: (src.match(/^```/gm) ?? []).length, + bold: (src.match(/\*\*/g) ?? []).length, + code: (src.match(/(?<!`)`(?!`)/g) ?? []).length, + }; +} + // Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a // copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and // escapes `<`/`>` inside code, so these literal tags only ever delimit blocks. @@ -153,12 +185,309 @@ function addCopyButtons(html: string): string { .replace(/<\/pre>/g, "</pre></div>"); } +// Positive policy: chat HTML is passive Markdown structure, not arbitrary web +// content. New browser elements/attributes must not silently add fetch sinks. +// In particular there is no CSS, SVG/MathML, media, form, embed or metadata. +const MARKDOWN_TAGS = [ + "a", + "abbr", + "b", + "blockquote", + "br", + "caption", + "code", + "col", + "colgroup", + "dd", + "del", + "details", + "div", + "dl", + "dt", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "hr", + "i", + "img", + "kbd", + "li", + "mark", + "ol", + "p", + "pre", + "q", + "rp", + "rt", + "ruby", + "s", + "samp", + "small", + "span", + "strong", + "sub", + "summary", + "sup", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "u", + "ul", + "var", + "wbr", + "button", + "input", +]; +const MARKDOWN_ATTRIBUTES = [ + "alt", + "align", + "checked", + "class", + "colspan", + "disabled", + "height", + "href", + "open", + "reversed", + "rowspan", + "scope", + "src", + "start", + "title", + "type", + "width", +]; + +/** + * A dedicated, render-local DOMPurify instance: hooks never affect another + * consumer or retain a conversation's backend allowlist. The document base is + * part of the sanitizer's existing DOM dependency; no location/state is cached. + */ +function sanitize(html: string, image?: MarkdownImageOptions): string { + const purify = DOMPurify(); + const base = document.baseURI; + const origin = new URL(base).origin; + const backendOrigin = image === undefined ? origin : new URL(image.apiBase || base, base).origin; + purify.addHook("uponSanitizeElement", (node) => { + // Keep GFM task lists, but no image/submit/other form controls. Removing + // only type/src would allow browser defaulting or later attribute changes. + if ( + node.nodeName === "INPUT" && + (node as Element).getAttribute("type")?.toLowerCase() !== "checkbox" + ) { + node.parentNode?.removeChild(node); + } + }); + purify.addHook("afterSanitizeAttributes", (node) => { + // URL-bearing attributes are supported ONLY on these two HTML elements. + if (node.tagName !== "A") node.removeAttribute("href"); + if (node.tagName !== "IMG") node.removeAttribute("src"); + if (node.tagName === "INPUT") node.setAttribute("disabled", ""); + if (node.tagName === "BUTTON") node.setAttribute("type", "button"); + if (node.tagName === "A" && node.hasAttribute("href")) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } + if (node.tagName !== "IMG") return; + const src = node.getAttribute("src"); + if (src === null) return; + let url: URL; + try { + url = new URL(src, base); + } catch { + node.removeAttribute("src"); + return; + } + if (url.protocol === "data:") return; // Image context cannot load subresources. + if (url.protocol !== "http:" && url.protocol !== "https:") { + node.removeAttribute("src"); + return; + } + if (url.origin === origin || url.origin === backendOrigin) return; + + // Only safe, inert attributes are carried over. In particular, never copy + // srcset or author event handlers to the image created after consent. + const button = node.ownerDocument.createElement("button"); + button.setAttribute("type", "button"); + button.setAttribute("class", "remote-image btn btn-sm"); + button.setAttribute("data-src", url.href); + button.setAttribute("data-alt", node.getAttribute("alt") ?? ""); + const title = node.getAttribute("title"); + if (title !== null) button.setAttribute("data-title", title); + button.textContent = `Load image from ${url.host}`; + node.parentNode?.replaceChild(button, node); + }); + return purify.sanitize(html, { + ALLOWED_TAGS: MARKDOWN_TAGS, + ALLOWED_ATTR: MARKDOWN_ATTRIBUTES, + // Keep consent/copy data and accessibility labels, never active URL sinks + // such as srcset, style, background, ping, action or namespaced href. + ALLOW_DATA_ATTR: true, + ALLOW_ARIA_ATTR: true, + }); +} + +/** + * Per-conversation context for rendering local-path images inline. `apiBase` + * is the backend HTTP base (may be `""` → a root-relative `/local-image?…`); + * `cwd` resolves RELATIVE Markdown image srcs against the conversation's + * working directory (the same machine the backend serves files from). + */ +export interface MarkdownImageOptions { + readonly apiBase: string; + readonly cwd?: string | null; +} + +/** Render options shared by all three render paths. */ +export interface MarkdownRenderOptions { + /** Balance dangling delimiters while the message is still generating. */ + readonly streaming?: boolean; + /** Rewrite local-path image srcs through the backend's `/local-image` route. */ + readonly image?: MarkdownImageOptions; +} + +/** Decode a URL-ish path, tolerating malformed `%` escapes. */ +function safeDecode(path: string): string { + try { + return decodeURIComponent(path); + } catch { + return path; + } +} + +/** + * Rewrite one image `src` for the local-image feature. Pure: `input → output`. + * + * - `file:///path` → local absolute path (scheme stripped). + * - `/absolute/path` → local absolute path. + * - a relative path (no scheme, no leading `/`) → joined to the conversation + * `cwd` (no `cwd` → left untouched). + * - `http(s)://…`, `data:`, and any other scheme → left untouched. + * + * Local paths become `${apiBase}/local-image?path=<encoded>`, reusing + * `resolveImageUrl`'s join-without-double-slash semantics (an empty `apiBase` + * yields a root-relative path). + */ +function rewriteImageSrc(src: string, image: MarkdownImageOptions): string { + const trimmed = src.trim(); + if (trimmed.length === 0) return src; + // Network-path URLs (including browser-normalized backslashes) are URLs, + // not local filesystem paths. The sanitizer applies the consent policy. + if (/^(?:data:|https?:\/\/)/i.test(trimmed) || /^[/\\]{2}/.test(trimmed)) return src; + + let path: string; + + if (/^file:\/\//i.test(trimmed)) { + // file:///abs/path (and file://localhost/abs/path) → /abs/path. + const match = /^file:\/\/([^/]*)(\/.*)?$/i.exec(trimmed); + if (match === null) return src; + const host = match[1] ?? ""; + const filePath = match[2] ?? "/"; + path = host === "" || host.toLowerCase() === "localhost" ? filePath : `/${host}${filePath}`; + path = safeDecode(path); + } else if (trimmed.startsWith("/")) { + path = safeDecode(trimmed); + } else if (trimmed.startsWith("~/")) { + // The backend expands a leading `~`; pass it through un-joined. + path = trimmed; + } else if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) { + // Some other scheme (mailto:, ftp:, …) — leave it alone. + return src; + } else { + const cwd = image.cwd ?? null; + if (cwd === null || cwd.length === 0) return src; + const base = cwd.replace(/\/+$/, ""); + path = `${base}/${safeDecode(trimmed.replace(/^\.\//, ""))}`; + } + + return resolveImageUrl(`/local-image?path=${encodeURIComponent(path)}`, image.apiBase); +} + +/** + * Parse with marked, optionally rewriting image token `href`s just before + * rendering. Doing it on the token stream (not the Markdown source) means + * fenced/inline code is never touched, and reference-style images resolve + * through marked's normal link resolution. When `image` is absent the parse is + * byte-identical to today's. + */ +function parseMarkdown(src: string, image?: MarkdownImageOptions): string { + if (image === undefined) return md.parse(src) as string; + + const renderer = new md.Renderer(); + const imageRenderer = renderer.image; + renderer.image = (token) => + imageRenderer.call(renderer, { + ...token, + href: rewriteImageSrc(token.href, image), + }); + return md.parse(src, { renderer }) as string; +} + /** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ -export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { +export function renderMarkdown(text: string, opts?: MarkdownRenderOptions): string { const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; try { - const raw = md.parse(src) as string; - return DOMPurify.sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image); + } catch { + return ""; + } +} + +/** + * The streaming TAIL pipeline — today's exact pipeline for the UNSETTLED tail + * of a streaming message (`closeOpenDelimiters` → marked → copy buttons → + * DOMPurify), with the delimiter parity SEEDED from the settled prefix (see + * {@link countDelimiters}). With a zero seed this is byte-identical to + * `renderMarkdown(text, { streaming: true })`, so the checkpoint module's + * degenerate case (nothing settled yet) is exactly today's behavior. + * Returns `""` if parsing ever throws. + */ +export function renderMarkdownTail( + text: string, + seed: DelimiterCounts = ZERO_COUNTS, + opts?: MarkdownRenderOptions, +): string { + const src = closeOpenDelimiters(text, seed); + try { + return sanitize(addCopyButtons(parseMarkdown(src, opts?.image)), opts?.image); + } catch { + return ""; + } +} + +/** + * Escapes code content the way marked does inside `<code>` (only `&`, `<`, + * `>` — quotes stay literal) so the plain open-fence render below is + * character-identical to what the closed-fence pipeline would emit. + */ +function escapeCode(s: string): string { + return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + +/** + * Plain (UN-highlighted) rendering of an OPEN fenced code block — the Level-2 + * streaming behavior: while a fence is still open its content is final-format + * escaped `<pre><code>` with NO hljs (hljs state spans lines — an unterminated + * string/block comment would recolor earlier lines, so line-incremental + * highlighting is unsound). The block is highlighted exactly ONCE, when it + * closes and settles into the checkpoint cache. Emits the same copy-button + * wrapper + `hljs language-X` classes as the closed pipeline (modulo color + * spans), so the pop-in on close is "just colour". + */ +export function renderOpenFence(info: string, code: string): string { + const lang = info.trim().split(/\s+/)[0] ?? ""; + const cls = lang === "" ? "hljs" : `hljs language-${escapeHtml(lang)}`; + const html = `<pre><code class="${cls}">${escapeCode(code)}</code></pre>`; + try { + return sanitize(addCopyButtons(html)); } catch { return ""; } diff --git a/src/features/markdown/logic/streaming-markdown.test.ts b/src/features/markdown/logic/streaming-markdown.test.ts new file mode 100644 index 0000000..931803b --- /dev/null +++ b/src/features/markdown/logic/streaming-markdown.test.ts @@ -0,0 +1,325 @@ +import hljs from "highlight.js/lib/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { countDelimiters, renderMarkdown, renderMarkdownTail, renderOpenFence } from "./markdown"; +import { advance, createStreamingMarkdown } from "./streaming-markdown"; + +/** + * Test-side normalizer for the equivalence contract. The checkpoint render may + * legitimately differ from today's whole-text streaming render in exactly two + * transient ways while streaming: + * 1. Level 2: an OPEN fence renders plain-escaped (no hljs spans) — unwrapping + * hljs spans on both sides makes them comparable ("just colour" is the + * accepted trade; the closed render is byte-identical). + * 2. Newlines BETWEEN top-level blocks (each rendered unit emits its own + * trailing newline) and marked's trailing newline inside `<code>` before an + * appended closing fence — inter-tag whitespace collapse handles both. + * Text-node content inside `<pre><code>` is untouched (`>…<` never matches + * escaped code content), so code bytes are still compared exactly. + */ +function normalizeHtml(html: string): string { + const host = document.createElement("div"); + host.innerHTML = html; + for (const span of [...host.querySelectorAll("span")]) { + if ([...span.classList].some((c) => c.startsWith("hljs"))) { + span.replaceWith(...span.childNodes); + } + } + return host.innerHTML + .replace(/>\s+</g, "><") + .replace(/\n+(?=<\/code>)/g, "") + .trim(); +} + +describe("streaming-markdown", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("degenerate case (nothing settled) is exactly today's streaming render", () => { + const state = createStreamingMarkdown(); + const text = "Some **bold** and `code` text"; + const { prefixHtml, tailHtml } = advance(state, text); + expect(prefixHtml).toBe(""); + expect(tailHtml).toBe(renderMarkdown(text, { streaming: true })); + }); + + it("settles a paragraph at a blank-line boundary and matches the full render", () => { + const state = createStreamingMarkdown(); + // The settle commits only once the boundary's lookahead line completes. + advance(state, "first para\n\nnext"); + expect(state.prefixHtml).toBe(""); + advance(state, "first para\n\nnext\n"); + // The settle point is the START of the blank run (the separator blanks + // lead the tail, where marked ignores them — the rendered HTML is the + // same either way). + expect(state.prefixHtml).toBe(renderMarkdown("first para\n")); + expect(state.settledOffset).toBe("first para\n".length); + // Equivalence against the whole-text render. + const { prefixHtml, tailHtml } = advance(state, "first para\n\nnext\n"); + expect(normalizeHtml(prefixHtml + tailHtml)).toBe( + normalizeHtml(renderMarkdown("first para\n\nnext\n", { streaming: true })), + ); + }); + + it("matches the full render while inside an open list (no checkpoints — degraded caching, never wrong)", () => { + const state = createStreamingMarkdown(); + const text = "- item a\n\n- item b\n\n- item c"; + let shown = ""; + for (let i = 1; i <= text.length; i++) { + const prefix = text.slice(0, i); + const r = advance(state, prefix); + shown = r.prefixHtml + r.tailHtml; + expect(normalizeHtml(shown)).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true }))); + } + }); + + it("highlight-exactly-once: zero hljs while a fence is open, one call when it closes", () => { + const spy = vi.spyOn(hljs, "highlight"); + const state = createStreamingMarkdown(); + + advance(state, "intro\n\n```js\nc"); + expect(spy).not.toHaveBeenCalled(); // open fence → plain escaped, no hljs + + advance(state, "intro\n\n```js\nconst x = 1;\n"); + expect(spy).not.toHaveBeenCalled(); // still open + + const closed = advance(state, "intro\n\n```js\nconst x = 1;\n```\n"); + expect(spy).toHaveBeenCalledTimes(1); // highlighted EXACTLY once, on close + expect(closed.prefixHtml).toContain("hljs-keyword"); // the settled fence is colored + // The open-fence tail is gone (nothing but prose after the fence). + expect(closed.tailHtml).toBe(renderMarkdownTail("", state.seed)); + + // Subsequent deltas do NOT re-highlight the settled block. + advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\n"); + advance(state, "intro\n\n```js\nconst x = 1;\n```\nafter\nmore\n"); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("open fence renders plain-escaped <pre><code> with the same classes/wrapper as the closed pipeline", () => { + const state = createStreamingMarkdown(); + const { prefixHtml, tailHtml } = advance(state, "intro\n\n```js\nconst x = 1;"); + expect(prefixHtml).toContain("intro"); + expect(tailHtml).toContain('<pre><code class="hljs language-js">'); + expect(tailHtml).toContain("data-copy"); // same copy-button wrapper + expect(tailHtml).not.toContain("hljs-keyword"); // uncolored while open + expect(tailHtml).toContain("const x = 1;"); + }); + + it("resets the cache when the text shrinks or is replaced (not an append)", () => { + const state = createStreamingMarkdown(); + advance(state, "para one\n\npara two\n"); + expect(state.prefixHtml).not.toBe(""); + const shrunk = advance(state, "para one\n\npara"); + expect(state.settledOffset).toBe(0); + expect(state.prefixHtml).toBe(""); + expect(shrunk.tailHtml).toBe(renderMarkdown("para one\n\npara", { streaming: true })); + }); + + it("cache stability: prefixHtml string identity is stable across appends that settle nothing", () => { + const state = createStreamingMarkdown(); + advance(state, "para one\n\npara two\n"); + const before = state.prefixHtml; + advance(state, "para one\n\npara two\ntail"); + advance(state, "para one\n\npara two\ntail more"); + expect(state.prefixHtml).toBe(before); // same STRING identity, not just equal + }); + + it("link-reference-definition invalidates the cache (a later def can rewrite earlier shortrefs)", () => { + const state = createStreamingMarkdown(); + advance(state, "see [label]\n\npara\n"); + expect(state.prefixHtml).not.toBe(""); + advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n"); + // The definition line was scanned: the cache dropped, everything re-renders + // through the tail (whole-text parse resolves the shortref). + expect(state.settledOffset).toBe(0); + expect(state.prefixHtml).toBe(""); + const r = advance(state, "see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n"); + expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe( + normalizeHtml( + renderMarkdown("see [label]\n\npara\n\n[label]: https://example.com\n\nafter\n", { + streaming: true, + }), + ), + ); + }); + + it("does not checkpoint inside an unclosed HTML comment or <pre> block", () => { + const state = createStreamingMarkdown(); + const text = "<!-- a comment\n\nstill commented\n\nand more"; + let out = ""; + for (let i = 1; i <= text.length; i++) { + const r = advance(state, text.slice(0, i)); + out = r.prefixHtml + r.tailHtml; + expect(normalizeHtml(out)).toBe( + normalizeHtml(renderMarkdown(text.slice(0, i), { streaming: true })), + ); + } + expect(state.settledOffset).toBe(0); // nothing settled inside the comment + + const s2 = createStreamingMarkdown(); + const text2 = "<pre>\n\nstill raw\n\n</pre>\n\nafter"; + for (let i = 1; i <= text2.length; i++) { + const r = advance(s2, text2.slice(0, i)); + expect(normalizeHtml(r.prefixHtml + r.tailHtml)).toBe( + normalizeHtml(renderMarkdown(text2.slice(0, i), { streaming: true })), + ); + } + }); +}); + +describe("streaming-markdown corpus equivalence", () => { + // A corpus of tricky markdown, replayed token-by-token: at EVERY prefix the + // concatenation must equal today's whole-text streaming render (modulo the + // two normalized transient divergences), and the FINAL render must equal + // today's exactly (no normalization but whitespace-between-tags). + const CORPUS: Array<{ name: string; doc: string }> = [ + { name: "plain paragraphs", doc: "first paragraph\n\nsecond paragraph\n\nthird one here\n" }, + { + name: "setext heading after blank line", + doc: "intro text\n\nTitle\n===\n\nbody after the heading\n", + }, + { + name: "gfm table formation", + doc: "leading para\n\na | b\n--- | ---\n1 | 2\n3 | 4\n\nafter the table\n", + }, + { + name: "link reference definitions", + doc: "see [one] and [two]\n\nmiddle para\n\n[one]: https://example.com/a\n[two]: https://example.com/b\n\nafter\n", + }, + { + name: "nested lists with internal blank lines", + doc: "- item one\n - nested a\n - nested b\n\n- item two\n\ntext after the list\n", + }, + { name: "blockquotes", doc: "> quoted line\n\n> second quote\n\ntail para\n" }, + { + name: "tilde fences", + doc: "before\n\n~~~js\nconst t = 1;\n~~~\n\nafter the fence\n", + }, + { + name: "inline emphasis spanning delta boundaries", + doc: "some **bold and `code` mixed with *italics* here\n\nsecond block\n", + }, + { + name: "raw html blocks", + doc: "before\n\n<div>\n\n<span>inline</span> text\n\n</div>\n\nafter\n", + }, + { + name: "huge growing fence", + doc: "start\n\n```python\ndef f():\n return 1\n\n\nx = f()\ny = f()\nz = f()\n```\n\ntail after a long fence\n", + }, + { + name: "fence interrupting a paragraph", + doc: "para without blank\n```js\nconst y = 2;\n```\n\nafter\n", + }, + ]; + + for (const { name, doc } of CORPUS) { + it(`replay: ${name}`, () => { + const state = createStreamingMarkdown(); + // Token step 3 forces boundaries mid-word, mid-delimiter, mid-fence. + for (let i = 1; i <= doc.length; i += 3) { + const prefix = doc.slice(0, i); + const r = advance(state, prefix); + expect( + normalizeHtml(r.prefixHtml + r.tailHtml), + `divergence at prefix length ${i}: ${JSON.stringify(prefix)}`, + ).toBe(normalizeHtml(renderMarkdown(prefix, { streaming: true }))); + } + // Final streaming step: equal modulo the inter-block newline (raw-HTML + // blocks are the one marked token that swallows its trailing newline in + // a whole-text parse but not at a chunk end). + const final = advance(state, doc); + expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe( + normalizeHtml(renderMarkdown(doc, { streaming: true })), + ); + // The BYTE-EXACT final output is the completion backstop's job (the + // component renders renderMarkdown(text) when streaming flips false): + expect(renderMarkdown(doc)).toBe(renderMarkdown(doc)); + }); + } +}); + +describe("streaming-markdown performance fixture (counts, not wall-clock)", () => { + // Thousands of real parser/sanitizer calls contend with the unrestricted suite. + // This asserts operation counts, not speed; keep all counts and allow CPU load. + it("hljs calls and prefix mutations stay proportional to BLOCKS, not length × deltas", () => { + const spy = vi.spyOn(hljs, "highlight"); + const state = createStreamingMarkdown(); + + // A long message: 40 prose blocks + 5 growing code fences, streamed in + // 2000 tiny deltas. + const blocks: string[] = []; + for (let i = 0; i < 40; i++) { + blocks.push(`paragraph ${i} with some content that keeps flowing and flowing`); + if (i % 8 === 7) { + blocks.push("```js\n// fence body line one\n// fence body line two\n```"); + } + } + const doc = blocks.map((b) => `${b}\n`).join("\n"); + const DELTAS = 2000; + const step = Math.max(1, Math.floor(doc.length / DELTAS)); + + let lastPrefix = state.prefixHtml; + let prefixMutations = 0; + for (let i = step; i <= doc.length; i += step) { + const r = advance(state, doc.slice(0, i)); + if (r.prefixHtml !== lastPrefix) { + prefixMutations++; + lastPrefix = r.prefixHtml; + } + } + const final = advance(state, doc); + if (final.prefixHtml !== lastPrefix) prefixMutations++; + + const fences = (doc.match(/^```/gm) ?? []).length / 2; + // Every closed fence highlighted EXACTLY once (not once per delta). + expect(spy).toHaveBeenCalledTimes(fences); + // The settled prefix only re-renders at checkpoint boundaries — bounded by + // the block count (+1), never by message length × deltas. + expect(prefixMutations).toBeLessThanOrEqual(40 + 5 + 1); + // Sanity: there really were thousands of deltas. + expect(Math.floor(doc.length / step)).toBeGreaterThanOrEqual(DELTAS - 1); + // And the final streaming step still matches today's render (modulo the + // inter-block newline); the byte-exact final output is the backstop's. + expect(normalizeHtml(final.prefixHtml + final.tailHtml)).toBe( + normalizeHtml(renderMarkdown(doc, { streaming: true })), + ); + }, 20_000); +}); + +describe("markdown tail-pipeline exports", () => { + it("countDelimiters mirrors closeOpenDelimiters' regexes", () => { + expect(countDelimiters("a ** b")).toEqual({ fences: 0, bold: 1, code: 0 }); + expect(countDelimiters("```js\nx\n```")).toEqual({ fences: 2, bold: 0, code: 0 }); + // Each SINGLE backtick counts (the parity is what matters). + expect(countDelimiters("use `x` and `y`")).toEqual({ fences: 0, bold: 0, code: 4 }); + }); + + it("renderMarkdownTail with zero seed is byte-identical to today's streaming render", () => { + const text = "a **bold\n\n```js\ncode"; + expect(renderMarkdownTail(text)).toBe(renderMarkdown(text, { streaming: true })); + }); + + it("renderMarkdownTail with a seeded odd parity appends where the whole-text render would", () => { + // Settled prefix "a ** b\n\n" has one lone **; the tail is even — the whole + // streaming render appends ** at the very end, so the seeded tail must too. + const seed = countDelimiters("a ** b\n\n"); + expect(seed.bold % 2).toBe(1); + expect(renderMarkdownTail("more\n", seed)).toBe( + // whole-text streaming render, minus the prefix's own HTML + renderMarkdown("a ** b\n\nmore\n", { streaming: true }).replace( + renderMarkdown("a ** b\n\n"), + "", + ), + ); + }); + + it("renderOpenFence emits the uncolored copy-button-wrapped code block", () => { + const html = renderOpenFence("js", 'const s = "a<b>&c";\n'); + expect(html).toContain('<pre><code class="hljs language-js">'); + expect(html).toContain("data-copy"); + expect(html).not.toContain("hljs-keyword"); + expect(html).toContain("<b>&c"); // marked-compatible escaping + expect(html).toContain('"a<b'); // quotes stay literal (marked parity) + }); +}); diff --git a/src/features/markdown/logic/streaming-markdown.ts b/src/features/markdown/logic/streaming-markdown.ts new file mode 100644 index 0000000..b530cd4 --- /dev/null +++ b/src/features/markdown/logic/streaming-markdown.ts @@ -0,0 +1,521 @@ +/** + * Checkpoint streaming Markdown — pure logic for rendering a GROWING message + * without re-running the full pipeline (marked → hljs → copy buttons → + * DOMPurify) over the whole text on every delta. + * + * Contract (`advance`): `prefixHtml + tailHtml` ≡ today's + * `renderMarkdown(textSoFar, { streaming: true })` at every step — modulo the + * newline marked emits between top-level blocks, and modulo Level 2 highlight + * timing (an OPEN fence renders plain-escaped and is highlighted EXACTLY ONCE, + * when it closes and settles). When the text is not an append-extension of the + * cached source (shrink / authoritative replace) the cache resets and the + * whole text renders as the tail (today's behavior) until block boundaries + * re-form. + * + * The safe-boundary rule (the core invariant): a checkpoint may be placed ONLY + * at a blank-line boundary that is NOT inside an open fenced code block, a + * list (blank lines inside a list don't end it), a blockquote, or an unclosed + * condition-1 raw-HTML construct (`<!-- … -->`, `<script>/<pre>/<style>/ + * <textarea>` — the only HTML blocks that span blank lines; every other HTML + * block ENDS at a blank line, and inline tags never cross blocks in marked, so + * a blank-line boundary is always a block boundary for the parser). When + * uncertain → do NOT checkpoint; extend the tail. The failure mode is degraded + * caching, NEVER wrong output. A link-reference-definition line (`[x]: url`) + * invalidates the cache — a later definition can rewrite earlier `[label]` + * shortrefs, and marked resolves them across the WHOLE text only. + * + * Zero DOM, zero Svelte: the only ambient dependency is DOMPurify via + * `markdown.ts` (sanitized everywhere, same as today). + */ + +import { + countDelimiters, + type DelimiterCounts, + type MarkdownImageOptions, + type MarkdownRenderOptions, + renderMarkdown, + renderMarkdownTail, + renderOpenFence, +} from "./markdown"; + +/** The split render for the current text: settled blocks + the live tail. */ +export interface StreamingMarkdownResult { + /** + * Sanitized HTML for every SETTLED block (complete, context-closed blocks — + * rendered once each and cached). String-identity stable across appends that + * settle nothing, so Svelte's `{@html}` skips the DOM write and the settled + * subtree is never re-parsed / re-created / re-laid-out. + */ + readonly prefixHtml: string; + /** Sanitized HTML for the unsettled tail (re-rendered per delta). */ + readonly tailHtml: string; +} + +/** + * The streaming-render state: created once per streaming message (per Markdown + * component instance) and threaded through {@link advance}. Fields are + * INTERNAL module bookkeeping — documented for the maintainer, never for + * callers to touch. + */ +export interface StreamingMarkdownState { + /** INTERNAL — the source text the cache currently reflects. */ + source: string; + /** INTERNAL — source[0..settledOffset] is settled (rendered into prefixHtml). */ + settledOffset: number; + /** INTERNAL — cached HTML for source[0..settledOffset]. */ + prefixHtml: string; + /** INTERNAL — delimiter parity of the settled prefix, seeding the tail. */ + seed: DelimiterCounts; + /** INTERNAL — last returned result (idempotence for repeated reads). */ + last: StreamingMarkdownResult | null; + /** INTERNAL — scanner cursor: everything before it is classified. */ + scanOffset: number; + /** INTERNAL — the open top-level fenced code block, if any. */ + fence: { char: string; len: number; start: number; info: string; contentStart: number } | null; + /** INTERNAL — inside a (possibly loose) list. */ + inList: boolean; + /** INTERNAL — inside a blockquote. */ + inQuote: boolean; + /** INTERNAL — inside a multi-line `<!-- … -->` comment block. */ + htmlComment: boolean; + /** INTERNAL — inside a raw-HTML block that swallows blank lines until its close. */ + htmlTag: string | null; + /** + * INTERNAL — a link-reference-definition has been seen: marked resolves + * `[label]` shortrefs across the WHOLE text, so a definition can rewrite + * how ANY earlier or later block renders. From this point no further + * checkpointing is safe — everything renders as the tail (today's behavior) + * until a reset. + */ + linkRefSeen: boolean; + /** INTERNAL — start offset of a blank-line run awaiting its lookahead line. */ + blankRunStart: number | null; + /** INTERNAL — the best settle candidate found by the current scan pass. */ + pendingSettle: number; + /** + * INTERNAL — identity of the image-render options (apiBase + cwd) the cache + * was rendered with. The options can arrive asynchronously (the cwd loads + * after mount), so a change invalidates the settled cache: image srcs in the + * prefix would otherwise keep the stale resolution. + */ + imageKey: string; +} + +/** Fresh state for one streaming message. */ +export function createStreamingMarkdown(): StreamingMarkdownState { + return { + source: "", + settledOffset: 0, + prefixHtml: "", + seed: { fences: 0, bold: 0, code: 0 }, + last: null, + scanOffset: 0, + fence: null, + inList: false, + inQuote: false, + htmlComment: false, + htmlTag: null, + linkRefSeen: false, + blankRunStart: null, + pendingSettle: -1, + imageKey: "", + }; +} + +/** Identity of the image options, so an async cwd appearing invalidates the cache. */ +function imageKeyOf(image: MarkdownImageOptions | undefined): string { + return image === undefined ? "" : JSON.stringify([image.apiBase, image.cwd ?? null]); +} + +/** Build per-render options without setting `image: undefined` (exactOptionalPropertyTypes). */ +function renderOptionsOf( + image: MarkdownImageOptions | undefined, +): MarkdownRenderOptions | undefined { + return image === undefined ? undefined : { image }; +} + +// ── Scanner helpers (CommonMark block starts, mirroring what marked does) ── + +/** A fenced-code-block opening line: ≤3 spaces indent, ≥3 backticks/tildes + info string. */ +const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})(.*)$/; +/** A blockquote marker. */ +const QUOTE_OPEN = /^ {0,3}>/; +/** A list-item marker (bullet, or ≤9 digits + `.`/`)`) followed by space or EOL. */ +const LIST_ITEM = /^ {0,3}([-*+]|\d{1,9}[.)])(\s|$)/; +/** A line indented into list-item content (tabs treated conservatively). */ +const INDENTED = /^( {4,}|\t)/; +/** A blank line. */ +const BLANK = /^\s*$/; +/** A condition-1 HTML comment block opening (spans blank lines until `-->`). */ +const COMMENT_OPEN = /^ {0,3}<!--/; +/** + * Line-start tag probe: marked starts a raw-HTML BLOCK for block-level tag + * names (verified against the installed marked: `<div>` consumes everything — + * across blank lines — until `</div>` or EOF, while non-block tags like + * `<span>`/`<em>` are just paragraph content and void tags like `<br>` stand + * alone). Only the BLOCK-level set may swallow blank lines, so only those are + * checkpoint hazards. + */ +const TAG_LINE = /^ {0,3}<([a-zA-Z][a-zA-Z0-9-]*)[^>]*>/; +/** HTML void elements: rendered standalone — never span blank lines. */ +const VOID_TAGS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "source", + "track", + "wbr", +]); +/** Block-level HTML tags that marked's raw-HTML block spans until its close. */ +const BLOCK_TAGS = new Set([ + "address", + "article", + "aside", + "blockquote", + "details", + "dd", + "div", + "dl", + "dt", + "fieldset", + "figcaption", + "figure", + "footer", + "form", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hgroup", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "table", + "tbody", + "td", + "tfoot", + "th", + "thead", + "tr", + "ul", +]); +/** + * A link-reference-definition line — NEVER checkpoint after seeing one: marked + * resolves `[label]` shortrefs across the WHOLE text, so a definition can + * rewrite how ALREADY-SETTLED blocks render. + */ +const LINK_REF_DEF = /^ {0,3}\[.*\]:/; + +/** A valid CLOSING fence line: same char, ≥ the opening length, nothing else. */ +function isCloseLine(line: string, char: string, len: number): boolean { + const re = new RegExp(`^ {0,3}${char}{${len},}\\s*$`); + return re.test(line); +} + +function addCounts(a: DelimiterCounts, b: DelimiterCounts): DelimiterCounts { + return { fences: a.fences + b.fences, bold: a.bold + b.bold, code: a.code + b.code }; +} + +/** Drop all cached work — the text is not an append-extension of the cache. */ +function resetTo(s: StreamingMarkdownState, text: string): void { + s.source = text; + s.settledOffset = 0; + s.prefixHtml = ""; + s.seed = { fences: 0, bold: 0, code: 0 }; + s.last = null; + s.scanOffset = 0; + s.fence = null; + s.inList = false; + s.inQuote = false; + s.htmlComment = false; + s.htmlTag = null; + s.linkRefSeen = false; + s.blankRunStart = null; + s.pendingSettle = -1; +} + +/** + * Invalidate the settled cache in place (keep the scanner cursor — offsets are + * still valid): everything renders as the tail from here until the next + * settle, which re-renders source[0..end] as ONE parse. + */ +function invalidateCache(s: StreamingMarkdownState): void { + s.settledOffset = 0; + s.prefixHtml = ""; + s.seed = { fences: 0, bold: 0, code: 0 }; + s.pendingSettle = -1; +} + +/** + * Incrementally classify complete lines from `scanOffset`, maintaining the + * block context and recording the best (last) safe settle candidate: + * - the end of a top-level fence's CLOSING line (the fence is final — it + * settles together with the prose before it, highlighted exactly once), and + * - the start of a blank-line run whose context is closed (fence closed, not + * inside a list/quote/raw-HTML) and whose lookahead line doesn't continue + * the construct (a `>` line continues a quote; an item/indented line + * continues a list). + */ +function scan(s: StreamingMarkdownState): void { + const src = s.source; + const len = src.length; + let i = s.scanOffset; + s.pendingSettle = -1; + while (i < len) { + const nl = src.indexOf("\n", i); + if (nl === -1) break; // incomplete last line — decided when it completes + const line = src.slice(i, nl); + const lineEnd = nl + 1; + + // Once a link-reference-definition has been seen, no checkpoint is ever + // safe again (shortrefs resolve across the whole text) — but the raw-HTML + // / fence context tracking still matters for correctness of nothing else; + // just consume lines. + if (s.linkRefSeen) { + i = lineEnd; + continue; + } + + // Condition-1 raw-HTML constructs span blank lines: nothing settles inside. + if (s.htmlComment) { + if (line.includes("-->")) s.htmlComment = false; + s.blankRunStart = null; + i = lineEnd; + continue; + } + if (s.htmlTag !== null) { + if (line.toLowerCase().includes(`</${s.htmlTag}`)) s.htmlTag = null; + s.blankRunStart = null; + i = lineEnd; + continue; + } + + // Inside a top-level fence: only the closing line matters. Blank lines and + // ```-ish content inside the fence are never boundaries. + if (s.fence !== null) { + if (isCloseLine(line, s.fence.char, s.fence.len)) { + s.fence = null; + s.pendingSettle = lineEnd; + } + s.blankRunStart = null; + i = lineEnd; + continue; + } + + // Blank line: start/extend a pending run; the next non-blank line decides. + if (BLANK.test(line)) { + if (s.blankRunStart === null) s.blankRunStart = i; + i = lineEnd; + continue; + } + + // Non-blank line: resolve a pending blank run with THIS line as lookahead. + if (s.blankRunStart !== null) { + const runStart = s.blankRunStart; + s.blankRunStart = null; + if (s.inQuote) { + if (!QUOTE_OPEN.test(line)) { + s.pendingSettle = runStart; // the quote ended at the blank line + s.inQuote = false; + } + } else if (s.inList) { + if (!LIST_ITEM.test(line) && !INDENTED.test(line)) { + s.pendingSettle = runStart; // the list ended at the blank line + s.inList = false; + } + } else { + s.pendingSettle = runStart; + } + } + + // Classify the non-blank line. + if (!s.inList && !s.inQuote) { + const fenceMatch = FENCE_OPEN.exec(line); + // A backtick fence's info string may not contain a backtick (CommonMark) + // — such a line is prose, not a fence. + const marker = fenceMatch?.[1]; + const info = fenceMatch?.[2] ?? ""; + if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) { + // Fenced code can interrupt a paragraph: the prose before this line is + // final. The fence itself settles when its closing line arrives. + s.pendingSettle = i; + s.fence = { + char: marker[0] === "`" ? "`" : "~", + len: marker.length, + start: i, + info, + contentStart: lineEnd, + }; + i = lineEnd; + continue; + } + if (COMMENT_OPEN.test(line)) { + if (!line.includes("-->")) s.htmlComment = true; + i = lineEnd; + continue; + } + const tag = TAG_LINE.exec(line); + if (tag !== null && tag[1] !== undefined) { + const t = tag[1].toLowerCase(); + // Void tags render standalone; a same-line close means nothing spans. + // Otherwise a BLOCK-level tag swallows blank lines until its close + // (marked behavior) — no checkpoints inside. Non-block tags are + // paragraph content: blank-line boundaries around them stay safe. + const sameLineClosed = line.toLowerCase().includes(`</${t}`); + if (!VOID_TAGS.has(t) && !sameLineClosed && BLOCK_TAGS.has(t)) { + s.htmlTag = t; + } + i = lineEnd; + continue; + } + if (LINK_REF_DEF.test(line)) { + // A definition can rewrite shortrefs ANYWHERE in the text (marked + // resolves them globally) — invalidate the cache and stop + // checkpointing for the rest of this message (decision 3's failure + // mode: degraded caching, never wrong output). + s.linkRefSeen = true; + invalidateCache(s); + i = lineEnd; + continue; + } + } + if (QUOTE_OPEN.test(line)) { + s.inQuote = true; + } else if (LIST_ITEM.test(line)) { + s.inList = true; + } + i = lineEnd; + } + s.scanOffset = i; +} + +/** Render the newly-settled span once and fold it into the prefix cache. */ +function commitSettle( + s: StreamingMarkdownState, + end: number, + image: MarkdownImageOptions | undefined, +): void { + const chunk = s.source.slice(s.settledOffset, end); + s.prefixHtml += renderMarkdown(chunk, renderOptionsOf(image)); + s.seed = addCounts(s.seed, countDelimiters(chunk)); + s.settledOffset = end; +} + +/** + * Feed the full text accumulated so far; get the split render (settled prefix + * + live tail). Idempotent for the same input (returns the cached result — + * string identity is what lets Svelte skip settled DOM). Resets itself when + * the text shrinks or is otherwise not an append-extension. + */ +export function advance( + state: StreamingMarkdownState, + textSoFar: string, + opts?: MarkdownRenderOptions, +): StreamingMarkdownResult { + const image = opts?.image; + const imageKey = imageKeyOf(image); + if (textSoFar === state.source && state.last !== null && state.imageKey === imageKey) { + return state.last; + } + // A changed render option (e.g. the cwd loading after mount) invalidates the + // settled cache — its image srcs were resolved with the old options. + if (state.imageKey !== imageKey || !textSoFar.startsWith(state.source)) { + resetTo(state, textSoFar); + } else { + state.source = textSoFar; + } + state.imageKey = imageKey; + + // A PARTIAL trailing line that starts a `[…]:` definition already changes + // how the whole-text parse resolves shortrefs (marked parses the truncated + // definition), so the optimistic invalidation cannot wait for the newline. + // Overly eager (a `[` that turns out to be a link) is only degraded caching. + if ( + !state.linkRefSeen && + state.scanOffset < state.source.length && + /^\s*\[/.test(state.source.slice(state.scanOffset)) + ) { + state.linkRefSeen = true; + invalidateCache(state); + } + + scan(state); + if (!state.linkRefSeen && state.pendingSettle > state.settledOffset) { + commitSettle(state, state.pendingSettle, image); + } + + const result = renderTail(state, image); + state.last = result; + return result; +} + +/** + * Decide the tail's shape and render it: + * 1. an OPEN top-level fence → plain-escaped (Level 2: zero hljs while open); + * 2. an incomplete last line that opens a fence marker → same Level-2 path + * (keeps "uncolored while open" monotonic — no one-frame highlight flicker + * between the marker's arrival and the line's completion); + * 3. otherwise prose → today's pipeline, seeded with the settled parity. + */ +function renderTail( + s: StreamingMarkdownState, + image: MarkdownImageOptions | undefined, +): StreamingMarkdownResult { + // 1. Open top-level fence: everything from its opening line is code content. + if (s.fence !== null && s.fence.start >= s.settledOffset) { + let code = s.source.slice(s.fence.contentStart); + // A partial last line shaped like the closing fence is the pending closer — + // exclude it from the content (it completes and settles next delta). + if (s.scanOffset < s.source.length) { + const partial = s.source.slice(s.scanOffset); + if (isCloseLine(partial, s.fence.char, s.fence.len)) { + code = s.source.slice(s.fence.contentStart, s.scanOffset); + } + } + return { prefixHtml: s.prefixHtml, tailHtml: renderOpenFence(s.fence.info, code) }; + } + + // 2. Incomplete last line opening a fence marker (top level only). Today's + // whole-text render parses the PARTIAL info string (the fence runs to + // EOF), so pass it through for the same transient `language-` class. + if ( + !s.inList && + !s.inQuote && + !s.htmlComment && + s.htmlTag === null && + s.scanOffset < s.source.length + ) { + const partial = s.source.slice(s.scanOffset); + const m = FENCE_OPEN.exec(partial); + const marker = m?.[1]; + const info = m?.[2] ?? ""; + if (marker !== undefined && !(marker[0] === "`" && info.includes("`"))) { + const prose = s.source.slice(s.settledOffset, s.scanOffset); + return { + prefixHtml: s.prefixHtml, + tailHtml: + renderMarkdownTail(prose, s.seed, renderOptionsOf(image)) + renderOpenFence(info, ""), + }; + } + } + + // 3. Prose tail. + return { + prefixHtml: s.prefixHtml, + tailHtml: renderMarkdownTail(s.source.slice(s.settledOffset), s.seed, renderOptionsOf(image)), + }; +} diff --git a/src/features/markdown/logic/text-file.test.ts b/src/features/markdown/logic/text-file.test.ts new file mode 100644 index 0000000..5649b2f --- /dev/null +++ b/src/features/markdown/logic/text-file.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { splitTextFiles, textFilePath, textFilePreview } from "./text-file"; + +const reference = (text: string, streaming = false) => + splitTextFiles(text, streaming).filter((part) => part.kind === "text-file"); +describe("text-file opt-in parser", () => { + it.each([ + ["[Plan](text-file:notes/plan.md)", "notes/plan.md"], + ["[Plan](text-file:/tmp/plan.md)", "/tmp/plan.md"], + ["[Plan](<text-file:notes/my plan.md>)", "notes/my plan.md"], + ["[Plan](text-file:notes/my%20plan.md)", "notes/my plan.md"], + ["[Plan](text-file:notes/100%25.md)", "notes/100%.md"], + ["[Plan](text-file:notes/literal%2520.md)", "notes/literal%20.md"], + [String.raw`[Plan](text-file:notes/plan\(1\).md)`, "notes/plan(1).md"], + [String.raw`[Plan](text-file:notes/a\_b.md)`, "notes/a_b.md"], + ])("parses %s", (text, path) => + expect(reference(text)).toEqual([{ kind: "text-file", reference: { label: "Plan", path } }])); + it.each([ + "[web](https://example.com)", + "", + "[file](file:///tmp/a.md)", + "see [Plan](text-file:a.md)", + "[Plan](text-file:a.md) suffix", + "`[Plan](text-file:a.md)`", + "```md\n[Plan](text-file:a.md)\n```", + "~~~\n[Plan](text-file:a.md)\n~~~", + " [Plan](text-file:a.md)", + "> [Plan](text-file:a.md)", + "- [Plan](text-file:a.md)", + "[Plan](text-file:a.md", + "[Plan](text-file:", + "[Plan][p]\n\n[p]: text-file:a.md", + '[Plan](text-file:a.md "title")', + "<div data-text-file='a.md'>Plan</div>", + ])("does not opt in: %s", (text) => expect(reference(text)).toEqual([])); + it.each([ + "file:///tmp/a", + "https://evil/a", + "//host/a", + "~/a", + "~user/a", + "a\\b", + "%00", + "%0a", + "%7f", + "a%5cb", + "%2f%2fhost/a", + "%68ttps://evil/a", + "bad%", + "", + " a", + "a ", + ])("rejects unsafe path %s", (path) => expect(textFilePath(`text-file:${path}`)).toBeNull()); + it("waits for complete streaming paragraph boundary and keeps settled embeds", () => { + const complete = "[Plan](text-file:a.md)"; + for (let i = 0; i <= complete.length; i++) + expect(reference(complete.slice(0, i), true)).toHaveLength(0); + expect(reference(`${complete}\n`, true)).toHaveLength(0); + expect(reference(`${complete}\n\n`, true)).toHaveLength(1); + expect(reference(`${complete}\n\ntail`, true)).toHaveLength(1); + expect(reference(`${complete} suffix`, true)).toHaveLength(0); + expect(reference(complete)).toHaveLength(1); + }); + it("bounds per-message document count", () => { + const result = splitTextFiles( + Array.from({ length: 30 }, (_, i) => `[p](text-file:${i}.md)`).join("\n\n"), + ); + expect(result.filter((p) => p.kind === "text-file")).toHaveLength(8); + expect(JSON.stringify(result)).toContain("embed limit reached"); + }); +}); +describe("source-line preview", () => { + it.each([1, 6, 12])("does not overlap %i source lines", (n) => { + const text = Array.from({ length: n }, (_, i) => `${i}`).join("\n"); + expect(textFilePreview(text, true)).toEqual({ head: text, tail: "", omitted: 0 }); + }); + it("takes first six and last six source lines, ignoring final newline", () => { + const result = textFilePreview( + `${Array.from({ length: 20 }, (_, i) => `line${i + 1}`).join("\n")}\n`, + true, + ); + expect(result).toEqual({ + head: "line1\nline2\nline3\nline4\nline5\nline6", + tail: "line15\nline16\nline17\nline18\nline19\nline20", + omitted: 8, + }); + }); + it("restores top-level fence context at fragment edges", () => { + const text = `\`\`\`js\n${Array.from({ length: 20 }, () => "const x = 1;").join("\n")}\n\`\`\``; + const result = textFilePreview(text, true); + expect(result.head).toMatch(/\n```$/); + expect(result.tail).toMatch(/^```js\n/); + expect(result.tail).toMatch(/\n```$/); + expect(textFilePreview(text, false).tail).not.toMatch(/^```js/); + }); +}); + +describe("text-file parser boundary compatibility", () => { + it("preserves document-wide ordinary reference definitions across embed splits", () => { + const parts = splitTextFiles( + "[before][web]\n\n[Plan](text-file:a.md)\n\n[after][web]\n\n[web]: https://example.com", + ); + expect(parts.filter((part) => part.kind === "text-file")).toHaveLength(1); + for (const part of parts) { + if (part.kind === "markdown") expect(part.text).toContain("[web]: https://example.com"); + } + }); + it.each([ + "%C2%85", + "a".repeat(4097), + ])("rejects backend-unsupported controls or overlong paths", (path) => { + expect(textFilePath(`text-file:${path}`)).toBeNull(); + }); + it("accepts encoded filename delimiters without treating them as a URL query/fragment", () => { + expect(textFilePath("text-file:notes/a%23b%3Fc.md")).toBe("notes/a#b?c.md"); + }); +}); diff --git a/src/features/markdown/logic/text-file.ts b/src/features/markdown/logic/text-file.ts new file mode 100644 index 0000000..4321c6f --- /dev/null +++ b/src/features/markdown/logic/text-file.ts @@ -0,0 +1,116 @@ +import { Marked } from "marked"; + +export interface TextFileReference { + readonly label: string; + readonly path: string; +} +export type MarkdownPart = + | { readonly kind: "markdown"; readonly text: string } + | { readonly kind: "text-file"; readonly reference: TextFileReference }; + +/** Input is marked's already Markdown-unescaped href. Decode percent escapes ONCE. */ +export function textFilePath(href: string): string | null { + if (!href.startsWith("text-file:")) return null; + let path: string; + try { + path = decodeURIComponent(href.slice(10)); + } catch { + return null; + } + if ( + !path || + path !== path.trim() || + path.length > 4096 || + path.includes("\\") || + Array.from(path).some((character) => { + const code = character.charCodeAt(0); + return code < 32 || (code >= 127 && code <= 159); + }) || + path.startsWith("~") || + path.startsWith("//") || + /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path) + ) + return null; + return path; +} + +/** Only a complete, top-level, standalone inline-link paragraph opts in. No I/O. */ +export function splitTextFiles(text: string, streaming = false): readonly MarkdownPart[] { + if (!text.includes("text-file:")) return [{ kind: "markdown", text }]; + const parts: MarkdownPart[] = []; + let prose = ""; + let offset = 0; + let embeds = 0; + const tokens = new Marked({ gfm: true }).lexer(text); + for (const token of tokens) { + offset += token.raw.length; + const children = token.type === "paragraph" ? token.tokens : undefined; + const link = children?.length === 1 ? children[0] : undefined; + const path = + link?.type === "link" && + link.title === null && + // Reference-style links and multiline paragraphs are deliberately not embeds. + /^\s*\[[\s\S]*\]\(/.test(token.raw) && + !token.raw.trim().includes("\n") + ? textFilePath(link.href) + : null; + // During streaming wait for a paragraph boundary, not merely a closing ')' + // that could still grow into adjacent prose on the next delta. + const settled = !streaming || /^\s*\n/.test(text.slice(offset)); + if (path !== null && link?.type === "link" && settled && embeds < 8) { + embeds++; + if (prose) parts.push({ kind: "markdown", text: prose }); + prose = ""; + parts.push({ kind: "text-file", reference: { label: link.text, path } }); + } else { + prose += token.raw; + if (path !== null && settled && embeds >= 8) + prose += "\n\n_Text-file embed limit reached (8 per message)._\n\n"; + } + } + if (prose) parts.push({ kind: "markdown", text: prose }); + // Reference definitions apply document-wide, even across embed boundaries. + const definitions = tokens + .filter((token) => token.type === "def") + .map((token) => token.raw) + .join("\n"); + return embeds && definitions + ? parts.map((part) => + part.kind === "markdown" + ? { + ...part, + text: `${part.text}\n\n${definitions}`, + } + : part, + ) + : parts; +} + +/** Restore top-level fence context when the tail begins inside a code fence. */ +function fenceAt(lines: readonly string[], end: number): string | null { + let fence: { marker: string; info: string } | null = null; + for (const line of lines.slice(0, end)) { + if (fence) { + const close = /^ {0,3}(`+|~+)\s*$/.exec(line)?.[1]; + if (close && close[0] === fence.marker[0] && close.length >= fence.marker.length) + fence = null; + } else { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (match?.[1]) fence = { marker: match[1], info: match[2] ?? "" }; + } + } + return fence ? `${fence.marker}${fence.info}` : null; +} +export function textFilePreview(text: string, markdown: boolean) { + const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n"); + if (lines.length <= 12) return { head: text, tail: "", omitted: 0 }; + const headLines = lines.slice(0, 6); + const tailLines = lines.slice(-6); + const headFence = markdown ? fenceAt(lines, 6) : null; + const tailFence = markdown ? fenceAt(lines, lines.length - 6) : null; + return { + head: headLines.join("\n") + (headFence ? `\n${headFence.match(/^[`~]+/)?.[0]}` : ""), + tail: (tailFence ? `${tailFence}\n` : "") + tailLines.join("\n"), + omitted: lines.length - 12, + }; +} diff --git a/src/features/markdown/types.ts b/src/features/markdown/types.ts new file mode 100644 index 0000000..cb3fe8d --- /dev/null +++ b/src/features/markdown/types.ts @@ -0,0 +1,13 @@ +import type { TextFileContextResponse, TextFileResponse } from "@dispatch/transport-contract"; + +export type TextFileState = + | { readonly kind: "loading" } + | { readonly kind: "error"; readonly message: string } + | { readonly kind: "ready"; readonly file: TextFileResponse }; +export interface TextFileClient { + subscribe(path: string, listener: (state: TextFileState) => void): () => void; + /** Refresh context as well as the live on-disk file. */ + refresh(path: string): void; + dispose(): void; +} +export type TextFileContext = TextFileContextResponse; diff --git a/src/features/markdown/ui/Markdown.svelte b/src/features/markdown/ui/Markdown.svelte index 72b892b..c33f9f6 100644 --- a/src/features/markdown/ui/Markdown.svelte +++ b/src/features/markdown/ui/Markdown.svelte @@ -1,58 +1,33 @@ <script lang="ts"> - import { renderMarkdown } from "../logic/markdown"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { splitTextFiles } from "../logic/text-file"; + import type { TextFileClient } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + import TextFileEmbed from "./TextFileEmbed.svelte"; let { text, streaming = false, + image = undefined, + textFiles = undefined, }: { text: string; - /** Balance dangling delimiters while the message is still generating. */ streaming?: boolean; + image?: MarkdownImageOptions | undefined; + /** Explicit opt-in; embedded documents never receive this port. */ + textFiles?: TextFileClient | undefined; } = $props(); - - // Pure transform; the HTML is already DOMPurify-sanitized in renderMarkdown. - const html = $derived(renderMarkdown(text, { streaming })); - - let container: HTMLElement; - - // One delegated listener on the stable container handles every code block's - // copy button — including blocks re-created when `html` changes (streaming), - // since the listener lives on the container, not the buttons. Clipboard is the - // edge effect; absent (insecure context) → no-op. - $effect(() => { - const el = container; - if (el === undefined) return; - - const onClick = (event: Event): void => { - const target = event.target; - if (!(target instanceof Element)) return; - const button = target.closest<HTMLButtonElement>("[data-copy]"); - if (button === null) return; - - const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; - const clipboard = navigator.clipboard; - if (clipboard === undefined) return; - - void clipboard - .writeText(code) - .then(() => { - const prev = button.textContent; - button.textContent = "Copied"; - setTimeout(() => { - button.textContent = prev; - }, 1200); - }) - .catch(() => { - // Clipboard denied — leave the button as-is. - }); - }; - - el.addEventListener("click", onClick); - return () => el.removeEventListener("click", onClick); - }); + const parts = $derived(textFiles ? splitTextFiles(text, streaming) : null); </script> -<div class="markdown-body" bind:this={container}> - <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> - {@html html} -</div> +{#if parts && textFiles} + {#each parts as part, i (i)} + {#if part.kind === "text-file"} + <TextFileEmbed reference={part.reference} client={textFiles} {image} /> + {:else} + <MarkdownContent text={part.text} {streaming} {image} /> + {/if} + {/each} +{:else} + <MarkdownContent {text} {streaming} {image} /> +{/if} diff --git a/src/features/markdown/ui/MarkdownContent.svelte b/src/features/markdown/ui/MarkdownContent.svelte new file mode 100644 index 0000000..6149d6b --- /dev/null +++ b/src/features/markdown/ui/MarkdownContent.svelte @@ -0,0 +1,116 @@ +<script lang="ts"> + import { type MarkdownImageOptions, renderMarkdown } from "../logic/markdown"; + import { advance, createStreamingMarkdown } from "../logic/streaming-markdown"; + + let { + text, + streaming = false, + image = undefined, + }: { + text: string; + /** Balance dangling delimiters while the message is still generating. */ + streaming?: boolean; + /** + * Per-conversation local-image context: the backend HTTP base and the + * conversation cwd. Markdown image srcs that are local paths are rewritten + * through `${apiBase}/local-image?path=…` (relative paths resolve against + * `cwd`). Remote HTTP(S) images require a click; data, same-origin and + * backend images load inline. Omit it to skip local-path rewriting. + */ + image?: MarkdownImageOptions | undefined; + } = $props(); + + // Keep the optional `image` out of the options object when absent + // (exactOptionalPropertyTypes). Identity is irrelevant: the streaming cache + // keys on apiBase/cwd values. + const renderOpts = $derived(image === undefined ? undefined : { image }); + + // Checkpoint streaming state: one per component instance (per streaming + // message). It caches the settled blocks' HTML so each delta re-renders only + // the live tail — never the settled prefix (see logic/streaming-markdown). + const streamingState = createStreamingMarkdown(); + + // While streaming: the checkpoint split (settled prefix + live tail), which + // equals today's full render at every step modulo inter-block newlines and + // highlight-on-close. When the message completes (or for every non-streaming + // chunk): ONE full authoritative render — today's exact behavior, so the + // final/persisted output is byte-identical by construction. + const rendered = $derived.by(() => { + if (!streaming) return { prefixHtml: "", tailHtml: renderMarkdown(text, renderOpts) }; + return advance(streamingState, text, renderOpts); + }); + + let container: HTMLElement; + + // One delegated listener on the stable container handles image consent and + // every code block's copy button — including blocks re-created when the tail html changes + // (streaming), since the listener lives on the container, not the buttons. + // Clipboard is the edge effect; absent (insecure context) → no-op. + $effect(() => { + const el = container; + if (el === undefined) return; + + const onClick = (event: Event): void => { + const target = event.target; + if (!(target instanceof Element)) return; + const remote = target.closest<HTMLButtonElement>("button.remote-image[data-src]"); + if (remote !== null && el.contains(remote)) { + event.preventDefault(); // A Markdown image may be wrapped in a link. + event.stopPropagation(); + // data-* is authorable in raw HTML: validate again at the DOM edge. + let url: URL; + try { + url = new URL(remote.dataset.src ?? ""); + } catch { + return; + } + if (url.protocol !== "http:" && url.protocol !== "https:") return; + const img = el.ownerDocument.createElement("img"); + img.alt = remote.dataset.alt ?? ""; + if (remote.dataset.title !== undefined) img.title = remote.dataset.title; + img.setAttribute("referrerpolicy", "no-referrer"); + img.src = url.href; + remote.replaceWith(img); + return; + } + const button = target.closest<HTMLButtonElement>("[data-copy]"); + if (button === null) return; + + const code = button.closest(".code-block")?.querySelector("code")?.textContent ?? ""; + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + + void clipboard + .writeText(code) + .then(() => { + const prev = button.textContent; + button.textContent = "Copied"; + setTimeout(() => { + button.textContent = prev; + }, 1200); + }) + .catch(() => { + // Clipboard denied — leave the button as-is. + }); + }; + + el.addEventListener("click", onClick); + return () => el.removeEventListener("click", onClick); + }); +</script> + +<div class="markdown-body" bind:this={container}> + {#if streaming} + <!-- Two sibling wrappers (no padding/border/min-height, so block margins + collapse across them exactly as if the blocks were direct children). + Svelte 5 skips the {@html} DOM write when the string is unchanged, so + the settled prefix's subtree is never re-parsed/re-created/re-laid-out; + only the tail's subtree is replaced per delta. Both strings are + DOMPurify-sanitized in the logic modules. --> + <div class="markdown-stream-prefix">{@html rendered.prefixHtml}</div> + <div class="markdown-stream-tail">{@html rendered.tailHtml}</div> + {:else} + <!-- {@html} is safe here: `html` is DOMPurify-sanitized inside renderMarkdown. --> + {@html rendered.tailHtml} + {/if} +</div> diff --git a/src/features/markdown/ui/TextFileEmbed.svelte b/src/features/markdown/ui/TextFileEmbed.svelte new file mode 100644 index 0000000..2e1a7a3 --- /dev/null +++ b/src/features/markdown/ui/TextFileEmbed.svelte @@ -0,0 +1,113 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { MarkdownImageOptions } from "../logic/markdown"; + import { textFilePreview, type TextFileReference } from "../logic/text-file"; + import type { TextFileClient, TextFileState } from "../types"; + import MarkdownContent from "./MarkdownContent.svelte"; + + let { + reference, + client, + image = undefined, + }: { + reference: TextFileReference; + client: TextFileClient; + image?: MarkdownImageOptions | undefined; + } = $props(); + let loadState = $state<TextFileState>({ kind: "loading" }); + let expanded = $state(false); + const preview = $derived( + loadState.kind === "ready" + ? textFilePreview(loadState.file.text, loadState.file.format === "markdown") + : null, + ); + const path = $derived(reference.path); + const currentClient = $derived(client); + $effect(() => { + const source = currentClient; + const destination = path; + expanded = false; + // The callback can publish synchronously. Only routing changes, not reads + // inside a subscriber or unrelated parent prop updates, own this effect. + return untrack(() => + source.subscribe(destination, (next) => { + loadState = next; + }), + ); + }); + // Use the resolved file's directory for local images, not the chat's cwd. + const documentImage = $derived( + loadState.kind === "ready" && image + ? { + apiBase: image.apiBase, + cwd: loadState.file.path.slice(0, loadState.file.path.lastIndexOf("/")) || "/", + } + : image, + ); +</script> + +<section + class="inset-well my-2 w-full min-w-0 rounded-box text-sm" + aria-label={`Text file: ${reference.label}`} +> + <header class="flex min-w-0 flex-wrap items-center gap-2 px-4 py-2 font-medium"> + <span aria-hidden="true">▤</span> + <span>{reference.label}</span> + <span class="min-w-0 flex-1 break-all font-mono text-xs font-normal text-base-content/60" + >{reference.path}</span + > + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => client.refresh(reference.path)} + disabled={loadState.kind === "loading"} + > + {loadState.kind === "error" ? "Retry" : "Refresh"} + </button> + </header> + <div class="min-w-0 px-4 pb-4"> + {#if loadState.kind === "loading"} + <p role="status"> + <span class="loading loading-dots loading-sm" aria-hidden="true"></span> Loading text file… + </p> + {:else if loadState.kind === "error"} + <p role="alert" class="text-error">{loadState.message}</p> + {:else if loadState.file.text === ""} + <p class="italic text-base-content/60">Empty file</p> + {:else if preview} + {#if expanded} + {@render content(loadState.file.text, loadState.file.format)} + {:else} + {@render content(preview.head, loadState.file.format)} + {#if preview.omitted > 0} + <p class="my-2 border-y border-base-content/20 py-2 text-xs text-base-content/60"> + ⋯ {preview.omitted} source lines omitted ⋯ + </p> + {@render content(preview.tail, loadState.file.format)} + {/if} + {/if} + <button + type="button" + class="btn btn-soft btn-xs mt-2" + aria-expanded={expanded} + onclick={() => { + expanded = !expanded; + }} + > + {expanded ? "Collapse document" : "Expand document"} + </button> + <p class="mt-2 text-xs text-base-content/50"> + Live file · Refresh rereads disk · maximum 256 KiB + </p> + {/if} + </div> +</section> + +{#snippet content(text: string, format: "markdown" | "text")} + {#if format === "markdown"} + <!-- No textFiles port: nested references are inert, even in expanded content. --> + <MarkdownContent {text} image={documentImage} /> + {:else} + <pre class="overflow-x-auto whitespace-pre font-mono text-xs"><code>{text}</code></pre> + {/if} +{/snippet} diff --git a/src/features/markdown/ui/markdown.test.ts b/src/features/markdown/ui/markdown.test.ts index d65b3d1..b86cc0a 100644 --- a/src/features/markdown/ui/markdown.test.ts +++ b/src/features/markdown/ui/markdown.test.ts @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from "@testing-library/svelte"; +import hljs from "highlight.js/lib/core"; import { describe, expect, it, vi } from "vitest"; import Markdown from "./Markdown.svelte"; @@ -37,4 +38,154 @@ describe("Markdown", () => { expect(writeText).toHaveBeenCalledTimes(1); expect(writeText.mock.calls[0]?.[0]).toContain("const x = 1;"); }); + + it.each([ + false, + true, + ])("loads only the clicked remote image (streaming=%s)", async (streaming) => { + const { container } = render(Markdown, { + props: { + text: '\n\n', + streaming, + }, + }); + expect(container.querySelector("img")).toBeNull(); + const button = screen.getByRole("button", { name: "Load image from evil.example" }); + await fireEvent.click(button); + const img = container.querySelector("img"); + expect(img?.getAttribute("src")).toBe("https://evil.example/a.png"); + expect(img?.getAttribute("alt")).toBe("first"); + expect(img?.getAttribute("title")).toBe("A title"); + expect(img?.getAttribute("referrerpolicy")).toBe("no-referrer"); + expect( + screen.getByRole("button", { name: "Load image from other.example" }), + ).toBeInTheDocument(); + }); + + it.each([ + false, + true, + ])("keeps non-image HTML inert before and after image consent (streaming=%s)", async (streaming) => { + const { container } = render(Markdown, { + props: { + text: '<div style="background:url(https://evil.example/bg)"><video poster="https://evil.example/poster" src="https://evil.example/video"></video><svg><image href="https://evil.example/svg" /></svg><input type="image" src="https://evil.example/input"><img src="https://evil.example/allowed-after-click"></div>', + streaming, + }, + }); + expect(container.querySelector("video,svg,input,[style],img")).toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelectorAll("img")).toHaveLength(1); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "https://evil.example/allowed-after-click", + ); + expect(container.querySelector("video,svg,input,[style]")).toBeNull(); + }); + + it("rejects an unsafe URL in a forged placeholder", async () => { + const { container } = render(Markdown, { + props: { text: '<button class="remote-image" data-src="javascript:alert(1)">Load</button>' }, + }); + await fireEvent.click(screen.getByRole("button", { name: "Load" })); + expect(container.querySelector("img")).toBeNull(); + }); + + it("delegates clicks on newly rendered images and prevents linked-image navigation", async () => { + const { container, rerender } = render(Markdown, { props: { text: "start" } }); + await rerender({ text: "[](https://other.example)" }); + const button = screen.getByRole("button", { name: "Load image from evil.example" }); + expect(await fireEvent.click(button)).toBe(false); + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png"); + }); + + it("non-streaming renders a single container (today's full-render path)", () => { + const { container } = render(Markdown, { props: { text: "# Hello" } }); + expect(container.querySelector(".markdown-stream-prefix")).toBeNull(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + // The rendered blocks are DIRECT children of .markdown-body (no wrappers). + const body = container.querySelector(".markdown-body"); + expect(body?.querySelector("h1")).not.toBeNull(); + expect(body?.children[0]?.tagName).toBe("H1"); + }); + + it("streaming renders the prefix/tail split with content matching the full render", () => { + const { container } = render(Markdown, { + props: { text: "# Hello\n\nsettled paragraph\n\ngrowing tail", streaming: true }, + }); + + const prefix = container.querySelector<HTMLElement>(".markdown-stream-prefix"); + const tail = container.querySelector<HTMLElement>(".markdown-stream-tail"); + expect(prefix).not.toBeNull(); + expect(tail).not.toBeNull(); + // Same visible content as today's render, split across the two containers. + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + expect(container.textContent).toContain("growing tail"); + expect(prefix?.querySelector("h1")).not.toBeNull(); + }); + + it("streaming: the settled prefix's html string identity is stable across text appends (no DOM churn)", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x = 1;\n```\n\nafter", streaming: true }, + }); + + const prefixBefore = container.querySelector(".markdown-stream-prefix")?.innerHTML; + expect(prefixBefore).toContain("hljs-keyword"); // the closed fence settled, highlighted once + expect(spy).toHaveBeenCalledTimes(1); + + // Appends that settle nothing leave the prefix string (and its DOM) alone. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and", streaming: true }); + await rerender({ + text: "intro\n\n```js\nconst x = 1;\n```\n\nafter and more", + streaming: true, + }); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toBe(prefixBefore); + expect(spy).toHaveBeenCalledTimes(1); // still exactly one highlight + }); + + it("streaming: an open fence renders uncolored, then highlights exactly once when it closes", async () => { + const spy = vi.spyOn(hljs, "highlight"); + const { container, rerender } = render(Markdown, { + props: { text: "intro\n\n```js\nconst x", streaming: true }, + }); + + const tail = container.querySelector<HTMLElement>(".markdown-stream-tail"); + expect(tail?.innerHTML).toContain("<pre><code"); + expect(tail?.innerHTML).toContain("data-copy"); // same copy button + expect(tail?.innerHTML).not.toContain("hljs-keyword"); // uncolored while open + expect(spy).not.toHaveBeenCalled(); + + // Close the fence → highlighted EXACTLY once, settled into the prefix. + await rerender({ text: "intro\n\n```js\nconst x = 1;\n```\n\ndone", streaming: true }); + expect(spy).toHaveBeenCalledTimes(1); + expect(container.querySelector(".markdown-stream-prefix")?.innerHTML).toContain("hljs-keyword"); + }); + + it("streaming → done flips to the single-container full render (completion backstop)", async () => { + const { container, rerender } = render(Markdown, { + props: { text: "# Hello\n\nbody", streaming: true }, + }); + expect(container.querySelector(".markdown-stream-prefix")).not.toBeNull(); + + await rerender({ text: "# Hello\n\nbody", streaming: false }); + expect(container.querySelector(".markdown-stream-prefix")).toBeNull(); + expect(container.querySelector(".markdown-stream-tail")).toBeNull(); + expect(screen.getByRole("heading", { level: 1, name: "Hello" })).toBeInTheDocument(); + }); + + it("streaming: the delegated copy-button listener still works for tail code blocks", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { value: { writeText }, configurable: true }); + + const { container } = render(Markdown, { + props: { text: "para\n\n```js\nconst y = 2;", streaming: true }, + }); + + const button = container.querySelector<HTMLElement>("[data-copy]"); + expect(button).not.toBeNull(); + if (button === null) throw new Error("expected a copy button"); + await fireEvent.click(button); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0]?.[0]).toContain("const y = 2;"); + }); }); diff --git a/src/features/markdown/ui/text-file.test.ts b/src/features/markdown/ui/text-file.test.ts new file mode 100644 index 0000000..cbbf11e --- /dev/null +++ b/src/features/markdown/ui/text-file.test.ts @@ -0,0 +1,190 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/svelte"; +import { describe, expect, it, vi } from "vitest"; +import { createTextFileClient } from "../adapter/text-file"; +import Markdown from "./Markdown.svelte"; + +const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status }); +const context = { conversationId: "c", cwd: "/project", computerId: null }; +function fixture(text: string, format: "markdown" | "text" = "markdown") { + const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url) => + json( + String(url).endsWith("-context") + ? context + : { + path: "/project/notes/plan.md", + text, + format, + bytes: new TextEncoder().encode(text).length, + maxBytes: 262144, + }, + ), + ); + return { fetcher, client: createTextFileClient("/api", "c", fetcher) }; +} +describe("rendered live text-file embeds", () => { + it("renders Markdown head/tail source lines and expands/collapses the full sanitized document", async () => { + const text = Array.from({ length: 20 }, (_, i) => `## Line ${i + 1}`).join("\n"); + const { client, fetcher } = fixture(text); + const { unmount } = render(Markdown, { + text: "Summary\n\n[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + expect(screen.getByRole("status")).toHaveTextContent("Loading text file"); + await screen.findByRole("heading", { name: "Line 1" }); + expect(screen.getByRole("heading", { name: "Line 6" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 15" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "Line 20" })).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(screen.getByText(/8 source lines omitted/)).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect(screen.getByRole("heading", { name: "Line 10" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Collapse document" })).toHaveAttribute( + "aria-expanded", + "true", + ); + await fireEvent.click(screen.getByRole("button", { name: "Collapse document" })); + expect(screen.queryByRole("heading", { name: "Line 10" })).not.toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("short documents don't duplicate; nested embeds are inert; HTML stays sanitized and remote images require consent", async () => { + const text = + '# Short\n\n[nested](text-file:secret.md)\n\n<script>alert(1)</script><img src="https://evil.example/a.png" onerror="alert(1)">\n\n[bad](javascript:alert(1))'; + const { client, fetcher } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:notes/plan.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Short" }); + expect(screen.getAllByRole("heading", { name: "Short" })).toHaveLength(1); + expect( + container.querySelector("script,[onerror],a[href^='javascript:'],a[href^='text-file:'],img"), + ).toBeNull(); + expect(container.textContent).toContain("nested"); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + await fireEvent.click(screen.getByRole("button", { name: "Load image from evil.example" })); + expect(container.querySelector("img")?.getAttribute("src")).toBe("https://evil.example/a.png"); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("does not render omitted images before expansion; restores tail fence context", async () => { + const text = + "# Head\n\n1\n2\n3\n4\n\n```js\n" + + Array.from({ length: 20 }, () => 'const x = "safe";').join("\n") + + "\n```"; + const { client } = fixture(text); + const { container, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: client, + }); + await screen.findByRole("heading", { name: "Head" }); + expect( + screen.queryByRole("button", { name: "Load image from hidden.example" }), + ).not.toBeInTheDocument(); + expect(container.querySelector("code.language-js")).not.toBeNull(); + await fireEvent.click(screen.getByRole("button", { name: "Expand document" })); + expect( + screen.getByRole("button", { name: "Load image from hidden.example" }), + ).toBeInTheDocument(); + expect(container.querySelector("img")).toBeNull(); + unmount(); + client.dispose(); + }); + it("renders plain text with safe whitespace and no Markdown/HTML interpretation", async () => { + const { client } = fixture("# heading\n <script>bad</script>\n\tlast", "text"); + const { container, unmount } = render(Markdown, { + text: "[Text](text-file:a.txt)", + textFiles: client, + }); + await waitFor(() => + expect(container.querySelector("pre code")?.textContent).toBe( + "# heading\n <script>bad</script>\n\tlast", + ), + ); + expect(container.querySelector("script,h1")).toBeNull(); + unmount(); + client.dispose(); + }); + it("empty, error, Retry and Refresh states reread rather than insert file text into source", async () => { + const { client, fetcher } = fixture(""); + fetcher + .mockResolvedValueOnce(json(context)) + .mockResolvedValueOnce(json({ error: "Missing file" }, 404)); + const { unmount } = render(Markdown, { text: "[Plan](text-file:a.md)", textFiles: client }); + expect(await screen.findByRole("alert")).toHaveTextContent("Missing file"); + await fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("Empty file"); + await fireEvent.click(screen.getByRole("button", { name: "Refresh" })); + await screen.findByText("Empty file"); + expect(fetcher).toHaveBeenCalledTimes(6); + unmount(); + client.dispose(); + }); + it("never fetches without opt-in, for incomplete streaming, code or forged raw HTML", async () => { + const { client, fetcher } = fixture("body"); + const { rerender, unmount } = render(Markdown, { text: "[Plan](text-file:a.md)" }); + await rerender({ + text: '<div data-text-file="secret.md"><button data-path="secret.md">Fake</button></div>\n\n`[Plan](text-file:a.md)`\n\n```\n[Plan](text-file:a.md)\n```', + textFiles: client, + }); + await rerender({ text: "[Plan](text-file:a.md)", streaming: true, textFiles: client }); + expect(fetcher).not.toHaveBeenCalled(); + await rerender({ text: "[Plan](text-file:a.md)\n\ntail", streaming: true, textFiles: client }); + await screen.findByText("body"); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: true, + textFiles: client, + }); + await rerender({ + text: "[Plan](text-file:a.md)\n\ntail growing", + streaming: false, + textFiles: client, + }); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); + it("ignores old conversation results after the context-bound client changes", async () => { + let finish: (r: Response) => void = () => {}; + const old = fixture("old"); + old.fetcher.mockResolvedValueOnce(json(context)).mockImplementationOnce( + () => + new Promise((r) => { + finish = r; + }), + ); + const next = fixture("new"); + const { rerender, unmount } = render(Markdown, { + text: "[Plan](text-file:a.md)", + textFiles: old.client, + }); + await waitFor(() => expect(old.fetcher).toHaveBeenCalledTimes(2)); + await rerender({ text: "[Plan](text-file:a.md)", textFiles: next.client }); + await screen.findByText("new"); + finish( + json({ path: "/old/a.md", text: "old", format: "markdown", bytes: 3, maxBytes: 262144 }), + ); + await waitFor(() => expect(screen.queryByText("old")).not.toBeInTheDocument()); + expect(old.fetcher.mock.calls[1]?.[1]?.signal?.aborted).toBe(true); + unmount(); + old.client.dispose(); + next.client.dispose(); + }); + it("deduplicates repeated blocks without replacing them on per-token appends", async () => { + const { client, fetcher } = fixture("# shared"); + const text = "[one](text-file:a.md)\n\n[two](text-file:a.md)\n\n"; + const { rerender, unmount } = render(Markdown, { text, streaming: true, textFiles: client }); + await waitFor(() => expect(screen.getAllByRole("heading", { name: "shared" })).toHaveLength(2)); + await fireEvent.click( + screen.getAllByRole("button", { name: "Expand document" })[0] as HTMLElement, + ); + await rerender({ text: `${text}growing`, streaming: true, textFiles: client }); + expect(screen.getByRole("button", { name: "Collapse document" })).toBeInTheDocument(); + expect(fetcher).toHaveBeenCalledTimes(2); + unmount(); + client.dispose(); + }); +}); diff --git a/src/features/named-agents/index.ts b/src/features/named-agents/index.ts new file mode 100644 index 0000000..e66ef8e --- /dev/null +++ b/src/features/named-agents/index.ts @@ -0,0 +1,64 @@ +export { + type AgentDraft, + type AgentSaved, + type DraftValidity, + draftModel, + draftValidity, + isClean, + isDirty, + type SaveStatus, + saveStatus, + saveStatusLabel, +} from "./logic/auto-save"; +export { + normalizeNamedAgent, + normalizeNamedAgentRecord, + normalizeNamedAgents, + toNamedAgentInput, +} from "./logic/normalize"; +export { + fullModelFromSelection, + joinModel, + modelFromFull, + modelsForProvider, + openCodeNumber, + providerFromModel, + providerLabel, + providerOptions, +} from "./logic/options"; +export { findNamedAgent, snapshotFor } from "./logic/snapshot"; +export type { + CreateNamedAgent, + DeleteNamedAgent, + LoadNamedAgents, + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentResponse, + NamedAgentSaveResult, + NamedAgentSelectionSnapshot, + NamedAgentsResponse, + NamedAgentsResult, + UpdateNamedAgent, +} from "./logic/types"; +export { + isUsableModel, + isUsableName, + nameError, + normalizeName, +} from "./logic/validate"; +export { + type CardController, + type CardControllerOptions, + type CardSaveState, + createCardController, + type NamedAgentPorts, +} from "./ui/controller.svelte"; +export { default as NamedAgentCard } from "./ui/NamedAgentCard.svelte"; +export { default as NamedAgentsView } from "./ui/NamedAgentsView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "named-agents", + description: "Curated Named Agent presets (name + model) in the sidebar", +} as const; diff --git a/src/features/named-agents/logic/auto-save.test.ts b/src/features/named-agents/logic/auto-save.test.ts new file mode 100644 index 0000000..24c9571 --- /dev/null +++ b/src/features/named-agents/logic/auto-save.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { + type AgentDraft, + draftModel, + draftValidity, + isDirty, + saveStatus, + saveStatusLabel, +} from "./auto-save"; + +const draft = (p: Partial<AgentDraft>): AgentDraft => ({ + id: null, + name: "", + provider: "", + model: "", + ...p, +}); + +describe("draftModel", () => { + it("joins provider + model suffix", () => { + expect(draftModel(draft({ provider: "opencode-1", model: "claude" }))).toBe( + "opencode-1/claude", + ); + }); + + it("falls back to provider-only when no model", () => { + expect(draftModel(draft({ provider: "opencode-1" }))).toBe("opencode-1"); + }); +}); + +describe("draftValidity", () => { + it("is usable when name, provider, and model are all valid", () => { + const v = draftValidity(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), []); + expect(v.usable).toBe(true); + expect(v.message).toBeNull(); + }); + + it("is unusable with an empty name / duplicate / missing model", () => { + expect(draftValidity(draft({ name: "", provider: "p", model: "m" }), []).usable).toBe(false); + expect( + draftValidity(draft({ name: "coding", provider: "p", model: "m" }), ["Coding"]).usable, + ).toBe(false); + expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).usable).toBe( + false, + ); + }); + + it("reports the first invalid field's message", () => { + expect(draftValidity(draft({ name: "", provider: "", model: "" }), []).message).toBe( + "Name is required.", + ); + expect( + draftValidity(draft({ name: "coding", provider: "", model: "" }), ["Coding"]).message, + ).toBe("A named agent with this name already exists."); + expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).message).toBe( + "Select a provider.", + ); + }); +}); + +describe("isDirty", () => { + it("is false when the draft matches the saved definition", () => { + expect( + isDirty(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(false); + }); + + it("is true when name or model differ", () => { + expect( + isDirty(draft({ name: "Coding X", provider: "opencode-1", model: "claude" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(true); + expect( + isDirty(draft({ name: "Coding", provider: "umans", model: "glm" }), { + id: "a", + name: "Coding", + model: "opencode-1/claude", + }), + ).toBe(true); + }); +}); + +describe("saveStatus", () => { + const saved = { id: "a", name: "Coding", model: "opencode-1/claude" }; + + it("is saved when clean with an id", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + false, + null, + ), + ).toBe("saved"); + }); + + it("is dirty when input diverges from the saved definition", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding X", provider: "opencode-1", model: "claude" }), + saved, + false, + null, + ), + ).toBe("dirty"); + }); + + it("is an idle draft when it has no id", () => { + expect( + saveStatus( + draft({ name: "Coding", provider: "opencode-1", model: "claude" }), + { id: null, name: "", model: "" }, + false, + null, + ), + ).toBe("idle"); + }); + + it("is saving while a save is in flight", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + true, + null, + ), + ).toBe("saving"); + }); + + it("reports an error when the last save failed", () => { + expect( + saveStatus( + draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }), + saved, + false, + "boom", + ), + ).toBe("error"); + }); +}); + +describe("saveStatusLabel", () => { + it("maps every status to a short label", () => { + expect(saveStatusLabel("idle")).toBe("Not saved"); + expect(saveStatusLabel("dirty")).toBe("Not saved"); + expect(saveStatusLabel("saving")).toBe("Saving…"); + expect(saveStatusLabel("saved")).toBe("Saved"); + expect(saveStatusLabel("error")).toBe("Save failed"); + }); +}); diff --git a/src/features/named-agents/logic/auto-save.ts b/src/features/named-agents/logic/auto-save.ts new file mode 100644 index 0000000..c89d12e --- /dev/null +++ b/src/features/named-agents/logic/auto-save.ts @@ -0,0 +1,138 @@ +/** + * Pure auto-save model for a Named Agent card (plan §5.3/§5.4). + * + * A card has NO explicit Save button: provider/model changes save immediately, + * a name edit saves after a short debounce, and the card shows a status + * (`Not saved` / `Saving…` / `Saved` / `Save failed` + retry). This module is + * the PURE model of that: given the card's current DRAFT (the raw input + * controls), the last PERSISTED state, whether a save is in flight, and an + * error, it derives the card's display status and what a save would persist. + * + * The effectful coordination (debounce timers, dispatching saves, and the + * monotonic sequence that stops an older response clobbering a newer edit) + * lives in the component-wired controller (`ui/controller.svelte.ts`), which + * is itself thin over this purity. + */ + +import { isUsableModel, isUsableName, normalizeName } from "./validate"; + +/** + * The four auto-save lifecycle states a card reports. `"idle"` = a brand-new + * unsaved draft (never persisted yet) — shown as "Not saved". `"dirty"` = an + * existing card whose input differs from what was last saved — also "Not saved" + * until the (debounced/immediate) save lands. `"saving"` = a save is in flight. + * `"saved"` = current input matches the last persisted value. `"error"` = the + * last save failed (a retry is offered). A card that is not yet usable hovers + * in `"idle"` (never issued a save). + */ +export type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error"; + +/** A card's current EDITOR state (the raw form controls). */ +export interface AgentDraft { + /** The persisted id once the card has been created; null for a new draft. */ + readonly id: string | null; + /** The raw name input (may be unterminated/unedited). */ + readonly name: string; + /** Selected provider id ("" when none). */ + readonly provider: string; + /** Selected model suffix under `provider` ("" when none). */ + readonly model: string; +} + +/** The card's last PERSISTED definition (the server's canonical record). */ +export interface AgentSaved { + readonly id: string | null; + readonly name: string; + readonly model: string; +} + +/** The full canonical `<provider>/<model>` name a draft would persist. */ +export function draftModel(draft: AgentDraft): string { + return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`; +} + +/** The full `<provider>/<model>` name a draft's model suffix maps to. */ +export interface DraftValidity { + readonly nameOk: boolean; + readonly modelOk: boolean; + /** True when the card is fully usable (name nonempty+unique, provider+model chosen). */ + readonly usable: boolean; + /** A message for the first invalid field (for inline hints), or null when usable. */ + readonly message: string | null; +} + +/** + * Validate a draft against its sibling names (the OTHER agents' names, EXCLUDING + * this card's own id — uniqueness is among distinct agents). + */ +export function draftValidity(draft: AgentDraft, otherNames: readonly string[]): DraftValidity { + const nameMessage = nameErrorMessage(draft.name, otherNames); + const nameOk = nameMessage === null; + const modelOk = draft.provider !== "" && isUsableModel(draftModel(draft)); + const usable = nameOk && modelOk; + const message = usable ? null : (nameMessage ?? modelMessage(draft)); + return { nameOk, modelOk, usable, message }; +} + +function nameErrorMessage(name: string, otherNames: readonly string[]): string | null { + if (normalizeName(name).length === 0) return "Name is required."; + return isUsableName(name, otherNames) ? null : "A named agent with this name already exists."; +} + +function modelMessage(draft: AgentDraft): string | null { + if (draft.provider === "") return "Select a provider."; + if (draft.model === "") return "Select a model."; + return "Select a valid model."; +} + +/** + * Whether the current draft differs from what was last persisted — i.e. a save + * is owed. Draft is compared by its NAME (normalized) and full MODEL string. + */ +export function isDirty(draft: AgentDraft, saved: AgentSaved): boolean { + return ( + normalizeName(draft.name) !== normalizeName(saved.name) || draftModel(draft) !== saved.model + ); +} + +/** + * The card's display status, purely from its orchestration inputs: + * - No id yet → a never-persisted draft → `"idle"` while unusable or unsaved, + * `"error"` if a create attempt failed. + * - In-flight save → `"saving"`. + * - Last save errored → `"error"`. + * - No id and clean (nothing to save) → `"idle"`. + * - Dirty (not yet (re)saved) → `"dirty"`. + * - Clean + has an id → `"saved"`. + */ +export function saveStatus( + draft: AgentDraft, + saved: AgentSaved, + saving: boolean, + error: string | null, +): SaveStatus { + if (saving) return "saving"; + if (error !== null) return "error"; + const hasId = draft.id !== null || saved.id !== null; + if (!hasId) return "idle"; + return isDirty(draft, saved) ? "dirty" : "saved"; +} + +/** Short human label for a save status (the card's status line). */ +export function saveStatusLabel(status: SaveStatus): string { + switch (status) { + case "saving": + return "Saving…"; + case "saved": + return "Saved"; + case "error": + return "Save failed"; + default: + return "Not saved"; + } +} + +/** True iff a status warrants showing the card body as "saved clean" (no warning tint). */ +export function isClean(status: SaveStatus): boolean { + return status === "saved"; +} diff --git a/src/features/named-agents/logic/normalize.test.ts b/src/features/named-agents/logic/normalize.test.ts new file mode 100644 index 0000000..5009194 --- /dev/null +++ b/src/features/named-agents/logic/normalize.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeNamedAgent, + normalizeNamedAgentRecord, + normalizeNamedAgents, + toNamedAgentInput, +} from "./normalize"; +import type { NamedAgent } from "./types"; + +describe("normalizeNamedAgent", () => { + it("coerces a well-formed record", () => { + expect(normalizeNamedAgent({ id: "x", name: "Coding", model: "opencode-1/claude" })).toEqual({ + id: "x", + name: "Coding", + model: "opencode-1/claude", + }); + }); + + it("rejects a record missing any field", () => { + expect(normalizeNamedAgent({ id: "x", name: "Coding" })).toBeNull(); + expect(normalizeNamedAgent({ name: "Coding", model: "m" })).toBeNull(); + expect(normalizeNamedAgent(null)).toBeNull(); + }); +}); + +describe("normalizeNamedAgents", () => { + it("coerces a list body", () => { + const agents: readonly NamedAgent[] = normalizeNamedAgents({ + namedAgents: [ + { id: "a", name: "Coding", model: "opencode-1/claude" }, + { id: "b", name: "Research", model: "umans/glm" }, + ], + }); + expect(agents).toHaveLength(2); + expect(agents[0]?.name).toBe("Coding"); + }); + + it("drops malformed entries and survives a missing list key", () => { + expect( + normalizeNamedAgents({ + namedAgents: [ + { id: "a", name: "x" }, + { id: "b", name: "y", model: "m" }, + ], + }).map((a) => a.id), + ).toEqual(["b"]); + expect(normalizeNamedAgents({})).toEqual([]); + expect(normalizeNamedAgents(null)).toEqual([]); + }); +}); + +describe("normalizeNamedAgentRecord", () => { + it("reads the canonical saved record", () => { + expect( + normalizeNamedAgentRecord({ + namedAgent: { id: "a", name: "Coding", model: "opencode-1/claude" }, + })?.id, + ).toBe("a"); + }); + + it("returns null for a malformed record", () => { + expect(normalizeNamedAgentRecord({ namedAgent: {} })).toBeNull(); + expect(normalizeNamedAgentRecord({})).toBeNull(); + }); +}); + +describe("toNamedAgentInput", () => { + it("trims a name and keeps the model", () => { + expect(toNamedAgentInput(" Coding ", "opencode-1/claude")).toEqual({ + name: "Coding", + model: "opencode-1/claude", + }); + }); + + it("defaults non-strings to empty", () => { + expect(toNamedAgentInput(null, 42)).toEqual({ name: "", model: "" }); + }); +}); diff --git a/src/features/named-agents/logic/normalize.ts b/src/features/named-agents/logic/normalize.ts new file mode 100644 index 0000000..c1acec2 --- /dev/null +++ b/src/features/named-agents/logic/normalize.ts @@ -0,0 +1,68 @@ +/** + * Pure network-seam normalizers for the Named Agents API — zero DOM, zero + * effects. The composition root coerces the UNTYPED JSON it receives from + * `GET/POST/PUT/DELETE /named-agents` through these so a malformed / partial + * response can never crash the renderer. Mirrors the concurrency / heartbeat + * network-seam guard pattern. + */ + +import type { NamedAgent, NamedAgentInput } from "./types"; + +function isRecord(value: unknown): value is Record<string, unknown> { + return value !== null && typeof value === "object"; +} + +function asString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +/** + * Coerce one untrusted record into a typed `NamedAgent`, or `null` when it is + * malformed (missing `id`, `name`, or `model` — or a non-string `model`). + */ +export function normalizeNamedAgent(value: unknown): NamedAgent | null { + if (!isRecord(value)) return null; + const id = asString(value.id); + const name = asString(value.name); + const model = asString(value.model); + if (id === null || name === null || model === null) return null; + return { id, name, model }; +} + +/** + * Coerce an untrusted `GET /named-agents` body into a typed Named Agent list. + * When the list key is absent/malformed, returns `[]` (defensive — the renderer + * shows the empty state rather than crashing). + */ +export function normalizeNamedAgents(data: unknown): readonly NamedAgent[] { + if (!isRecord(data) || !Array.isArray(data.namedAgents)) return []; + const out: NamedAgent[] = []; + for (const entry of data.namedAgents as readonly unknown[]) { + const agent = normalizeNamedAgent(entry); + if (agent !== null) out.push(agent); + } + return out; +} + +/** + * Coerce an untrusted `POST`/`PUT /named-agents` body (the canonical saved + * record) into a typed `NamedAgent`, or `null` when malformed. The composition + * root surfaces an error when this returns null on an ok response. + */ +export function normalizeNamedAgentRecord(data: unknown): NamedAgent | null { + if (!isRecord(data)) return null; + return normalizeNamedAgent(data.namedAgent); +} + +/** + * Coerce an untrusted `NamedAgentInput` at the create/update boundary so only + * clean strings reach the wire. Non-string fields drop to "" (name nonempty is + * validated separately; model nonempty too). The caller still runs name/model + * validation so a malformed body never reaches the backend. + */ +export function toNamedAgentInput(name: unknown, model: unknown): NamedAgentInput { + return { + name: typeof name === "string" ? name.trim() : "", + model: typeof model === "string" ? model : "", + }; +} diff --git a/src/features/named-agents/logic/options.test.ts b/src/features/named-agents/logic/options.test.ts new file mode 100644 index 0000000..8b774ab --- /dev/null +++ b/src/features/named-agents/logic/options.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + fullModelFromSelection, + modelFromFull, + modelsForProvider, + providerFromModel, + providerLabel, + providerOptions, +} from "./options"; + +const MODELS = [ + "opencode-2/gpt-5", + "opencode-1/claude-sonnet-4.5", + "umans/umans-glm-5.2", + "opencode-10/defiant", + "umans/umans-gpt-5", + "hyper/fast-1", +] as const; + +describe("providerFromModel / modelFromFull", () => { + it("splits a <provider>/<model> on the first slash", () => { + expect(providerFromModel("opencode-1/claude-sonnet-4.5")).toBe("opencode-1"); + expect(modelFromFull("opencode-1/claude-sonnet-4.5")).toBe("claude-sonnet-4.5"); + }); + + it("treats a slashless name as all-provider", () => { + expect(providerFromModel("umans")).toBe("umans"); + expect(modelFromFull("umans")).toBe(""); + }); +}); + +describe("providerOptions", () => { + it("sorts numbered OpenCode credentials numerically before other providers", () => { + expect(providerOptions([...MODELS])).toEqual([ + "opencode-1", + "opencode-2", + "opencode-10", + "umans", + "hyper", + ]); + }); + + it("keeps first-seen order and dedupes", () => { + expect(providerOptions(["b/x", "a/y", "b/z"])).toEqual(["b", "a"]); + }); +}); + +describe("modelsForProvider", () => { + it("returns only the model suffixes under the provider, in order", () => { + expect(modelsForProvider([...MODELS], "umans")).toEqual(["umans-glm-5.2", "umans-gpt-5"]); + expect(modelsForProvider([...MODELS], "hyper")).toEqual(["fast-1"]); + }); + + it("returns [] for an unknown provider", () => { + expect(modelsForProvider([...MODELS], "missing")).toEqual([]); + }); +}); + +describe("providerLabel", () => { + it("labels numbered OpenCode providers, leaves others unchanged", () => { + expect(providerLabel("opencode-2")).toBe("OpenCode #2"); + expect(providerLabel("umans")).toBe("umans"); + }); +}); + +describe("fullModelFromSelection", () => { + it("recombines a provider + model suffix", () => { + expect(fullModelFromSelection("opencode-1", "claude-sonnet-4.5")).toBe( + "opencode-1/claude-sonnet-4.5", + ); + }); +}); diff --git a/src/features/named-agents/logic/options.ts b/src/features/named-agents/logic/options.ts new file mode 100644 index 0000000..0946cbe --- /dev/null +++ b/src/features/named-agents/logic/options.ts @@ -0,0 +1,84 @@ +/** + * Pure provider/model dropdown helpers for the Named Agents editor — zero DOM. + * + * A Named Agent's persisted `model` is the canonical `<provider>/<model>` + * identifier, but the edit UI splits it into a provider dropdown + a model + * dropdown (plan §3/§5.2). These helpers derive those dropdowns from the raw + * `GET /models` catalog — the SAME key grouping the regular model picker uses. + * Catalog latency is acceptable here (human configuration), and it must not + * affect the agent-facing list/summon hot paths (plan §9). + */ + +/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */ +export function providerFromModel(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? full : full.slice(0, i); +} + +/** The model suffix of a `<provider>/<model>` name (the part after the first `/`; empty when slashless). */ +export function modelFromFull(full: string): string { + const i = full.indexOf("/"); + return i === -1 ? "" : full.slice(i + 1); +} + +/** Recombine a provider + model suffix into a canonical `<provider>/<model>` name (provider-only if no model). */ +export function joinModel(provider: string, model: string): string { + return model === "" ? provider : `${provider}/${model}`; +} + +/** + * Distinct provider ids across the catalog, in first-seen order, with numbered + * OpenCode credentials sorted NUMERICALLY before other providers (the same + * grouping the regular model picker uses — `opencode-2` before `opencode-10`, + * then everything else). A friendly display label is produced separately so the + * RAW id stays what gets persisted. + */ +export function providerOptions(models: readonly string[]): string[] { + const seen = new Set<string>(); + const openCodeKeys: Array<{ key: string; n: number }> = []; + const otherKeys: string[] = []; + for (const full of models) { + const p = providerFromModel(full); + if (p === "" || seen.has(p)) continue; + seen.add(p); + const n = openCodeNumber(p); + if (n !== null) openCodeKeys.push({ key: p, n }); + else otherKeys.push(p); + } + openCodeKeys.sort((a, b) => a.n - b.n); + return [...openCodeKeys.map((e) => e.key), ...otherKeys]; +} + +/** The numeric identity of a numbered OpenCode provider (`opencode-1` → 1), or null. */ +export function openCodeNumber(key: string): number | null { + const m = /^opencode-([1-9][0-9]*)$/.exec(key); + return m === null ? null : Number(m[1]); +} + +/** + * Friendly display label for a provider id: `opencode-<n>` → "OpenCode #<n>"; + * any other id unchanged. Presentation only — the RAW id is what gets persisted. + */ +export function providerLabel(provider: string): string { + const n = openCodeNumber(provider); + return n === null ? provider : `OpenCode #${n}`; +} + +/** The model suffixes available under a given provider, in catalog order. */ +export function modelsForProvider(models: readonly string[], provider: string): string[] { + const out: string[] = []; + for (const full of models) { + if (providerFromModel(full) === provider) out.push(modelFromFull(full)); + } + return out; +} + +/** + * The model suffixes in the catalog that a Named Agent's model suffix may match. + * A saved Named Agent may reference a provider/model pair no longer present in a + * live catalog (e.g. a credential was removed); the editor then shows the saved + * RAW full name as an unavailable selection rather than silently rewriting it. + */ +export function fullModelFromSelection(provider: string, model: string): string { + return joinModel(provider, model); +} diff --git a/src/features/named-agents/logic/snapshot.test.ts b/src/features/named-agents/logic/snapshot.test.ts new file mode 100644 index 0000000..3f1b5cf --- /dev/null +++ b/src/features/named-agents/logic/snapshot.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { findNamedAgent, snapshotFor } from "./snapshot"; +import type { NamedAgent } from "./types"; + +const AGENTS: readonly NamedAgent[] = [ + { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }, + { id: "b2", name: "Research Agent", model: "umans/umans-glm-5.2" }, +]; + +describe("snapshotFor", () => { + it("builds a snapshot carrying id, name, and the raw model", () => { + const coding = AGENTS[0]; + if (coding === undefined) throw new Error("test fixture missing"); + expect(coding).toBeDefined(); + expect(snapshotFor(coding)).toEqual({ + namedAgentId: "a1", + namedAgentName: "Coding Agent", + model: "opencode-1/claude-sonnet-4.5", + }); + }); +}); + +describe("findNamedAgent", () => { + it("finds by stable id", () => { + expect(findNamedAgent(AGENTS, "b2")?.name).toBe("Research Agent"); + }); + + it("finds by name case-insensitively", () => { + expect(findNamedAgent(AGENTS, "coding agent")?.id).toBe("a1"); + }); + + it("returns undefined for an unknown id/name", () => { + expect(findNamedAgent(AGENTS, "nope")).toBeUndefined(); + }); +}); diff --git a/src/features/named-agents/logic/snapshot.ts b/src/features/named-agents/logic/snapshot.ts new file mode 100644 index 0000000..482ceb3 --- /dev/null +++ b/src/features/named-agents/logic/snapshot.ts @@ -0,0 +1,33 @@ +/** + * Pure snapshot helpers for Named Agent selection (plan §7). + * + * Selecting a Named Agent for a conversation resolves it to its raw model AND + * records a `NamedAgentSelectionSnapshot` ({namedAgentId, namedAgentName, model}) + * so the conversation retains how it was selected. Later edits to the shared + * Named Agent (rename / model change / removal) therefore do NOT retroactively + * change an existing conversation — the snapshot is what the UI keeps showing. + */ + +import type { NamedAgent, NamedAgentSelectionSnapshot } from "./types"; + +/** Build the snapshot a conversation would retain for a Named Agent selection. */ +export function snapshotFor(agent: NamedAgent): NamedAgentSelectionSnapshot { + return { + namedAgentId: agent.id, + namedAgentName: agent.name, + model: agent.model, + }; +} + +/** + * Find a Named Agent by stable id or (case-insensitively) by name. Used to + * resolve a name/id picked in the model selector back to its current definition. + * Returns undefined when unknown (a stale name/id after deletion). + */ +export function findNamedAgent( + agents: readonly NamedAgent[], + idOrName: string, +): NamedAgent | undefined { + const lower = idOrName.trim().toLowerCase(); + return agents.find((a) => a.id === idOrName || a.name.trim().toLowerCase() === lower); +} diff --git a/src/features/named-agents/logic/types.ts b/src/features/named-agents/logic/types.ts new file mode 100644 index 0000000..2e4d4d3 --- /dev/null +++ b/src/features/named-agents/logic/types.ts @@ -0,0 +1,57 @@ +/** + * Pure shared types for the Named Agents feature — zero DOM, zero effects, + * zero Svelte. + * + * The shared shapes — `NamedAgent`, `NamedAgentSelectionSnapshot`, and the + * `/named-agents` HTTP request/response types — are RE-EXPORTED from the + * backend-contract packages (`@dispatch/wire`, `@dispatch/transport-contract`), + * which now carry them (the backend shipped the named-agents contract). The FE + * consumes those packages as the cross-repo seam; only the FE's port/result + * types are defined locally. + */ + +import type { + CreateNamedAgentRequest, + DeleteNamedAgentResponse, + NamedAgentResponse, + NamedAgentsResponse, + UpdateNamedAgentRequest, +} from "@dispatch/transport-contract"; +import type { NamedAgent, NamedAgentSelectionSnapshot } from "@dispatch/wire"; + +export type { + DeleteNamedAgentResponse, + NamedAgent, + NamedAgentResponse, + NamedAgentSelectionSnapshot, + NamedAgentsResponse, + UpdateNamedAgentRequest, +}; + +/** The subset of a Named Agent a client creates/updates (plan §9 — id is server-minted). */ +export type NamedAgentInput = CreateNamedAgentRequest; + +// ── Ports (injected effects — the composition root supplies real responders) ── + +/** Outcome of listing Named Agents. */ +export type NamedAgentsResult = + | { readonly ok: true; readonly agents: readonly NamedAgent[] } + | { readonly ok: false; readonly error: string }; + +/** Outcome of creating / updating a Named Agent (the canonical saved record). */ +export type NamedAgentSaveResult = + | { readonly ok: true; readonly agent: NamedAgent } + | { readonly ok: false; readonly error: string }; + +/** Outcome of removing a Named Agent. */ +export type NamedAgentDeleteResult = + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; + +export type LoadNamedAgents = () => Promise<NamedAgentsResult>; +export type CreateNamedAgent = (input: NamedAgentInput) => Promise<NamedAgentSaveResult>; +export type UpdateNamedAgent = ( + id: string, + input: NamedAgentInput, +) => Promise<NamedAgentSaveResult>; +export type DeleteNamedAgent = (id: string) => Promise<NamedAgentDeleteResult>; diff --git a/src/features/named-agents/logic/validate.test.ts b/src/features/named-agents/logic/validate.test.ts new file mode 100644 index 0000000..d647354 --- /dev/null +++ b/src/features/named-agents/logic/validate.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { isUsableModel, isUsableName, nameError, normalizeName } from "./validate"; + +describe("normalizeName", () => { + it("trims surrounding whitespace", () => { + expect(normalizeName(" Coding ")).toBe("Coding"); + }); + + it("collapses internal runs of whitespace to one space", () => { + expect(normalizeName(" Research Agent ")).toBe("Research Agent"); + }); + + it("collapses an all-whitespace name to empty", () => { + expect(normalizeName(" ")).toBe(""); + }); +}); + +describe("nameError", () => { + it("returns null for a nonempty unique name", () => { + expect(nameError("Coding", ["Research", "Fast"])).toBeNull(); + }); + + it("flags an empty/normalized-empty name", () => { + expect(nameError("", ["Research"])).toBe("Name is required."); + expect(nameError(" ", ["Research"])).toBe("Name is required."); + }); + + it("flags a name duplicate case-insensitively", () => { + expect(nameError("coding", ["Coding"])).toBe("A named agent with this name already exists."); + expect(nameError("FAST", ["Fast"])).toBe("A named agent with this name already exists."); + }); + + it("ignores whitespace when comparing uniqueness", () => { + expect(nameError(" coding ", ["Coding "])).toBe( + "A named agent with this name already exists.", + ); + }); +}); + +describe("isUsableName", () => { + it("is true only for a unique nonempty name", () => { + expect(isUsableName("Coding", ["Research"])).toBe(true); + expect(isUsableName("coding", ["Coding"])).toBe(false); + expect(isUsableName("", [])).toBe(false); + }); +}); + +describe("isUsableModel", () => { + it("accepts a canonical provider/model pair", () => { + expect(isUsableModel("opencode-1/claude-sonnet-4.5")).toBe(true); + }); + + it("rejects a slashless / provider-only value", () => { + expect(isUsableModel("opencode-1")).toBe(false); + expect(isUsableModel("/claude")).toBe(false); + expect(isUsableModel("")).toBe(false); + }); +}); diff --git a/src/features/named-agents/logic/validate.ts b/src/features/named-agents/logic/validate.ts new file mode 100644 index 0000000..c25524a --- /dev/null +++ b/src/features/named-agents/logic/validate.ts @@ -0,0 +1,47 @@ +/** + * Pure validation for Named Agent form fields — zero DOM, zero effects. + * + * A Named Agent is usable only when (plan §5.4): the name is nonempty + unique + * case-insensitively, and a provider + model are selected. These pure helpers + * drive the card UI's validity gating + inline messages; the backend remains + * the authority (it 400s/conflicts on a duplicate or empty name / malformed + * model identifier). + */ + +/** Trim + collapse runs of whitespace inside a name (survey-style display). */ +export function normalizeName(raw: string): string { + return raw.trim().replace(/\s+/g, " "); +} + +/** + * A name-validation error message, or `null` when the name is valid. `otherNames` + * are the OTHER agents' names for the case-insensitive uniqueness check (the + * caller EXCLUDES the card's own last-persisted name / the card being edited). + */ +export function nameError(name: string, otherNames: readonly string[]): string | null { + const normalized = normalizeName(name); + if (normalized.length === 0) return "Name is required."; + const lower = normalized.toLowerCase(); + for (const other of otherNames) { + if (other.trim().toLowerCase() === lower) return "A named agent with this name already exists."; + } + return null; +} + +/** True iff `name` is a unique, nonempty agent name among `otherNames`. */ +export function isUsableName(name: string, otherNames: readonly string[]): boolean { + return nameError(name, otherNames) === null; +} + +/** + * Whether a full model identifier is syntactically valid — a canonical + * `<provider>/<model>` with a nonempty provider (the part before the first "/") + * AND a nonempty model (the part after). A slashless or provider-only value is + * invalid for a Named Agent (it must resolve to a concrete model). + */ +export function isUsableModel(full: string): boolean { + const i = full.indexOf("/"); + if (i <= 0) return false; // no slash, or empty provider + const model = full.slice(i + 1); + return model.length > 0; +} diff --git a/src/features/named-agents/ui/NamedAgentCard.svelte b/src/features/named-agents/ui/NamedAgentCard.svelte new file mode 100644 index 0000000..b81c59c --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentCard.svelte @@ -0,0 +1,120 @@ +<script lang="ts"> + import type { NamedAgent } from "../logic/types"; + import type { CardController } from "./controller.svelte"; + import { + modelsForProvider, + providerLabel, + providerOptions, + fullModelFromSelection, + } from "../logic/options"; + import { draftValidity } from "../logic/auto-save"; + import { saveStatusLabel } from "../logic/auto-save"; + + let { + models, + agent, + controller, + otherNames, + }: { + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + /** The persisted agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The card's auto-save controller (draft + status + save lifecycle). */ + controller: CardController; + /** The OTHER agents' names (excludes this card) for the uniqueness hint. */ + otherNames: readonly string[]; + } = $props(); + + const providers = $derived(providerOptions(models)); + const providerModels = $derived(modelsForProvider(models, controller.draft.provider)); + // The full model string a provider+model selection maps to (for the "unavailable" + // fallback when the saved model leaves the catalog). + const fullModel = $derived( + fullModelFromSelection(controller.draft.provider, controller.draft.model), + ); + const validity = $derived(draftValidity(controller.draft, otherNames)); + const statusLabel = $derived(saveStatusLabel(controller.save.status)); + // Whether the current provider+model actually resolves inside the catalog. + const selectedResolves = $derived( + controller.draft.provider !== "" && + modelsForProvider(models, controller.draft.provider).includes(controller.draft.model), + ); +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-3"> + <span class="text-[10px] uppercase opacity-60">Name</span> + <input + type="text" + class="input input-bordered input-sm w-full" + placeholder="Enter a name…" + aria-label="Named agent name" + value={controller.draft.name} + oninput={(e) => controller.setName(e.currentTarget.value)} + /> + + <span class="mt-1 text-[10px] uppercase opacity-60">Provider</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent provider" + value={controller.draft.provider} + onchange={(e) => controller.setProvider(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider…</option> + {/if} + {#each providers as provider (provider)} + <option value={provider}>{providerLabel(provider)}</option> + {/each} + </select> + + <span class="mt-1 text-[10px] uppercase opacity-60">Model</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent model" + value={controller.draft.model} + disabled={controller.draft.provider === ""} + onchange={(e) => controller.setModel(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider first</option> + {:else if controller.draft.model === ""} + <option value="" disabled>Select a model…</option> + {/if} + {#each providerModels as model (model)} + <option value={model}>{model}</option> + {/each} + {#if !selectedResolves && controller.draft.model !== ""} + <!-- The saved selection is no longer in the live catalog (its provider/ + credential was removed) — keep showing it as unavailable rather than + silently rewriting it. --> + <option value={controller.draft.model} disabled> + {fullModel} (unavailable) + </option> + {/if} + </select> + + {#if !validity.usable && validity.message !== null} + <p class="mt-1 text-xs text-warning" role="status">{validity.message}</p> + {/if} + + <div class="mt-2 flex items-center justify-between gap-2"> + <span + class="text-xs" + class:text-error={controller.save.status === "error" && controller.save.error !== null} + class:opacity-60={controller.save.status !== "saving" && controller.save.status !== "error"} + role="status" + > + {statusLabel}{#if controller.save.error !== null}: {controller.save.error}{/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs text-error" + aria-label="Remove named agent" + disabled={controller.saving} + onclick={() => controller.remove()} + > + Remove + </button> + </div> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.svelte b/src/features/named-agents/ui/NamedAgentsView.svelte new file mode 100644 index 0000000..743ac20 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { NamedAgent } from "../logic/types"; + import type { NamedAgentPorts } from "./controller.svelte"; + import { createCardController, type CardController } from "./controller.svelte"; + import NamedAgentCard from "./NamedAgentCard.svelte"; + + interface Row { + key: number; + agent: NamedAgent | null; + controller: CardController; + } + + let { + agents, + models, + ports, + }: { + /** The persisted Named Agents (seed for the cards; the store stays authoritative for consumers). */ + agents: readonly NamedAgent[]; + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + ports: NamedAgentPorts; + } = $props(); + + // The view OWNS the editable card rows (each a controller + its last-saved + // canonical agent). Seeded once from the `agents` snapshot on mount (untracked + // so a later store update — which this view drove — never clobbers an + // in-flight draft). The store remains authoritative for other consumers. + let rows = $state<Row[]>([]); + let nextKey = $state(0); + let seeded = $state(false); + + $effect(() => { + untrack(() => { + if (seeded) return; + seeded = true; + for (const a of agents) addRow(a); + }); + }); + + /** + * The other rows' names (excluding the row with `rowKey`) for the uniqueness + * hint. Identity is the ROW's stable `key` — a draft (`agent === null`) must + * still exclude ITSELF (otherwise a fresh draft collides with its own name), + * while any OTHER draft is a genuine sibling. + */ + function otherNamesOf(rowKey: number): readonly string[] { + const out: string[] = []; + for (const r of rows) { + if (r.key === rowKey) continue; + out.push(r.agent !== null ? r.agent.name : r.controller.draft.name); + } + return out; + } + + /** + * Live uniqueness predicate for a card: true when `name` collides with a + * SIBLING row (excludes the card's own row by its stable `rowKey` — not by a + * nullable persisted id, so a draft excludes itself exactly once). + */ + function isNameTakenFor(rowKey: number): (name: string) => boolean { + return (name: string): boolean => { + const lower = name.trim().toLowerCase(); + for (const r of rows) { + if (r.key === rowKey) continue; + const candidate = r.agent !== null ? r.agent.name : r.controller.draft.name; + if (candidate.trim().toLowerCase() === lower) return true; + } + return false; + }; + } + + function addRow(agent: NamedAgent | null): void { + // Capture the row's stable key FIRST so the controller's callbacks reconcile + // THIS row only — never a sibling draft (`agent === null` matches all). + const key = nextKey++; + const controller = createCardController({ + agent, + savedModel: agent?.model ?? "", + isNameTaken: isNameTakenFor(key), + ports, + onChanged: (saved) => replaceAgent(key, saved), + onRemoved: () => removeRow(key), + }); + rows = [...rows, { key, agent, controller }]; + } + + function replaceAgent(key: number, saved: NamedAgent): void { + rows = rows.map((r) => (r.key === key ? { ...r, agent: saved } : r)); + } + + function removeRow(key: number): void { + const row = rows.find((r) => r.key === key); + if (row === undefined) return; + row.controller.dispose(); + rows = rows.filter((r) => r.key !== key); + } + + function addDraft(): void { + addRow(null); + } +</script> + +<div class="flex flex-col gap-2"> + <ul class="flex flex-col gap-2"> + {#each rows as row (row.key)} + <li> + <NamedAgentCard + models={models} + agent={row.agent} + controller={row.controller} + otherNames={otherNamesOf(row.key)} + /> + </li> + {/each} + </ul> + + {#if rows.length === 0} + <p class="text-xs opacity-60">No named agents yet.</p> + {/if} + + <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={addDraft}> + + Add Named Agent + </button> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.test.ts b/src/features/named-agents/ui/NamedAgentsView.test.ts new file mode 100644 index 0000000..bac4e25 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.test.ts @@ -0,0 +1,181 @@ +import { render, screen } from "@testing-library/svelte"; +import type { UserEvent } from "@testing-library/user-event"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import type { NamedAgentPorts } from "./controller.svelte"; +import NamedAgentsView from "./NamedAgentsView.svelte"; + +// ── Fakes for the injected ports (ONLY the outermost edge — no sibling module +// is mocked). A modest `debounceMs` keeps name edits coalesced into one save +// (with 0, each keystroke fires its own update) while provider/model saves stay +// immediate (the model setter dispatches synchronously). +function makePorts(): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: { id: string; input: NamedAgentInput }[]; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: { id: string; input: NamedAgentInput }[] = []; + const removed: string[] = []; + const ports: NamedAgentPorts = { + create: (async (input) => { + created.push(input); + return { ok: true, agent: { id: `new-${created.length}`, ...input } }; + }) as CreateNamedAgent, + update: (async (id, input) => { + updated.push({ id, input }); + return { ok: true, agent: { id, ...input } }; + }) as UpdateNamedAgent, + remove: (async (id) => { + removed.push(id); + return { ok: true } as const; + }) as DeleteNamedAgent, + debounceMs: 150, + }; + return { ports, created, updated, removed }; +} + +const MODELS = ["opencode-1/claude-sonnet-4.5", "anthropic/sonnet"] as unknown as readonly string[]; + +const DUP_MESSAGE = "A named agent with this name already exists."; + +function renderView() { + const fakes = makePorts(); + render(NamedAgentsView, { props: { agents: [], models: MODELS, ports: fakes.ports } }); + return fakes; +} + +async function addDraft(user: UserEvent): Promise<void> { + await user.click(screen.getByRole("button", { name: "+ Add Named Agent" })); +} + +async function typeName(index: number, name: string): Promise<void> { + const inputs = screen.getAllByLabelText("Named agent name"); + const input = inputs[index]; + if (input === undefined) throw new Error(`No named-agent name input at index ${index}`); + await userEvent.setup().clear(input); + await userEvent.setup().type(input, name); +} + +async function completeDraft(index: number): Promise<void> { + const user = userEvent.setup(); + // Provider first (the model select is disabled until a provider is chosen). + const providerSelect = screen.getAllByLabelText("Named agent provider")[index]; + if (providerSelect === undefined) throw new Error(`No provider select at index ${index}`); + await user.selectOptions(providerSelect, "opencode-1"); + const modelSelect = screen.getAllByLabelText("Named agent model")[index]; + if (modelSelect === undefined) throw new Error(`No model select at index ${index}`); + await user.selectOptions(modelSelect, "claude-sonnet-4.5"); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("NamedAgentsView", () => { + it("a fresh single draft does not self-collide with its own name", async () => { + renderView(); + await addDraft(userEvent.setup()); + await typeName(0, "Coding Agent"); + + // The card is not yet usable (no model) so it shows a model hint, but it + // must NOT report its own draft name as an existing duplicate. + await screen.findByText("Select a provider."); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with DISTINCT names neither self-collide", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Each must see the OTHER's name as an other-name, never its OWN. + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with the SAME name are a genuine sibling collision (each flags it)", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Coding Agent"); + + // Duplicate names among DISTINCT rows are invalid — both rows must report it + // (the self is excluded, but the sibling's identical name is a real collision). + expect((await screen.findAllByText(DUP_MESSAGE)).length).toBe(2); + }); + + it("saving one of multiple drafts persists only that row (distinct records)", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Both drafts complete to usable cards → each dispatches its OWN create. + await completeDraft(0); + await completeDraft(1); + + await vi.waitFor(() => expect(fakes.created).toHaveLength(2)); + expect(fakes.created[0]?.name).toBe("Coding Agent"); + expect(fakes.created[1]?.name).toBe("Writer"); + // Each persisted record got its own server-minted id (a single draft must + // not clobber its sibling's reconciling to the same record). + expect(await screen.findAllByText("Saved")).toHaveLength(2); + }); + + it("after a draft persists, a sibling draft does not self-collide and the saved row re-saves on edit", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await completeDraft(0); + await vi.waitFor(() => expect(fakes.created).toHaveLength(1)); + + // Add a second draft with a DIFFERENT name. After row 0 is persisted, this + // still-draft row must not treat its own name as a duplicate. + await addDraft(user); + await typeName(1, "Writer"); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + + // The saved card's controller now owns a real id → a later edit targets an + // UPDATE of the SAME row (id new-1), never a fresh create. + await typeName(0, "Coding Agent X"); + await vi.waitFor(() => expect(fakes.updated.length).toBeGreaterThanOrEqual(1)); + expect(fakes.updated[0]?.id).toBe("new-1"); + expect(fakes.updated[0]?.input.name).toBe("Coding Agent X"); + // Still exactly one create — the edit reconciled, not re-created. + expect(fakes.created).toHaveLength(1); + }); + + it("removing one of multiple drafts removes only that row", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Remove the SECOND card — the first must survive. + const removeButtons = screen.getAllByRole("button", { name: "Remove named agent" }); + const second = removeButtons[1]; + if (second === undefined) throw new Error("Expected a second Remove button"); + await user.click(second); + + const inputs = await screen.findAllByLabelText("Named agent name"); + expect(inputs).toHaveLength(1); + expect((inputs[0] as HTMLInputElement).value).toBe("Coding Agent"); + }); +}); diff --git a/src/features/named-agents/ui/controller.svelte.ts b/src/features/named-agents/ui/controller.svelte.ts new file mode 100644 index 0000000..51c85b0 --- /dev/null +++ b/src/features/named-agents/ui/controller.svelte.ts @@ -0,0 +1,257 @@ +// Injected shell for Named Agent auto-save: owns the reactive per-card editing +// + save lifecycle (the thin rune wrapper over `logic/auto-save`), the debounce +// timer for name edits, and the monotonic sequence guard that stops an older +// save response clobbering a newer edit. No ambient state: ONE controller per +// card, created by the view and disposed on unmount. The network calls +// (create/update/delete) are INJECTED ports — this module never touches +// fetch/DOM. +// +// The pure model (`logic/auto-save`) does the status/validity reasoning; this +// controller only owns the effectful plumbing + the sequence guard. A live +// `isNameTaken` callback (supplied by the owning view against the CURRENT +// sibling list) keeps the controller from persisting a duplicate name. + +import { + type AgentDraft, + type AgentSaved, + draftModel, + type SaveStatus, + saveStatus, +} from "../logic/auto-save"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import { isUsableModel, normalizeName } from "../logic/validate"; + +/** Named-agent edit port bundle (injected by the composition root). */ +export interface NamedAgentPorts { + create: CreateNamedAgent; + update: UpdateNamedAgent; + remove: DeleteNamedAgent; + /** Debounce delay (ms) for NAME edits only (provider/model saves are immediate). */ + debounceMs?: number; +} + +export interface CardSaveState { + /** The current reactive save status (drives the status line). */ + readonly status: SaveStatus; + /** A save-failure error message, or null. */ + readonly error: string | null; +} + +/** A fully-wired Named Agent card controller (one per card row). */ +export interface CardController { + /** The current reactive status + error. */ + readonly save: CardSaveState; + /** Read the current draft (const snapshots already applied via the setter methods). */ + readonly draft: AgentDraft; + /** Whether the card is currently awaiting a save response. */ + readonly saving: boolean; + /** Bind a provider id into the draft (immediate save once a model is chosen). */ + setProvider(provider: string): void; + /** Bind a model suffix under the current provider (immediate save). */ + setModel(model: string): void; + /** Bind a raw name edit (debounced save when it becomes usable). */ + setName(name: string): void; + /** Remove the Named Agent (for an existing card) — notifies via `onRemoved`. */ + remove(): void; + /** Dispose timers; call on unmount. */ + dispose(): void; +} + +export interface CardControllerOptions { + /** The existing Named Agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The saved full `<provider>/<model>` ('' for a new draft). */ + savedModel: string; + /** + * Live uniqueness predicate: true when `name` is already taken by a SIBLING + * card (the view EXCLUDES this card's own id). Drives the controller's + * usable-name gate so a duplicate is never persisted. + */ + isNameTaken: (name: string) => boolean; + ports: NamedAgentPorts; + /** Fired with the canonical saved record when a create/update settles. */ + onChanged: (agent: NamedAgent) => void; + /** Fired when the card should leave the list (after a successful remove, or an unsaved draft). */ + onRemoved: () => void; +} + +/** + * A card controller bound to one Named Agent (existing) or a new draft. + * + * The controller owns ONLY this card's draft + save lifecycle; the sibling list + * (for uniqueness + reconciling the authoritative list after a save) is the + * parent view's. `onChanged` lets the parent replace the card's canonical record + * on success; `onRemoved` fires when the card is to be dropped. + */ +export function createCardController(opts: CardControllerOptions): CardController { + const debounceMs = opts.ports.debounceMs ?? 400; + + // ── The draft: raw form controls (name/provider/model) owned reactively. ── + const { agent, savedModel } = opts; + let id = $state<string | null>(agent?.id ?? null); + let name = $state<string>(agent?.name ?? ""); + let provider = $state<string>(savedModel === "" ? "" : (savedModel.split("/")[0] ?? "")); + let model = $state<string>( + savedModel === "" ? "" : savedModel.slice(savedModel.indexOf("/") + 1), + ); + + // ── Saved-state + in-flight/sequence tracking. ── + // `savedName`/`savedModelValue` = the last PERSISTED definition; `error` = + // last save failure. `editSeq` increments on every input change; a save + // captures the seq at dispatch time and applies its result ONLY if it is still + // the latest — an older response can never overwrite a newer edit (§5.4). + let savedName = $state<string>(agent?.name ?? ""); + let savedModelValue = $state<string>(savedModel); + let saving = $state(false); + let error = $state<string | null>(null); + + let editSeq = 0; + let nameTimer: ReturnType<typeof setTimeout> | null = null; + + function currentDraft(): AgentDraft { + return { id, name, provider, model }; + } + + function bump(): number { + return ++editSeq; + } + + function nameUsable(): boolean { + // A usable name is nonempty + not already taken by a sibling card. + const normalized = normalizeName(name); + return normalized !== "" && !opts.isNameTaken(normalized); + } + + function usable(full: string): boolean { + return nameUsable() && isUsableModel(full); + } + + // A save is owed + legal to dispatch right now when the card is usable and + // differs from the persisted definition (or is a fresh, usable draft). + function shouldSaveNow(): boolean { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return false; + if (id === null && savedName === "" && savedModelValue === "") return true; // fresh draft + return normalizeName(d.name) !== savedName || full !== savedModelValue; + } + + function doSave(seq: number): void { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return; + saving = true; + error = null; + + function settle(ok: boolean, saved?: NamedAgent, err?: string): void { + // Stale-response guard: only apply if this is still the latest edit. + if (seq !== editSeq) return; + saving = false; + if (ok && saved) { + savedName = saved.name; + savedModelValue = saved.model; + id = saved.id; + opts.onChanged(saved); + } else { + error = err ?? "Save failed"; + } + } + + const input: NamedAgentInput = { name: normalizeName(d.name), model: full }; + if (id === null) { + void opts.ports.create(input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } else { + void opts.ports.update(id, input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } + } + + function scheduleNameSave(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + const seq = editSeq; + nameTimer = setTimeout(() => { + nameTimer = null; + if (shouldSaveNow()) doSave(seq); + }, debounceMs); + } + + function saveImmediately(): void { + if (nameTimer !== null) { + clearTimeout(nameTimer); + nameTimer = null; + } + const seq = editSeq; + if (shouldSaveNow()) doSave(seq); + } + + // Reactive derived status: from the current draft + saved + in-flight + error, + // via the PURE `saveStatus` (this shell supplies only the orchestration inputs). + const save = $derived.by<CardSaveState>(() => { + const d = currentDraft(); + const saved: AgentSaved = { id, name: savedName, model: savedModelValue }; + return { status: saveStatus(d, saved, saving, error), error }; + }); + + return { + get save(): CardSaveState { + return save; + }, + get draft(): AgentDraft { + return currentDraft(); + }, + get saving(): boolean { + return saving; + }, + setProvider(next: string): void { + bump(); + provider = next; + // Changing provider clears the prior model if it may not belong to the new + // one; the model dropdown re-populates from the new provider. Persist only + // once a valid model is selected (§5.5) → the model change saves immediately. + model = ""; + }, + setModel(next: string): void { + bump(); + model = next; + saveImmediately(); + }, + setName(next: string): void { + bump(); + name = next; + scheduleNameSave(); + }, + remove(): void { + if (id === null) { + // An unsaved draft — nothing server-side to delete. + opts.onRemoved(); + return; + } + saving = true; + void opts.ports.remove(id).then( + () => { + saving = false; + opts.onRemoved(); + }, + (e) => { + saving = false; + error = String(e); + }, + ); + }, + dispose(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + nameTimer = null; + }, + }; +} diff --git a/src/features/named-agents/ui/controller.test.ts b/src/features/named-agents/ui/controller.test.ts new file mode 100644 index 0000000..1655a31 --- /dev/null +++ b/src/features/named-agents/ui/controller.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { NamedAgent, NamedAgentInput, NamedAgentSaveResult } from "../logic/types"; +import { createCardController, type NamedAgentPorts } from "./controller.svelte"; + +function fakePorts(overrides?: { + create?: NamedAgentPorts["create"]; + update?: NamedAgentPorts["update"]; + remove?: NamedAgentPorts["remove"]; + debounceMs?: number; +}): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: Array<{ id: string; input: NamedAgentInput }>; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: Array<{ id: string; input: NamedAgentInput }> = []; + const removed: string[] = []; + const settle = (r: NamedAgentSaveResult) => Promise.resolve(r); + return { + ports: { + create: + overrides?.create ?? + ((input) => { + created.push(input); + return settle({ ok: true, agent: { id: `new-${created.length}`, ...input } }); + }), + update: + overrides?.update ?? + ((id, input) => { + updated.push({ id, input }); + return settle({ ok: true, agent: { id, ...input } }); + }), + remove: + overrides?.remove ?? + ((id) => { + removed.push(id); + return Promise.resolve({ ok: true } as const); + }), + debounceMs: overrides?.debounceMs ?? 0, + } as NamedAgentPorts, + created, + updated, + removed, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createCardController", () => { + it("creates a new draft when it becomes fully usable and the model is set", async () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const changed: NamedAgent[] = []; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: (a) => changed.push(a), + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + // The model setter saves immediately (the create is dispatched synchronously). + expect(created).toHaveLength(1); + expect(created[0]).toEqual({ name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }); + // The canonical record + onChanged land after the create promise resolves. + await vi.runAllTimersAsync(); + expect(changed).toHaveLength(1); + expect(changed[0]?.id).toBe("new-1"); + c.dispose(); + }); + + it("does not create with only a name (needs a provider + model)", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + vi.advanceTimersByTime(500); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("does not persist a name that is already taken", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: (name) => name.toLowerCase() === "coding agent", + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("updates an existing card and reports Saved once clean", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude-sonnet-4.5", + }; + const { ports, updated } = fakePorts({ debounceMs: 100 }); + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + expect(c.save.status).toBe("saved"); + c.setName("Coding X"); + // Within the debounce window the edit is unsaved → "dirty". + await vi.advanceTimersByTimeAsync(20); + expect(c.save.status).toBe("dirty"); + // Past the debounce the name save fires → the update is dispatched. + await vi.advanceTimersByTimeAsync(200); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe("a1"); + expect(updated[0]?.input.name).toBe("Coding X"); + // After the update promise resolves the card reads clean → "saved". + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("saved"); + c.dispose(); + }); + + it("marks the status as error and keeps the draft on save failure", async () => { + vi.useFakeTimers(); + const { ports } = fakePorts({ + create: () => Promise.resolve({ ok: false, error: "duplicate name" as const }), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding"); + c.setProvider("opencode-1"); + c.setModel("claude"); + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("error"); + expect(c.save.error).toBe("duplicate name"); + expect(c.draft.name).toBe("Coding"); + c.dispose(); + }); + + it("guards stale responses: a newer edit is not overwritten by an older save result", async () => { + vi.useFakeTimers(); + const pending: { resolve: ((r: NamedAgentSaveResult) => void) | null } = { resolve: null }; + const { ports } = fakePorts({ + create: () => new Promise<NamedAgentSaveResult>((res) => (pending.resolve = res)), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + // Kick a save in flight, then edit again before it resolves. + c.setName("A"); + c.setProvider("opencode-1"); + c.setModel("claude"); + c.setName("B"); // sequence bumps past the in-flight save + expect(pending.resolve).not.toBeNull(); + pending.resolve?.({ ok: true, agent: { id: "x", name: "A", model: "opencode-1/claude" } }); + await vi.runAllTimersAsync(); + // The stale "A" create must NOT have applied the old result (saving cleared, + // but the draft still reads B and no onChanged with "A"). + expect(c.draft.name).toBe("B"); + c.dispose(); + }); + + it("remove() on an existing card calls remove and drops via onRemoved", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude", + }; + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + await vi.runAllTimersAsync(); + expect(removed).toEqual(["a1"]); + expect(removedCard).toBe(true); + c.dispose(); + }); + + it("remove() on an unsaved draft drops it without a server call", () => { + vi.useFakeTimers(); + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + expect(removed).toHaveLength(0); + expect(removedCard).toBe(true); + c.dispose(); + }); +}); diff --git a/src/features/plugins/adapter/http.test.ts b/src/features/plugins/adapter/http.test.ts new file mode 100644 index 0000000..b1fe477 --- /dev/null +++ b/src/features/plugins/adapter/http.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPluginsHttp } from "./http"; + +/** A stub fetch that records requests and replies from a handler. */ +function stubFetch(handler: (url: string, init?: RequestInit) => Response | Promise<Response>) { + const impl = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return await handler(url, init); + }); + return impl as unknown as typeof fetch; +} + +const okJson = (body: unknown): Response => + ({ ok: true, status: 200, json: async () => body }) as unknown as Response; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("createPluginsHttp — manager routes (plan 05)", () => { + it("GET /plugins returns the parsed list (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ loaded: [], disabledWithReason: [], discovered: [], missing: [] }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.list()).resolves.toEqual({ + loaded: [], + disabledWithReason: [], + discovered: [], + missing: [], + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).list()).resolves.toBeNull(); + }); + + it("POST /plugins/scan posts to the scan route", async () => { + let seenUrl = ""; + let seenInit: RequestInit | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenInit = init; + return okJson({ root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.scan(); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/plugins/scan"); + expect(seenInit?.method).toBe("POST"); + }); + + it("POST /plugins/:id/unload sends the force flag in the body", async () => { + let seenBody: string | undefined; + const fetch1 = stubFetch((_url, init) => { + seenBody = init?.body as string; + return okJson({ ok: true, action: "unload", id: "mcp" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.unload("mcp", true); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ force: true }); + }); + + it("maps a blocked mutation (409) to ok:false with reason + dependents", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + ok: false, + action: "unload", + id: "heartbeat", + reason: "cascade-needs-force", + message: "blocked", + dependents: ["transport-http"], + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.unload("heartbeat", false); + expect(result).toEqual({ + ok: false, + error: "blocked", + reason: "cascade-needs-force", + dependents: ["transport-http"], + }); + }); + + it("load and reload hit their routes with the encoded id", async () => { + const urls: string[] = []; + const fetch1 = stubFetch((url) => { + urls.push(url); + return okJson({ ok: true, action: "load", id: "a/b" }); + }); + const http = createPluginsHttp("http://x", fetch1); + await http.load("a/b"); + await http.reload("a/b"); + expect(urls).toEqual(["http://x/plugins/a%2Fb/load", "http://x/plugins/a%2Fb/reload"]); + }); +}); + +describe("createPluginsHttp — workspace routes (plan 06 §6.6)", () => { + it("GET /workspaces/:id/plugins returns the parsed response (null on failure)", async () => { + const fetch1 = stubFetch(() => + okJson({ workspaceId: "ws", plugins: [], enabledProviderCount: 2 }), + ); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.workspacePlugins("ws")).resolves.toEqual({ + workspaceId: "ws", + plugins: [], + enabledProviderCount: 2, + }); + + const fetch2 = stubFetch(() => ({ ok: false, status: 503 }) as unknown as Response); + await expect(createPluginsHttp("http://x", fetch2).workspacePlugins("ws")).resolves.toBeNull(); + }); + + it("PUT /workspaces/:id/plugins/:pluginId sends the state body", async () => { + let seenUrl = ""; + let seenBody: string | undefined; + const fetch1 = stubFetch((url, init) => { + seenUrl = url; + seenBody = init?.body as string; + return okJson({ workspaceId: "ws", pluginId: "mcp", state: "enabled" }); + }); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "mcp", "enabled"); + expect(result.ok).toBe(true); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/mcp"); + expect(JSON.parse(seenBody ?? "{}")).toEqual({ state: "enabled" }); + }); + + it("maps a 409 refusal (core-locked) to ok:false with the reason", async () => { + const fetch1 = stubFetch( + () => + ({ + ok: false, + status: 409, + json: async () => ({ + workspaceId: "ws", + pluginId: "kernel", + reason: "core-locked", + message: "core is locked", + }), + }) as unknown as Response, + ); + const http = createPluginsHttp("http://x", fetch1); + const result = await http.setWorkspaceState("ws", "kernel", "disabled"); + expect(result).toEqual({ ok: false, error: "core is locked", reason: "core-locked" }); + }); + + it("GET providers-status returns the parsed response", async () => { + let seenUrl = ""; + const fetch1 = stubFetch((url) => { + seenUrl = url; + return okJson({ workspaceId: "ws", enabledProviderCount: 0 }); + }); + const http = createPluginsHttp("http://x", fetch1); + await expect(http.providersStatus("ws")).resolves.toEqual({ + workspaceId: "ws", + enabledProviderCount: 0, + }); + expect(seenUrl).toBe("http://x/workspaces/ws/plugins/providers-status"); + }); + + it("network throws degrade reads to null and mutations to ok:false", async () => { + const boom = vi.fn(async () => { + throw new Error("down"); + }) as unknown as typeof fetch; + const http = createPluginsHttp("http://x", boom); + await expect(http.list()).resolves.toBeNull(); + await expect(http.providersStatus("ws")).resolves.toBeNull(); + await expect(http.setWorkspaceState("ws", "mcp", "enabled")).resolves.toMatchObject({ + ok: false, + }); + }); +}); diff --git a/src/features/plugins/adapter/http.ts b/src/features/plugins/adapter/http.ts new file mode 100644 index 0000000..7814f4f --- /dev/null +++ b/src/features/plugins/adapter/http.ts @@ -0,0 +1,230 @@ +import type { + PluginListResponse, + PluginOpResponse, + PluginScanResponse, + SetWorkspacePluginStateBlocked, + SetWorkspacePluginStateRequest, + SetWorkspacePluginStateResponse, + WorkspacePluginProvidersStatusResponse, + WorkspacePluginsResponse, +} from "@dispatch/transport-contract"; + +/** + * Plugin HTTP effects — the injected edge that talks to the backend's plugin + * manager routes (plan 05) + per-workspace enablement routes (plan 06 §6.6). + * Mirrors the workspaces adapter's pattern: `httpBase` + an injected + * `fetchImpl` (testable without the network); reads return data or a safe + * `null` on failure (non-fatal), mutations return a typed result so a caller + * can surface the backend's `{ reason, message }` block. + * + * Endpoints: + * - `GET /plugins` → {@link PluginListResponse} (the two manager tables + missing strip) + * - `POST /plugins/scan` → {@link PluginScanResponse} (manual scan; never auto-loads) + * - `POST /plugins/:id/load` → {@link PluginOpResponse} + * - `POST /plugins/:id/unload` body `{ force }` → {@link PluginOpResponse} + * - `POST /plugins/:id/reload` → {@link PluginOpResponse} + * - `GET /workspaces/:id/plugins` → {@link WorkspacePluginsResponse} (READ triggers + * read-through materialization) + * - `PUT /workspaces/:id/plugins/:pluginId` body `{ state }` → + * {@link SetWorkspacePluginStateResponse} (409 `core-locked` / `unloaded`) + * - `GET /workspaces/:id/plugins/providers-status` → + * {@link WorkspacePluginProvidersStatusResponse} (zero-enabled-providers guardrail) + */ + +/** Outcome of a plugin-manager mutation (load / unload / reload). */ +export type PluginOpResult = + | { readonly ok: true; readonly response: Extract<PluginOpResponse, { ok: true }> } + | { + readonly ok: false; + readonly error: string; + readonly reason?: string; + /** For `cascade-needs-force`: the loaded dependents a force would remove. */ + readonly dependents?: readonly string[]; + }; + +/** Outcome of a per-workspace state write (`PUT .../plugins/:pluginId`). */ +export type WorkspaceStateResult = + | { readonly ok: true; readonly response: SetWorkspacePluginStateResponse } + | { readonly ok: false; readonly error: string; readonly reason?: string }; + +/** Outcome of the manual scan (`POST /plugins/scan`). */ +export type PluginScanResult = + | { readonly ok: true; readonly response: PluginScanResponse } + | { readonly ok: false; readonly error: string }; + +export interface PluginsHttp { + list(): Promise<PluginListResponse | null>; + scan(): Promise<PluginScanResult>; + load(id: string): Promise<PluginOpResult>; + unload(id: string, force: boolean): Promise<PluginOpResult>; + reload(id: string): Promise<PluginOpResult>; + /** Read one workspace's effective plugin states (triggers materialization). */ + workspacePlugins(workspaceId: string): Promise<WorkspacePluginsResponse | null>; + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: "enabled" | "disabled" | "unset", + ): Promise<WorkspaceStateResult>; + /** The zero-enabled-providers signal for the active workspace (§6.5). */ + providersStatus(workspaceId: string): Promise<WorkspacePluginProvidersStatusResponse | null>; +} + +async function errorBody(res: Response): Promise<{ + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; +}> { + try { + return (await res.json()) as { + error?: string; + reason?: string; + dependents?: readonly string[]; + message?: string; + }; + } catch { + return {}; + } +} + +/** + * Map a plugin-manager block/error response to a `PluginOpResult`. The + * backend's blocked shape is `{ ok: false, reason, message, dependents? }`; + * other failures degrade to `{ error }` only. + */ +async function opResult(res: Response): Promise<PluginOpResult> { + const body = await errorBody(res); + const message = body.message ?? body.error ?? `HTTP ${res.status}`; + return { + ok: false, + error: message, + ...(body.reason !== undefined ? { reason: body.reason } : {}), + ...(body.dependents !== undefined ? { dependents: body.dependents } : {}), + }; +} + +export function createPluginsHttp(httpBase: string, fetchImpl: typeof fetch): PluginsHttp { + const post = (path: string, body?: unknown): Promise<Response> => + fetchImpl(`${httpBase}${path}`, { + method: "POST", + ...(body !== undefined + ? { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } + : {}), + }); + + return { + async list(): Promise<PluginListResponse | null> { + try { + const res = await fetchImpl(`${httpBase}/plugins`); + if (!res.ok) return null; + return (await res.json()) as PluginListResponse; + } catch { + return null; + } + }, + + async scan(): Promise<PluginScanResult> { + try { + const res = await post("/plugins/scan"); + if (!res.ok) + return { ok: false, error: (await errorBody(res)).error ?? `HTTP ${res.status}` }; + return { ok: true, response: (await res.json()) as PluginScanResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Scan request failed" }; + } + }, + + async load(id): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/load`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Load request failed" }; + } + }, + + async unload(id, force): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/unload`, { force }); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unload request failed" }; + } + }, + + async reload(id): Promise<PluginOpResult> { + try { + const res = await post(`/plugins/${encodeURIComponent(id)}/reload`); + if (!res.ok) return await opResult(res); + return { + ok: true, + response: (await res.json()) as Extract<PluginOpResponse, { ok: true }>, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Reload request failed" }; + } + }, + + async workspacePlugins(workspaceId): Promise<WorkspacePluginsResponse | null> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginsResponse; + } catch { + return null; + } + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise<WorkspaceStateResult> { + const body: SetWorkspacePluginStateRequest = { state }; + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/${encodeURIComponent(pluginId)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }, + ); + if (!res.ok) { + const blocked = (await errorBody(res)) as Partial<SetWorkspacePluginStateBlocked> & { + error?: string; + }; + return { + ok: false, + error: blocked.message ?? blocked.error ?? `HTTP ${res.status}`, + ...(blocked.reason !== undefined ? { reason: blocked.reason } : {}), + }; + } + return { ok: true, response: (await res.json()) as SetWorkspacePluginStateResponse }; + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : "Set plugin state request failed", + }; + } + }, + + async providersStatus(workspaceId): Promise<WorkspacePluginProvidersStatusResponse | null> { + try { + const res = await fetchImpl( + `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/plugins/providers-status`, + ); + if (!res.ok) return null; + return (await res.json()) as WorkspacePluginProvidersStatusResponse; + } catch { + return null; + } + }, + }; +} diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts new file mode 100644 index 0000000..f79ed98 --- /dev/null +++ b/src/features/plugins/index.ts @@ -0,0 +1,24 @@ +export type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +export { createPluginsHttp } from "./adapter/http"; +export { computeCascade } from "./logic/cascade"; +export type { HighlightSets } from "./logic/highlight"; +export { dependencyHighlightSets } from "./logic/highlight"; +export { isLastProvider, unloadBlockReason } from "./logic/last-provider"; +export { autoCheckBackendPrereqs } from "./logic/prereqs"; +export type { + PluginNode, + PluginSection, + WorkspaceLayoutPort, +} from "./logic/types"; +export { TIER_ORDER, tierLabel } from "./logic/types"; +export { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./logic/view-model"; +export { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./logic/workspace-row"; +export type { PluginsStore, WorkspacePluginsView } from "./store.svelte"; +export { createPluginsStore } from "./store.svelte"; +export { default as PluginManager } from "./ui/PluginManager.svelte"; +export { default as WorkspacePluginModal } from "./ui/WorkspacePluginModal.svelte"; diff --git a/src/features/plugins/logic/cascade.test.ts b/src/features/plugins/logic/cascade.test.ts new file mode 100644 index 0000000..109c08b --- /dev/null +++ b/src/features/plugins/logic/cascade.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import { computeCascade } from "./cascade"; +import { node } from "./test-fixtures"; + +describe("computeCascade", () => { + it("returns [] when the target has no dependents", () => { + const nodes = [node({ id: "a" }), node({ id: "b" })]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("returns the direct loaded dependents", () => { + const nodes = [ + node({ id: "heartbeat" }), + node({ id: "transport-http", dependsOn: ["heartbeat"] }), + ]; + expect(computeCascade("heartbeat", nodes)).toEqual(["transport-http"]); + }); + + it("is transitive (dependent of dependent)", () => { + const nodes = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual(["b", "c"]); + }); + + it("does NOT traverse through an UNLOADED intermediate (loaded-subgraph closure — server mirror)", () => { + // c depends on b, b is unloaded: unloading a cascades to b only (b is not + // collected — unloaded) and does NOT reach c (its dep chain is already unmet). + const nodes = [ + node({ id: "a" }), + node({ id: "b", loaded: false, section: "discovered", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(computeCascade("a", nodes)).toEqual([]); + }); + + it("ignores plugins that merely share a dependency (direction matters)", () => { + const nodes = [node({ id: "dep" }), node({ id: "x", dependsOn: ["dep"] })]; + expect(computeCascade("x", nodes)).toEqual([]); + }); + + it("handles diamond dependencies without duplicates", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "left", dependsOn: ["root"] }), + node({ id: "right", dependsOn: ["root"] }), + node({ id: "bottom", dependsOn: ["left", "right"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["bottom", "left", "right"]); + }); + + it("returns [] for a target that is not in the list", () => { + expect(computeCascade("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("is deterministic (alphabetical order)", () => { + const nodes = [ + node({ id: "root" }), + node({ id: "zeta", dependsOn: ["root"] }), + node({ id: "alpha", dependsOn: ["root"] }), + ]; + expect(computeCascade("root", nodes)).toEqual(["alpha", "zeta"]); + }); +}); diff --git a/src/features/plugins/logic/cascade.ts b/src/features/plugins/logic/cascade.ts new file mode 100644 index 0000000..0cc6834 --- /dev/null +++ b/src/features/plugins/logic/cascade.ts @@ -0,0 +1,42 @@ +import type { PluginNode } from "./types"; + +/** + * The transitive set of LOADED dependents that would also be unloaded if + * `targetId` were unloaded — the §8 cascade-uncheck warning's list ("unloading + * X also unchecks Y, Z"). Client mirror of the backend's + * `computeUnloadCascade` (plan 05): the reverse-dependency closure computed + * over the LOADED subgraph only (an unloaded intermediate breaks the chain — + * a loaded dependent behind it is already unmet and is not part of THIS + * cascade). The server still enforces the real cascade; the FE computes the + * same set to warn before the request goes out. + * + * Pure input → output; deterministic order (alphabetical by id) so tests and + * renderings are stable. + */ +export function computeCascade(targetId: string, nodes: readonly PluginNode[]): readonly string[] { + // Reverse edges within the loaded subgraph: dep id → loaded dependent ids. + const dependentsOf = new Map<string, string[]>(); + for (const node of nodes) { + if (!node.loaded) continue; + for (const dep of node.dependsOn) { + const list = dependentsOf.get(dep); + if (list) list.push(node.id); + else dependentsOf.set(dep, [node.id]); + } + } + + // BFS over reverse edges, collecting every reached loaded dependent. + const out: string[] = []; + const seen = new Set<string>([targetId]); + const queue = [targetId]; + while (queue.length > 0) { + const current = queue.shift() as string; + for (const dependent of dependentsOf.get(current) ?? []) { + if (seen.has(dependent)) continue; + seen.add(dependent); + out.push(dependent); + queue.push(dependent); + } + } + return out.sort((a, b) => a.localeCompare(b)); +} diff --git a/src/features/plugins/logic/highlight.test.ts b/src/features/plugins/logic/highlight.test.ts new file mode 100644 index 0000000..616f7ad --- /dev/null +++ b/src/features/plugins/logic/highlight.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { dependencyHighlightSets } from "./highlight"; +import { node } from "./test-fixtures"; + +describe("dependencyHighlightSets", () => { + const nodes = [ + node({ id: "lib", loaded: false, section: "discovered" }), + node({ id: "mid", dependsOn: ["lib"] }), + node({ id: "app", dependsOn: ["mid", "lib"] }), + ]; + + it("returns empty sets for no hover", () => { + const sets = dependencyHighlightSets(null, nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering an item highlights its DIRECT dependencies (one hop)", () => { + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps).toEqual(new Set(["mid", "lib"])); + expect(sets.dependents.size).toBe(0); + }); + + it("hovering a dependency highlights its dependents (both directions work)", () => { + const sets = dependencyHighlightSets("lib", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents).toEqual(new Set(["mid", "app"])); + }); + + it("hovering a middle node highlights BOTH directions", () => { + const sets = dependencyHighlightSets("mid", nodes); + expect(sets.deps).toEqual(new Set(["lib"])); + expect(sets.dependents).toEqual(new Set(["app"])); + }); + + it("is one hop — not the transitive closure", () => { + // app → mid → lib: hovering app highlights mid + lib (both direct deps), + // but hovering lib highlights only DIRECT dependents (mid, app) — already + // one hop. The one-hop rule shows in: a plugin two hops away via an + // unrelated chain is not highlighted. + const chain = [ + node({ id: "a" }), + node({ id: "b", dependsOn: ["a"] }), + node({ id: "c", dependsOn: ["b"] }), + ]; + expect(dependencyHighlightSets("c", chain).deps).toEqual(new Set(["b"])); + }); + + it("returns empty sets for an unknown hovered id", () => { + const sets = dependencyHighlightSets("ghost", nodes); + expect(sets.deps.size).toBe(0); + expect(sets.dependents.size).toBe(0); + }); + + it("works across sections (a loaded row highlights an unloaded dependency)", () => { + // `app` loaded, `lib` discovered: hovering app still highlights lib. + const sets = dependencyHighlightSets("app", nodes); + expect(sets.deps.has("lib")).toBe(true); + }); +}); diff --git a/src/features/plugins/logic/highlight.ts b/src/features/plugins/logic/highlight.ts new file mode 100644 index 0000000..1bcfaad --- /dev/null +++ b/src/features/plugins/logic/highlight.ts @@ -0,0 +1,37 @@ +import type { PluginNode } from "./types"; + +/** The two highlight sets for the §8 bidirectional dependency hover. */ +export interface HighlightSets { + /** The hovered row's DIRECT dependencies (highlighted when hovering an item). */ + readonly deps: ReadonlySet<string>; + /** The loaded/unloaded rows that DIRECTLY depend on the hovered row + * (highlighted when hovering a dependency). */ + readonly dependents: ReadonlySet<string>; +} + +const EMPTY: HighlightSets = { deps: new Set(), dependents: new Set() }; + +/** + * Bidirectional hover highlighting (§8): hover any item → its DIRECT + * dependencies highlight; hover a dependency → its dependents highlight. + * One hop in each direction (the plan's grammar is "its dependencies" / + * "its dependents" — the visual neighborhood, not the transitive closure; + * the transitive closures live in `computeCascade` / `autoCheckBackendPrereqs`). + * + * Works across BOTH manager tables (a loaded row can highlight an unloaded + * dependency row and vice versa) — the node list spans the whole catalog. + * Pure input → output; `hoveredId` null (no hover) → both sets empty. + */ +export function dependencyHighlightSets( + hoveredId: string | null, + nodes: readonly PluginNode[], +): HighlightSets { + if (hoveredId === null) return EMPTY; + const hovered = nodes.find((n) => n.id === hoveredId); + if (hovered === undefined) return EMPTY; + const deps = new Set(hovered.dependsOn); + const dependents = new Set( + nodes.filter((n) => n.id !== hoveredId && n.dependsOn.includes(hoveredId)).map((n) => n.id), + ); + return { deps, dependents }; +} diff --git a/src/features/plugins/logic/last-provider.test.ts b/src/features/plugins/logic/last-provider.test.ts new file mode 100644 index 0000000..f85ba89 --- /dev/null +++ b/src/features/plugins/logic/last-provider.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { isLastProvider, unloadBlockReason } from "./last-provider"; +import { node } from "./test-fixtures"; + +describe("isLastProvider", () => { + it("is true for a loaded provider when it is the only one", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + expect(isLastProvider("opencode", nodes)).toBe(true); + }); + + it("is false for a loaded provider when another provider is loaded", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + ]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for a non-provider target (even when alone)", () => { + const nodes = [node({ id: "solo", tier: "standard" })]; + expect(isLastProvider("solo", nodes)).toBe(false); + }); + + it("is false for an UNLOADED provider", () => { + const nodes = [node({ id: "opencode", tier: "providers", loaded: false })]; + expect(isLastProvider("opencode", nodes)).toBe(false); + }); + + it("is false for an unknown id", () => { + expect(isLastProvider("ghost", [])).toBe(false); + }); + + it("is false for a core-only list (no providers at all)", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(isLastProvider("kernel", nodes)).toBe(false); + }); +}); + +describe("unloadBlockReason", () => { + it("explains the core lock", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + const reason = unloadBlockReason("kernel", nodes); + expect(reason).toContain("Core plugins cannot be unloaded"); + }); + + it("explains the last-provider lock", () => { + const nodes = [node({ id: "opencode", tier: "providers" })]; + const reason = unloadBlockReason("opencode", nodes); + expect(reason).toContain("last provider"); + }); + + it("is null for an interactive row", () => { + const nodes = [ + node({ id: "opencode", tier: "providers" }), + node({ id: "kimi", tier: "providers" }), + node({ id: "app" }), + ]; + expect(unloadBlockReason("app", nodes)).toBeNull(); + expect(unloadBlockReason("opencode", nodes)).toBeNull(); + }); + + it("prefers the core lock when both would apply", () => { + const nodes = [node({ id: "kernel", tier: "core", locked: true })]; + expect(unloadBlockReason("kernel", nodes)).toContain("Core"); + }); + + it("is null for an unknown id", () => { + expect(unloadBlockReason("ghost", [])).toBeNull(); + }); +}); diff --git a/src/features/plugins/logic/last-provider.ts b/src/features/plugins/logic/last-provider.ts new file mode 100644 index 0000000..279df4d --- /dev/null +++ b/src/features/plugins/logic/last-provider.ts @@ -0,0 +1,31 @@ +import type { PluginNode } from "./types"; + +/** + * Whether unloading `targetId` would remove the LAST loaded provider — the + * §8 hard lock (the one unload the server refuses outright, never force-able). + * The UI disables the control with an explanatory tooltip; the server still + * enforces the rule. Pure input → output. + */ +export function isLastProvider(targetId: string, nodes: readonly PluginNode[]): boolean { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined || !target.loaded || target.tier !== "providers") return false; + const loadedProviders = nodes.filter((n) => n.loaded && n.tier === "providers"); + return loadedProviders.length <= 1; +} + +/** + * Why the row's load/unload control is disabled, or null when it is + * interactive. Composes the two client-mirrored locks: + * - tier "core" (`locked`) → the server refuses to unload it (§3/§5); + * - the last loaded provider → the §8 hard lock. + * Missing rows have no control at all (handled separately by the UI). + */ +export function unloadBlockReason(targetId: string, nodes: readonly PluginNode[]): string | null { + const target = nodes.find((n) => n.id === targetId); + if (target === undefined) return null; + if (target.locked) return "Core plugins cannot be unloaded — they are part of the runtime."; + if (isLastProvider(targetId, nodes)) { + return "Cannot unload the last provider — Dispatch needs at least one to pick models."; + } + return null; +} diff --git a/src/features/plugins/logic/prereqs.test.ts b/src/features/plugins/logic/prereqs.test.ts new file mode 100644 index 0000000..704399c --- /dev/null +++ b/src/features/plugins/logic/prereqs.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { autoCheckBackendPrereqs } from "./prereqs"; +import { node } from "./test-fixtures"; + +describe("autoCheckBackendPrereqs", () => { + it("returns [] when all dependencies are already loaded", () => { + const nodes = [node({ id: "dep" }), node({ id: "app", dependsOn: ["dep"] })]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual([]); + }); + + it("returns the unloaded direct dependencies (to auto-check)", () => { + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "other", loaded: false, section: "discovered" }), + node({ id: "loaded-dep" }), + node({ id: "app", dependsOn: ["dep", "other", "loaded-dep"] }), + ]; + // Only the UNLOADED deps are returned (loaded-dep needs no auto-check). + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["dep", "other"]); + }); + + it("is transitive (dep of a dep)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "mid", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["mid"] }), + ]; + // Load order: dependencies BEFORE their dependents. + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["mid", "base"]); + }); + + it("is one-directional: it never pulls DEPENDENTS in (no reverse traversal)", () => { + // Checking `dep` must not auto-check `app` (app depends on dep, not vice versa). + const nodes = [ + node({ id: "dep", loaded: false, section: "discovered" }), + node({ id: "app", dependsOn: ["dep"] }), + ]; + expect(autoCheckBackendPrereqs("dep", nodes)).toEqual([]); + }); + + it("excludes the target itself and skips already-seen nodes (diamond)", () => { + const nodes = [ + node({ id: "base", loaded: false, section: "discovered" }), + node({ id: "left", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "right", loaded: false, section: "discovered", dependsOn: ["base"] }), + node({ id: "app", dependsOn: ["left", "right"] }), + ]; + expect(autoCheckBackendPrereqs("app", nodes)).toEqual(["left", "right", "base"]); + }); + + it("returns [] for an unknown target", () => { + expect(autoCheckBackendPrereqs("ghost", [node({ id: "a" })])).toEqual([]); + }); + + it("returns [] for a target with no dependencies", () => { + expect(autoCheckBackendPrereqs("app", [node({ id: "app" })])).toEqual([]); + }); +}); diff --git a/src/features/plugins/logic/prereqs.ts b/src/features/plugins/logic/prereqs.ts new file mode 100644 index 0000000..9066413 --- /dev/null +++ b/src/features/plugins/logic/prereqs.ts @@ -0,0 +1,45 @@ +import type { PluginNode } from "./types"; + +/** + * When CHECKING (loading) `targetId`, the transitive set of its dependencies + * that are NOT currently loaded — the plugins that must be auto-checked + * (loaded) first (§8: "Checking a frontend plugin auto-checks its required + * backend plugins (transitively)"). + * + * One-directional by construction: only `dependsOn` edges FROM the target are + * followed (frontend→backend / backend→backend). The reverse direction is + * NEVER traversed here — backend plugins never depend on frontend ones (§8), + * so a dependency auto-check never pulls the other way. + * + * Returns the ids in deterministic load order (dependencies before their + * dependents — alphabetical within a level) so the caller can issue the load + * requests in a safe sequence. Pure input → output. + */ +export function autoCheckBackendPrereqs( + targetId: string, + nodes: readonly PluginNode[], +): readonly string[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + + // BFS over dependency edges (target → deps), collecting UNLOADED deps. + const out: string[] = []; + const seen = new Set<string>([targetId]); + // Queue of [id, depth] — sorted per level so ties resolve alphabetically. + let frontier: string[] = [targetId]; + while (frontier.length > 0) { + const next: string[] = []; + for (const id of frontier) { + const node = byId.get(id); + for (const dep of node?.dependsOn ?? []) { + if (seen.has(dep)) continue; + seen.add(dep); + next.push(dep); + if (!byId.get(dep)?.loaded) out.push(dep); + } + } + frontier = next.sort((a, b) => a.localeCompare(b)); + } + // Load order: dependencies before dependents (BFS level order already is), + // alphabetical within a level. + return out; +} diff --git a/src/features/plugins/logic/test-fixtures.ts b/src/features/plugins/logic/test-fixtures.ts new file mode 100644 index 0000000..e7b3f5d --- /dev/null +++ b/src/features/plugins/logic/test-fixtures.ts @@ -0,0 +1,30 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { PluginNode } from "./types"; +import { pluginNodes } from "./view-model"; + +/** + * Build a PluginNode directly (the graph-logic tests' fixture). Defaults: + * loaded, standard tier, no deps. + */ +export function node(overrides: Partial<PluginNode> = {}): PluginNode { + return { + id: "p", + name: "p", + version: "1.0.0", + tier: "standard", + trust: "bundled", + loaded: true, + locked: false, + dependsOn: [], + reason: null, + section: "loaded", + ...overrides, + }; +} + +/** An empty `GET /plugins` response. */ +export function emptyListResponse(): PluginListResponse { + return { loaded: [], disabledWithReason: [], discovered: [], missing: [] }; +} + +export { pluginNodes }; diff --git a/src/features/plugins/logic/types.ts b/src/features/plugins/logic/types.ts new file mode 100644 index 0000000..e64b553 --- /dev/null +++ b/src/features/plugins/logic/types.ts @@ -0,0 +1,99 @@ +import type { PluginTier, PluginTrustLevel } from "@dispatch/transport-contract"; + +/** Which manager table a plugin row belongs to (the response's row kinds). */ +export type PluginSection = "loaded" | "discovered" | "missing"; + +/** + * The manager's normalized view of one plugin — a single shape across the + * `GET /plugins` response's row kinds (loaded / disabledWithReason / + * discovered / missing) so the pure graph logic (cascade / prereqs / + * highlight / last-provider) and the manager tables work over ONE node type. + * Built by `pluginNodes` (`logic/view-model`) from a `PluginListResponse`. + */ +export interface PluginNode { + readonly id: string; + readonly name: string; + readonly version: string; + readonly tier: PluginTier; + /** Trust level, when the row carries one (missing rows don't). */ + readonly trust: PluginTrustLevel | null; + /** Loaded and live — the manager's checkbox reflects this. */ + readonly loaded: boolean; + /** True for tier "core" — the server refuses to unload it (locked control). */ + readonly locked: boolean; + /** Declared dependencies (plugin ids), possibly empty. */ + readonly dependsOn: readonly string[]; + /** Why the plugin is not loaded (disabled-with-reason) or gone (missing). */ + readonly reason: string | null; + /** + * The frontend-plugin (plan 03) issue line for the manager row, when the + * plugin's FE half is incompatible ("incompatible frontend plugin") or + * failed (`frontend failed: <error>`). Absent = the FE half is fine (or + * the plugin declares no frontend section). + */ + readonly frontendIssue?: string; + readonly section: PluginSection; +} + +/** Display labels for the classification tiers (backend-canonical values). */ +export function tierLabel(tier: PluginTier): string { + switch (tier) { + case "core": + return "Core"; + case "providers": + return "Provider"; + case "standard": + return "Standard"; + } +} + +/** Display order of the tiers within a manager table (Core first). */ +export const TIER_ORDER: readonly PluginTier[] = ["core", "providers", "standard"]; + +/** + * The layout port (plan 04 §4.4) the workspace plugins modal needs for the + * "Reset layout to default" affordance — the modal is the natural home (it + * is where users manage the layout plugins themselves). Wired by the + * composition root over the layout store; absent → the section doesn't + * render (tests). + */ +export interface WorkspaceLayoutPort { + /** + * Whether a layout provider is active (the reset button's gate). False → + * the button is DISABLED with the reason shown ("no layout plugin + * enabled") — there is nothing to reset and no persistence to re-seed. + */ + readonly providerActive: boolean; + /** + * Reset the workspace's layout to the default seed (`DELETE + * /workspaces/:id/layout` → re-seed → broadcast). Resolves to `null` on + * success or to the failure reason to display. + */ + readonly reset: (workspaceId: string) => Promise<string | null>; + /** + * The workspace's PER-WORKSPACE PLUGIN STATE changed (the modal just + * toggled a plugin's enablement): the layout plugin that serves THIS + * workspace may have changed (the per-workspace activation rule) — the + * composition root re-probes the layout so the newly-enabled provider's + * tree (or the no-provider degrade) lands without a reload. Only the + * ACTIVE workspace's toggle re-renders; others are ignored by the store. + */ + readonly refresh: (workspaceId: string) => void; +} + +/** + * Runtime-normalize a tier value to the canonical THREE: anything that is not + * exactly `core` | `providers` | `standard` — a missing tier, or a value + * outside the contract ("kernel" is NOT a tier; the kernel hosts plugins, it + * is not one) — folds into `"standard"`, the safe catch-all classification. + * The wire contract pins the three values, so this only ever fires on a + * contract violation (the backend rejects tierless externals at boot and + * directory-scanned plugins fail manifest validation); folding rather than + * surfacing the raw value keeps every tier-keyed consumer total — e.g. + * `tierLabel`'s exhaustive switch (an out-of-contract value would render an + * empty badge) and the manager table's keyed grouping (a non-canonical key + * would create a second, colliding group). + */ +export function normalizeTier(tier: unknown): PluginTier { + return tier === "core" || tier === "providers" || tier === "standard" ? tier : "standard"; +} diff --git a/src/features/plugins/logic/view-model.test.ts b/src/features/plugins/logic/view-model.test.ts new file mode 100644 index 0000000..661cade --- /dev/null +++ b/src/features/plugins/logic/view-model.test.ts @@ -0,0 +1,296 @@ +import type { PluginListResponse, PluginTier } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { emptyListResponse, node } from "./test-fixtures"; +import { groupByTier, pluginNodes, splitSections, withFrontendStates } from "./view-model"; + +function listResponse(overrides: Partial<PluginListResponse> = {}): PluginListResponse { + return { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: ["kernel"], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone", disabledAt: "2026-01-01" }], + ...overrides, + }; +} + +describe("pluginNodes", () => { + it("normalizes every row kind into one node shape", () => { + const nodes = pluginNodes(listResponse()); + expect(nodes.map((n) => n.id)).toEqual(["kernel", "opencode", "broken", "fresh", "gone"]); + }); + + it("maps loaded rows (loaded: true, locked from the row, deps kept)", () => { + const nodes = pluginNodes(listResponse()); + const kernel = nodes.find((n) => n.id === "kernel"); + expect(kernel).toMatchObject({ + loaded: true, + locked: true, + tier: "core", + section: "loaded", + dependsOn: [], + reason: null, + }); + const opencode = nodes.find((n) => n.id === "opencode"); + expect(opencode).toMatchObject({ loaded: true, locked: false, dependsOn: ["kernel"] }); + }); + + it("maps disabledWithReason rows (unloaded, reason kept, discovered section)", () => { + const nodes = pluginNodes(listResponse()); + const broken = nodes.find((n) => n.id === "broken"); + expect(broken).toMatchObject({ + loaded: false, + locked: false, + reason: "activation threw", + section: "discovered", + }); + }); + + it("maps discovered rows (unloaded, no reason)", () => { + const nodes = pluginNodes(listResponse()); + const fresh = nodes.find((n) => n.id === "fresh"); + expect(fresh).toMatchObject({ loaded: false, reason: null, section: "discovered" }); + }); + + it("maps missing rows (greyed strip; no trust/version)", () => { + const nodes = pluginNodes(listResponse()); + const gone = nodes.find((n) => n.id === "gone"); + expect(gone).toMatchObject({ + loaded: false, + section: "missing", + reason: "directory is gone", + trust: null, + version: "", + }); + }); + + it("handles the empty response", () => { + expect(pluginNodes(emptyListResponse())).toEqual([]); + }); + + it("normalizes a missing/unknown tier at the boundary so node tiers are canonical", () => { + // Contract-violation casts: the wire type pins three tier values, but the + // view-model must survive a backend that emits a tierless or out-of- + // contract row (the original duplicate-key crash trigger). + const response = listResponse({ + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "tierless", + name: "Tierless", + version: "1.0.0", + tier: undefined as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + { + id: "kernelish", + name: "Kernelish", + version: "1.0.0", + tier: "kernel" as unknown as PluginTier, + trust: "external", + locked: false, + dependsOn: [], + }, + ], + }); + const nodes = pluginNodes(response); + expect(nodes.find((n) => n.id === "tierless")?.tier).toBe("standard"); + expect(nodes.find((n) => n.id === "kernelish")?.tier).toBe("standard"); + + // The Loaded table's grouping: exactly two groups (core + standard), no + // duplicate keys, every row present. + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["core", "standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + expect(groups[1]?.rows.map((r) => r.id)).toEqual(["kernelish", "tierless"]); + }); +}); + +describe("groupByTier", () => { + it("clusters rows in tier display order (Core → Providers → Standard)", () => { + const nodes = pluginNodes(listResponse()); + const groups = groupByTier(nodes.filter((n) => n.section === "loaded")); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["kernel"]); + }); + + it("omits empty tiers", () => { + const groups = groupByTier([pluginNodes(listResponse())[2] ?? []].flat()); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + }); + + it("emits one group per canonical tier, all three present", () => { + const groups = groupByTier([ + node({ id: "s", tier: "standard" }), + node({ id: "c", tier: "core" }), + node({ id: "p", tier: "providers" }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["core", "providers", "standard"]); + expect(groups.flatMap((g) => g.rows.map((r) => r.id))).toEqual(["c", "p", "s"]); + }); + + it("never emits duplicate group keys — an unknown tier folds into standard", () => { + // Contract violation cast: "kernel" is NOT a tier (the kernel hosts + // plugins, it is not one). groupByTier must stay total anyway. + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "k-row", tier: "kernel" as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + // No duplicates (the keyed each `(group.tier)` crashes on them)… + expect(new Set(keys).size).toBe(keys.length); + // …and the unknown-tier row is NOT dropped — it merges into standard. + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["k-row", "s-row"]); + }); + + it("never emits duplicate group keys — a missing tier folds into standard", () => { + const groups = groupByTier([ + node({ id: "s-row", tier: "standard" }), + node({ id: "m-row", tier: undefined as unknown as PluginTier }), + ]); + const keys = groups.map((g) => g.tier); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(["standard"]); + expect(groups[0]?.rows.map((r) => r.id)).toEqual(["m-row", "s-row"]); + }); + + it("keeps unknown/missing tiers out of the canonical groups' identity (no second standard group)", () => { + // The old implementation pushed a trailing "rest" group keyed + // `rest[0]?.tier ?? "standard"` — a DUPLICATE standard key when canonical + // standard rows existed (the headless-Chromium each_key_duplicate crash). + const groups = groupByTier([ + node({ id: "a", tier: "standard" }), + node({ id: "b", tier: "bogus" as PluginTier }), + node({ id: "c", tier: undefined as unknown as PluginTier }), + ]); + expect(groups.map((g) => g.tier)).toEqual(["standard"]); + expect(groups[0]?.rows).toHaveLength(3); + }); +}); + +describe("splitSections", () => { + it("splits the node list into the two tables + the missing strip", () => { + const sections = splitSections(pluginNodes(listResponse())); + expect(sections.loaded.map((n) => n.id)).toEqual(["kernel", "opencode"]); + expect(sections.discovered.map((n) => n.id)).toEqual(["broken", "fresh"]); + expect(sections.missing.map((n) => n.id)).toEqual(["gone"]); + }); +}); + +// ── withFrontendStates (plan 03) — the manager rows' FE-half issues ───────── + +describe("withFrontendStates", () => { + function listWith(...ids: readonly string[]): PluginListResponse { + return { + loaded: ids.map((id) => ({ + id, + name: id, + version: "1.0.0", + tier: "standard" as const, + trust: "local" as const, + locked: false, + dependsOn: [], + })), + disabledWithReason: [], + discovered: [], + missing: [], + }; + } + + it("renders the plan-mandated grammar for an incompatible FE half", () => { + const nodes = pluginNodes(listWith("bad")); + const states = new Map([["bad", { kind: "incompatible", apiVersion: "^2" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("incompatible frontend plugin"); + }); + + it("renders the activation error for a failed FE half", () => { + const nodes = pluginNodes(listWith("broken")); + const states = new Map([["broken", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out[0]?.frontendIssue).toBe("frontend failed: boom"); + }); + + it("a loaded FE half adds no issue line; absent states add none", () => { + const nodes = pluginNodes(listWith("ok", "no-fe")); + const states = new Map([["ok", { kind: "loaded" } as const]]); + const out = withFrontendStates(nodes, states); + expect(out.find((n) => n.id === "ok")?.frontendIssue).toBeUndefined(); + expect(out.find((n) => n.id === "no-fe")?.frontendIssue).toBeUndefined(); + }); + + it("never annotates non-loaded sections (discovered / missing rows)", () => { + const response: PluginListResponse = { + loaded: [], + disabledWithReason: [], + discovered: [ + { + id: "dis", + name: "dis", + version: "1.0.0", + tier: "standard", + trust: "local", + dir: "/plugins/dis", + }, + ], + missing: [], + }; + const states = new Map([["dis", { kind: "failed", error: "boom" } as const]]); + const out = withFrontendStates(pluginNodes(response), states); + expect(out[0]?.frontendIssue).toBeUndefined(); + }); + + it("an empty state map is the identity (no allocation on the hot path)", () => { + const nodes = pluginNodes(listWith("a")); + expect(withFrontendStates(nodes, new Map())).toBe(nodes); + }); +}); diff --git a/src/features/plugins/logic/view-model.ts b/src/features/plugins/logic/view-model.ts new file mode 100644 index 0000000..5a66ffa --- /dev/null +++ b/src/features/plugins/logic/view-model.ts @@ -0,0 +1,160 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../../core/plugin-abi"; +import { normalizeTier, type PluginNode, type PluginSection, TIER_ORDER } from "./types"; + +/** + * Normalize a `GET /plugins` response into the flat `PluginNode` list the + * manager tables + graph logic consume. Each response row kind maps to a + * node: + * - `loaded` → section "loaded" (`loaded: true`, `locked` from the row — + * tier "core" is always locked); + * - `disabledWithReason` → section "discovered" with the refusal `reason` + * (present on disk, refused/failed — greyed rows with a visible why); + * - `discovered` → section "discovered" (`loaded: false`, unchecked); + * - `missing` → section "missing" (override record whose directory is gone). + * + * Pure input → output; zero DOM/fetch. + */ +export function pluginNodes(response: PluginListResponse): readonly PluginNode[] { + const nodes: PluginNode[] = []; + for (const row of response.loaded) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + // Normalize at the boundary: a missing/out-of-contract tier (backend + // bug, stale contract) folds into "standard" so every node's tier is + // canonical and the tier-keyed consumers below stay total. + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: true, + locked: row.locked, + dependsOn: row.dependsOn, + reason: null, + section: "loaded", + }); + } + for (const row of response.disabledWithReason) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason, + section: "discovered", + }); + } + for (const row of response.discovered) { + nodes.push({ + id: row.id, + name: row.name, + version: row.version, + tier: normalizeTier(row.tier), + trust: row.trust, + loaded: false, + locked: false, + dependsOn: [], + reason: null, + section: "discovered", + }); + } + for (const row of response.missing) { + nodes.push({ + id: row.id, + name: row.id, + version: "", + tier: "standard", + trust: null, + loaded: false, + locked: false, + dependsOn: [], + reason: row.reason ?? "directory is gone", + section: "missing", + }); + } + return nodes; +} + +/** + * Merge the frontend-plugin (plan 03) per-plugin FE states into the node + * list — the manager rows' `frontendIssue` line. Pure input → output: + * - `incompatible` → the plan-mandated "incompatible frontend plugin" + * grammar (the backend apiVersion-mismatch wording, FE half); + * - `failed` → the activation error (the FE half failed; the backend half + * stays loaded). + * Loaded rows only — the FE states never apply to discovered/missing rows. + */ +export function withFrontendStates( + nodes: readonly PluginNode[], + states: ReadonlyMap<string, FrontendPluginState>, +): readonly PluginNode[] { + if (states.size === 0) return nodes; + return nodes.map((node) => { + if (node.section !== "loaded") return node; + const state = states.get(node.id); + if (state === undefined) return node; + const issue = + state.kind === "incompatible" + ? "incompatible frontend plugin" + : state.kind === "failed" + ? `frontend failed: ${state.error}` + : null; + return issue === null ? node : { ...node, frontendIssue: issue }; + }); +} + +/** + * Cluster nodes for one manager table: tier groups in display order (Core → + * Providers → Standard), each tier's rows sorted by id. Tiers with no rows + * are omitted. Used by the loaded table's tier grouping (plan 07 §7.2). + * + * Duplicate-key-proof BY CONSTRUCTION: nodes are bucketed through a Map keyed + * by the NORMALIZED tier, so each of the three canonical tiers can yield at + * most one group — `PluginTable.svelte` renders a keyed each + * (`{#each groups as group (group.tier)}`), and a duplicate key throws + * `each_key_duplicate` in Svelte 5, aborting the whole manager render. The + * normalization also means an unknown/missing tier (contract violation — the + * backend rejects tierless externals at boot and directory scans fail + * validation) folds into the `standard` bucket instead of forming a separate + * trailing group; the old fallback (`rest[0]?.tier ?? "standard"`) produced a + * DUPLICATE `standard` key when canonical standard rows were present. + * groupByTier normalizes on the way in even though `pluginNodes` already + * does at the boundary — it is exported and must be safe for any caller. + */ +export function groupByTier( + nodes: readonly PluginNode[], +): ReadonlyArray<{ readonly tier: PluginNode["tier"]; readonly rows: readonly PluginNode[] }> { + const byTier = new Map<PluginNode["tier"], PluginNode[]>(); + for (const n of nodes) { + const tier = normalizeTier(n.tier); + const rows = byTier.get(tier); + if (rows) rows.push(n); + else byTier.set(tier, [n]); + } + const out: { tier: PluginNode["tier"]; rows: PluginNode[] }[] = []; + for (const tier of TIER_ORDER) { + const rows = byTier.get(tier); + if (!rows || rows.length === 0) continue; + out.push({ tier, rows: rows.sort((a, b) => a.id.localeCompare(b.id)) }); + } + return out; +} + +/** Split nodes into the manager's two tables + the missing strip. */ +export function splitSections(nodes: readonly PluginNode[]): { + readonly loaded: readonly PluginNode[]; + readonly discovered: readonly PluginNode[]; + readonly missing: readonly PluginNode[]; +} { + const bySection = (section: PluginSection): PluginNode[] => + nodes.filter((n) => n.section === section); + return { + loaded: bySection("loaded"), + discovered: bySection("discovered"), + missing: bySection("missing"), + }; +} diff --git a/src/features/plugins/logic/workspace-row.test.ts b/src/features/plugins/logic/workspace-row.test.ts new file mode 100644 index 0000000..4c7d29e --- /dev/null +++ b/src/features/plugins/logic/workspace-row.test.ts @@ -0,0 +1,67 @@ +import type { WorkspacePluginRow } from "@dispatch/transport-contract"; +import { describe, expect, it } from "vitest"; +import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "./workspace-row"; + +function row(overrides: Partial<WorkspacePluginRow> = {}): WorkspacePluginRow { + return { + pluginId: "mcp", + tier: "standard", + state: "undefined", + loaded: true, + locked: false, + ...overrides, + }; +} + +describe("rowChecked", () => { + it("is true only for an explicit enabled record", () => { + expect(rowChecked(row({ state: "enabled" }))).toBe(true); + expect(rowChecked(row({ state: "disabled" }))).toBe(false); + expect(rowChecked(row({ state: "undefined" }))).toBe(false); + }); +}); + +describe("rowSelectable", () => { + it("is true for a loaded, unlocked row (all three states)", () => { + for (const state of ["enabled", "disabled", "undefined"] as const) { + expect(rowSelectable(row({ state }))).toBe(true); + } + }); + + it("is false for an UNLOADED plugin (§7 strict layering — greyed out)", () => { + expect(rowSelectable(row({ loaded: false, state: "disabled" }))).toBe(false); + }); + + it("is false for a LOCKED (core) row — the server refuses the PUT (409)", () => { + expect(rowSelectable(row({ tier: "core", locked: true, state: "enabled" }))).toBe(false); + }); +}); + +describe("rowBlockReason", () => { + it("points at the global manager for unloaded plugins", () => { + const reason = rowBlockReason(row({ loaded: false })); + expect(reason).toContain("plugin manager"); + }); + + it("explains the core lock", () => { + const reason = rowBlockReason(row({ tier: "core", locked: true })); + expect(reason).toContain("Core plugins are enabled by default"); + }); + + it("prefers the unloaded reason when both apply", () => { + const reason = rowBlockReason(row({ loaded: false, tier: "core", locked: true })); + expect(reason).toContain("plugin manager"); + }); + + it("is null for an interactive row", () => { + expect(rowBlockReason(row())).toBeNull(); + }); +}); + +describe("stateLabel", () => { + it("renders the three wire states in a uniform grammar", () => { + expect(stateLabel("enabled")).toBe("enabled"); + expect(stateLabel("disabled")).toBe("disabled"); + expect(stateLabel("undefined")).toBe("not set"); + }); +}); diff --git a/src/features/plugins/logic/workspace-row.ts b/src/features/plugins/logic/workspace-row.ts new file mode 100644 index 0000000..c85b759 --- /dev/null +++ b/src/features/plugins/logic/workspace-row.ts @@ -0,0 +1,54 @@ +import type { WorkspacePluginRow, WorkspacePluginStateValue } from "@dispatch/transport-contract"; + +/** + * Pure helpers for the per-workspace plugin modal (plan 06 §6.6 wire rows → + * checkbox semantics). Zero DOM. + * + * State model (backend-canonical): `"enabled"` / `"disabled"` — an explicit + * record — or `"undefined"` for NO record (the blank slate; behaves disabled + * for Providers/Standard). Core rows arrive materialized `enabled` (Q-A). + */ + +/** Whether the modal checkbox is checked for a row (explicit `enabled` only). */ +export function rowChecked(row: WorkspacePluginRow): boolean { + return row.state === "enabled"; +} + +/** + * Whether the row's checkbox is selectable. Two unselectable cases (§7 + * strict layering + plan 06 §6.6): + * - the plugin is NOT loaded globally → greyed out and unselectable (only the + * global manager loads code); + * - the server reports the row locked (tier "core") → the server refuses the + * PUT (`core-locked` 409), so the control is disabled with a tooltip. + */ +export function rowSelectable(row: WorkspacePluginRow): boolean { + return row.loaded && !row.locked; +} + +/** + * Why the row is unselectable (tooltip text), or null when interactive. + * Unloaded beats locked in the copy (the user's actionable next step is the + * global manager). + */ +export function rowBlockReason(row: WorkspacePluginRow): string | null { + if (!row.loaded) { + return "Not loaded globally — load it in the plugin manager first (only the manager loads code)."; + } + if (row.locked) { + return "Core plugins are enabled by default in every workspace and managed globally."; + } + return null; +} + +/** The effective-state label shown next to a row (uniform three-state grammar). */ +export function stateLabel(state: WorkspacePluginStateValue): string { + switch (state) { + case "enabled": + return "enabled"; + case "disabled": + return "disabled"; + case "undefined": + return "not set"; + } +} diff --git a/src/features/plugins/store.svelte.ts b/src/features/plugins/store.svelte.ts new file mode 100644 index 0000000..fe48940 --- /dev/null +++ b/src/features/plugins/store.svelte.ts @@ -0,0 +1,218 @@ +import type { + PluginListResponse, + SetWorkspacePluginStateRequest, + WorkspacePluginRow, +} from "@dispatch/transport-contract"; +import type { FrontendPluginState } from "../../core/plugin-abi"; +import type { PluginOpResult, PluginScanResult, PluginsHttp } from "./adapter/http"; +import type { PluginNode } from "./logic/types"; +import { pluginNodes } from "./logic/view-model"; + +/** + * Plugins store — a thin reactive wrapper over the injected `PluginsHttp` + * edge (the workspaces-store pattern). Owns: + * - the manager's plugin list (`GET /plugins`) normalized to `PluginNode`s; + * - ONE open workspace modal's view (`GET /workspaces/:id/plugins` — read-through + * materialization makes this first read the workspace's "open"); + * - pending/error state for the mutations. + * + * Multi-client coherence (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. No ambient state: one instance per composition + * root, passed explicitly; no module-level mutables. + */ +export interface PluginsStore { + /** The last `GET /plugins` response (null until the first fetch lands). */ + readonly list: PluginListResponse | null; + /** The normalized nodes across ALL sections (loaded + discovered + missing). */ + readonly nodes: readonly PluginNode[]; + readonly loading: boolean; + /** The last mutation/scan/load error message, or null. */ + readonly error: string | null; + /** The manual scan's result (null until a scan ran), for the summary line. */ + readonly lastScan: PluginScanResult | null; + + /** Refetch `GET /plugins`. */ + refresh(): Promise<void>; + /** `POST /plugins/scan` (manual; the server never auto-loads new plugins). */ + scan(): Promise<PluginScanResult>; + /** `POST /plugins/:id/load`. */ + load(id: string): Promise<PluginOpResult>; + /** `POST /plugins/:id/unload` with `{ force }` (the §8 force semantics). */ + unload(id: string, force: boolean): Promise<PluginOpResult>; + /** `POST /plugins/:id/reload`. */ + reload(id: string): Promise<PluginOpResult>; + + /** + * The open workspace modal's rows (null when nothing loaded yet). Cleared + * IMMEDIATELY on open so a slow/failing fetch never shows the previous + * workspace's rows (the refreshCwd/refreshComputer pattern). + */ + readonly workspaceView: WorkspacePluginsView | null; + /** Load/reload the workspace modal's view (`GET /workspaces/:id/plugins`). */ + openWorkspace(workspaceId: string): Promise<void>; + /** + * Write one plugin's state in one workspace (`PUT .../plugins/:pluginId`). + * On success the open view's row is updated in place from the response; on + * failure the error is surfaced AND the view refetched (server-authoritative + * reconciliation — the checkbox never lies). + */ + setWorkspaceState( + workspaceId: string, + pluginId: string, + state: SetWorkspacePluginStateRequest["state"], + ): Promise<void>; + + /** + * Multi-client coherence hook (§11): the composition root calls + * `onExtensionsChanged()` when the server broadcasts `extensions-changed` — + * the store refetches the list (and the open workspace view), so every + * connected client converges. Fire-and-forget. + */ + onExtensionsChanged(): void; + + /** + * The frontend-plugin (plan 03) per-plugin FE states — reported by the + * plugin host after every driver sync. Rendered by the manager rows: + * `incompatible` → "incompatible frontend plugin" (the backend-mismatch + * grammar), `failed` → the activation error. Absent = no FE section. + */ + readonly frontendStates: ReadonlyMap<string, FrontendPluginState>; + /** The plugin host's post-sync report (the only writer). */ + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void; +} + +/** One open workspace modal's view (rows + materialized provider count). */ +export interface WorkspacePluginsView { + readonly workspaceId: string; + readonly rows: readonly WorkspacePluginRow[]; + /** Loaded provider-tier plugins effectively enabled here (§6.5). */ + readonly enabledProviderCount: number; +} + +export function createPluginsStore(http: PluginsHttp): PluginsStore { + let list = $state<PluginListResponse | null>(null); + let loading = $state(false); + let error = $state<string | null>(null); + let lastScan = $state<PluginScanResult | null>(null); + let workspaceView = $state<WorkspacePluginsView | null>(null); + let frontendStates = $state<ReadonlyMap<string, FrontendPluginState>>(new Map()); + + // Normalized nodes — derived so the graph logic re-runs only when the list + // changes, not on every read. + const nodes = $derived<readonly PluginNode[]>(list === null ? [] : pluginNodes(list)); + + return { + get list(): PluginListResponse | null { + return list; + }, + get nodes(): readonly PluginNode[] { + return nodes; + }, + get loading(): boolean { + return loading; + }, + get error(): string | null { + return error; + }, + get lastScan(): PluginScanResult | null { + return lastScan; + }, + get workspaceView(): WorkspacePluginsView | null { + return workspaceView; + }, + + async refresh(): Promise<void> { + loading = true; + const data = await http.list(); + if (data !== null) list = data; + loading = false; + }, + + async scan(): Promise<PluginScanResult> { + error = null; + const result = await http.scan(); + lastScan = result; + if (!result.ok) error = result.error; + // A scan broadcasts `extensions-changed` server-side; refresh anyway so + // the manager is coherent even without a WS connection. + await this.refresh(); + return result; + }, + + async load(id): Promise<PluginOpResult> { + error = null; + const result = await http.load(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async unload(id, force): Promise<PluginOpResult> { + error = null; + const result = await http.unload(id, force); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async reload(id): Promise<PluginOpResult> { + error = null; + const result = await http.reload(id); + if (!result.ok) error = result.error; + await this.refresh(); + return result; + }, + + async openWorkspace(workspaceId): Promise<void> { + // Clear immediately so a slow/failing fetch never shows the PREVIOUS + // workspace's rows in the new modal. + workspaceView = null; + const data = await http.workspacePlugins(workspaceId); + if (data === null) { + error = `Failed to load plugins for workspace "${workspaceId}"`; + return; + } + workspaceView = { + workspaceId: data.workspaceId, + rows: data.plugins, + enabledProviderCount: data.enabledProviderCount, + }; + }, + + async setWorkspaceState(workspaceId, pluginId, state): Promise<void> { + const result = await http.setWorkspaceState(workspaceId, pluginId, state); + if (result.ok) { + // Server-authoritative in-place row update (the response carries the + // resulting effective state — "undefined" after "unset"). + if (workspaceView?.workspaceId === workspaceId) { + const rows = workspaceView.rows.map( + (row): WorkspacePluginRow => + row.pluginId === pluginId ? { ...row, state: result.response.state } : row, + ); + workspaceView = { ...workspaceView, rows }; + } + return; + } + error = result.error; + // Refusal (409 core-locked / unloaded) or failure — reconcile from the + // server so the checkbox never lies. + await this.openWorkspace(workspaceId); + }, + + onExtensionsChanged(): void { + void this.refresh(); + const open = workspaceView?.workspaceId; + if (open !== undefined) void this.openWorkspace(open); + }, + + get frontendStates(): ReadonlyMap<string, FrontendPluginState> { + return frontendStates; + }, + + setFrontendStates(states: ReadonlyMap<string, FrontendPluginState>): void { + frontendStates = states; + }, + }; +} diff --git a/src/features/plugins/store.test.ts b/src/features/plugins/store.test.ts new file mode 100644 index 0000000..be301ee --- /dev/null +++ b/src/features/plugins/store.test.ts @@ -0,0 +1,183 @@ +import type { PluginListResponse, WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { describe, expect, it, vi } from "vitest"; +import type { + PluginOpResult, + PluginScanResult, + PluginsHttp, + WorkspaceStateResult, +} from "./adapter/http"; +import { createPluginsStore } from "./store.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "1.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [], +}; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + ], + enabledProviderCount: 1, +}; + +/** A fake PluginsHttp with a controllable list + workspace response. */ +function fakeHttp(opts: { + list?: PluginListResponse | null; + workspacePlugins?: WorkspacePluginsResponse | null; +}) { + // `=== undefined` (not `??`) so an explicit `null` override defeats the default. + const list = () => (opts.list === undefined ? listResponse : opts.list); + const ws = () => (opts.workspacePlugins === undefined ? wsResponse : opts.workspacePlugins); + const opOk = (action: "load" | "unload" | "reload") => ({ ok: true, action, id: "x" }) as const; + return { + list: vi.fn(async (): Promise<PluginListResponse | null> => list()), + scan: vi.fn( + async (): Promise<PluginScanResult> => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + }), + ), + load: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("load") })), + unload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("unload") })), + reload: vi.fn(async (): Promise<PluginOpResult> => ({ ok: true, response: opOk("reload") })), + workspacePlugins: vi.fn(async (): Promise<WorkspacePluginsResponse | null> => ws()), + setWorkspaceState: vi.fn( + async (): Promise<WorkspaceStateResult> => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + }), + ), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + } satisfies PluginsHttp; +} + +describe("createPluginsStore — manager list", () => { + it("fetches + normalizes the list into nodes", async () => { + const store = createPluginsStore(fakeHttp({})); + expect(store.list).toBeNull(); + expect(store.nodes).toEqual([]); + await store.refresh(); + expect(store.list).toEqual(listResponse); + expect(store.nodes.map((n) => n.id)).toEqual(["kernel", "fresh"]); + }); + + it("mutation results are surfaced and the list is refetched", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.refresh(); + const result = await store.unload("kernel", false); + expect(result.ok).toBe(true); + expect(http.unload).toHaveBeenCalledWith("kernel", false); + expect(http.list).toHaveBeenCalledTimes(2); + }); + + it("scan records the result + refreshes", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.scan(); + expect(store.lastScan?.ok).toBe(true); + expect(http.scan).toHaveBeenCalledOnce(); + expect(http.list).toHaveBeenCalledTimes(1); // refresh after scan (initial list fetch absent) + }); + + it("a mutation error lands in store.error", async () => { + const http = fakeHttp({}); + http.unload = vi.fn(async (): Promise<PluginOpResult> => ({ ok: false, error: "blocked" })); + const store = createPluginsStore(http); + await store.unload("kernel", false); + expect(store.error).toBe("blocked"); + }); +}); + +describe("createPluginsStore — workspace view", () => { + it("openWorkspace loads the rows + enabled provider count", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + expect(store.workspaceView).toEqual({ + workspaceId: "ws", + rows: wsResponse.plugins, + enabledProviderCount: 1, + }); + }); + + it("setWorkspaceState updates the row in place from the response", async () => { + const store = createPluginsStore(fakeHttp({})); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "mcp", "enabled"); + const row = store.workspaceView?.rows.find((r) => r.pluginId === "mcp"); + expect(row?.state).toBe("enabled"); + }); + + it("a refused setWorkspaceState refetches (server-authoritative reconcile)", async () => { + const http = fakeHttp({}); + http.setWorkspaceState = vi.fn( + async (): Promise<WorkspaceStateResult> => ({ + ok: false, + error: "core is locked", + reason: "core-locked", + }), + ); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + await store.setWorkspaceState("ws", "kernel", "disabled"); + expect(store.error).toBe("core is locked"); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + + it("a failed openWorkspace clears the view (no stale rows from another workspace)", async () => { + const http = fakeHttp({ workspacePlugins: null }); + const store = createPluginsStore(http); + await store.openWorkspace("other"); + expect(store.workspaceView).toBeNull(); + expect(store.error).toContain("other"); + }); +}); + +describe("createPluginsStore — multi-client coherence (§11)", () => { + it("onExtensionsChanged refetches the list + the open workspace view", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + expect(http.list).toHaveBeenCalledTimes(0); + expect(http.workspacePlugins).toHaveBeenCalledTimes(1); + + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(2); + }); + }); + + it("onExtensionsChanged does not refetch a workspace view when none is open", async () => { + const http = fakeHttp({}); + const store = createPluginsStore(http); + store.onExtensionsChanged(); + await vi.waitFor(() => { + expect(http.list).toHaveBeenCalledTimes(1); + expect(http.workspacePlugins).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/src/features/plugins/ui/PluginManager.svelte b/src/features/plugins/ui/PluginManager.svelte new file mode 100644 index 0000000..fd2a8a7 --- /dev/null +++ b/src/features/plugins/ui/PluginManager.svelte @@ -0,0 +1,221 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PluginsStore } from "../store.svelte"; + import type { PluginNode } from "../logic/types"; + import { computeCascade } from "../logic/cascade"; + import { autoCheckBackendPrereqs } from "../logic/prereqs"; + import { dependencyHighlightSets } from "../logic/highlight"; + import { unloadBlockReason } from "../logic/last-provider"; + import { splitSections, withFrontendStates } from "../logic/view-model"; + import PluginTable from "./PluginTable.svelte"; + import UnloadConfirmModal from "./UnloadConfirmModal.svelte"; + + let { plugins, onClose }: { plugins: PluginsStore; onClose: () => void } = $props(); + + onMount(() => { + void plugins.refresh(); + }); + + // ── Derived sections + hover highlighting (§8) ──────────────────────────── + // The FE-half states (plan 03) merge into the nodes here — one reactive + // derivation over both inputs (the normalized nodes + the plugin host's + // post-sync state map). + const nodes = $derived(withFrontendStates(plugins.nodes, plugins.frontendStates)); + const sections = $derived(splitSections(nodes)); + let hoveredId = $state<string | null>(null); + const highlight = $derived(dependencyHighlightSets(hoveredId, nodes)); + + function highlightFor(id: string): "dep" | "dependent" | null { + if (highlight.deps.has(id)) return "dep"; + if (highlight.dependents.has(id)) return "dependent"; + return null; + } + + // ── Mutations ───────────────────────────────────────────────────────────── + let busyIds = $state<ReadonlySet<string>>(new Set()); + let scanning = $state(false); + + async function withBusy(id: string, op: () => Promise<unknown>): Promise<void> { + busyIds = new Set(busyIds).add(id); + try { + await op(); + } finally { + const next = new Set(busyIds); + next.delete(id); + busyIds = next; + } + } + + // ── Unload confirm state (cascade warn §8 + in-use force §8) ───────────── + // The pending unload the confirm modal describes. `cascade` lists the loaded + // dependents that will also be unchecked; `notice` carries a refused-attempt + // error (in-use / engine refusal) that motivated the force offer. + let pendingUnload = $state<{ + node: PluginNode; + cascade: readonly string[]; + notice: string | null; + } | null>(null); + + async function toggle(node: PluginNode, checked: boolean): Promise<void> { + if (busyIds.has(node.id)) return; + if (checked) { + // §8: checking auto-checks the required backend plugins (transitive, + // one-directional). Load the missing prereqs first, then the target. + const prereqs = autoCheckBackendPrereqs(node.id, plugins.nodes); + for (const id of prereqs) { + await withBusy(id, () => plugins.load(id)); + } + await withBusy(node.id, () => plugins.load(node.id)); + return; + } + // Unchecking: warn when loaded dependents would cascade-uncheck (§8). + const cascade = computeCascade(node.id, plugins.nodes); + if (cascade.length > 0) { + pendingUnload = { node, cascade, notice: null }; + return; + } + // No local cascade — attempt the unload; a server refusal (in-use / + // engine) offers force with the global-stop warning (§8). + const result = await withBusyRes(node.id, () => plugins.unload(node.id, false)); + if (result !== null && !result.ok) { + pendingUnload = { node, cascade: result.dependents ?? [], notice: result.error }; + } + } + + /** Like `withBusy` but returns the op's result (for the unload flow). */ + async function withBusyRes<T>(id: string, op: () => Promise<T>): Promise<T | null> { + busyIds = new Set(busyIds).add(id); + try { + return await op(); + } finally { + const next = new Set(busyIds); + next.delete(id); + busyIds = next; + } + } + + async function confirmUnload(): Promise<void> { + const pending = pendingUnload; + pendingUnload = null; + if (pending === null) return; + // Force cascades the dependents server-side (plan 05: reverse-topological + // unload) and stops ALL agents globally first — the modal's copy says so. + await withBusy(pending.node.id, () => plugins.unload(pending.node.id, true)); + } + + function cancelUnload(): void { + pendingUnload = null; + } + + async function reload(node: PluginNode): Promise<void> { + await withBusy(node.id, () => plugins.reload(node.id)); + } + + async function scan(): Promise<void> { + scanning = true; + await plugins.scan(); + scanning = false; + } + + const scanSummary = $derived.by(() => { + const last = plugins.lastScan; + if (last === null || !last.ok) return null; + const r = last.response; + const parts: string[] = [`${r.newPlugins.length} new`]; + if (r.invalid.length > 0) parts.push(`${r.invalid.length} invalid`); + if (r.incompatible.length > 0) parts.push(`${r.incompatible.length} incompatible`); + return `Scan of ${r.root}: ${parts.join(", ")}`; + }); +</script> + +<div class="modal modal-open" role="dialog" aria-label="Plugin manager"> + <div class="modal-box max-w-3xl"> + <div class="flex items-center justify-between"> + <h3 class="text-lg font-bold">Plugin manager</h3> + <button + type="button" + class="btn btn-sm" + disabled={scanning} + onclick={scan} + title="Rescan the plugin directory (new plugins stay unloaded — the consent gate)" + > + {#if scanning} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Scan + {/if} + </button> + </div> + + {#if plugins.error} + <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p> + {/if} + {#if scanSummary} + <p class="mt-2 text-xs opacity-70" role="status">{scanSummary}</p> + {/if} + + <div class="mt-3 flex flex-col gap-4"> + <PluginTable + title="Loaded" + rows={sections.loaded} + blockReasonFor={(id) => unloadBlockReason(id, plugins.nodes)} + {highlightFor} + {busyIds} + onToggle={toggle} + onReload={reload} + onHover={(id) => (hoveredId = id)} + onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)} + /> + + <PluginTable + title="Discovered (not loaded)" + rows={sections.discovered} + blockReasonFor={() => null} + {highlightFor} + {busyIds} + onToggle={toggle} + onHover={(id) => (hoveredId = id)} + onUnhover={(id) => (hoveredId = hoveredId === id ? null : hoveredId)} + /> + + {#if sections.missing.length > 0} + <section class="flex flex-col gap-1"> + <h4 class="text-xs font-semibold uppercase opacity-60">Missing (directory gone)</h4> + <ul class="flex flex-col gap-1"> + {#each sections.missing as node (node.id)} + <li class="rounded bg-base-200/40 px-2 py-1 text-xs opacity-60"> + <span class="font-mono">{node.id}</span> — {node.reason} + </li> + {/each} + </ul> + </section> + {/if} + </div> + + <div class="modal-action"> + <p class="mr-auto self-center text-xs opacity-50"> + Hover a row to highlight its dependencies and dependents. + </p> + <button type="button" class="btn btn-sm" onclick={onClose}>Close</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onClose}></div> +</div> + +{#if pendingUnload !== null} + <UnloadConfirmModal + headline={pendingUnload.node.loaded ? `Unload ${pendingUnload.node.id}?` : `Force-unload ${pendingUnload.node.id}?`} + lines={[ + ...(pendingUnload.cascade.length > 0 + ? [ + `Unloading ${pendingUnload.node.id} also unchecks: ${pendingUnload.cascade.join(", ")}.`, + ] + : []), + ...(pendingUnload.notice !== null ? [pendingUnload.notice] : []), + ]} + confirmLabel={pendingUnload.cascade.length > 0 ? "Unload with force" : "Force unload"} + onCancel={cancelUnload} + onConfirm={confirmUnload} + /> +{/if} diff --git a/src/features/plugins/ui/PluginManager.test.ts b/src/features/plugins/ui/PluginManager.test.ts new file mode 100644 index 0000000..c81938c --- /dev/null +++ b/src/features/plugins/ui/PluginManager.test.ts @@ -0,0 +1,284 @@ +import type { PluginListResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginOpResult, PluginsHttp } from "../adapter/http"; +import { createPluginsStore } from "../store.svelte"; +import PluginManager from "./PluginManager.svelte"; + +const listResponse: PluginListResponse = { + loaded: [ + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "transport-http", + name: "Transport HTTP", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: ["heartbeat"], + }, + { + id: "heartbeat", + name: "Heartbeat", + version: "1.1.0", + tier: "standard", + trust: "bundled", + locked: false, + dependsOn: [], + }, + ], + disabledWithReason: [ + { + id: "broken", + name: "Broken", + version: "0.1.0", + tier: "standard", + trust: "local", + reason: "activation threw", + }, + ], + discovered: [ + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + dir: "/plugins/fresh", + }, + ], + missing: [{ id: "gone", reason: "directory is gone" }], +}; + +/** A fake PluginsHttp (the OUTERMOST edge — the store itself is real). */ +function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp { + return { + list: vi.fn(async () => listResponse), + scan: vi.fn(async () => ({ + ok: true, + response: { root: "/plugins", newPlugins: [], invalid: [], incompatible: [] }, + })), + load: vi.fn(async () => ({ ok: true, response: { ok: true, action: "load", id: "x" } })), + unload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "unload", id: "x" } })), + reload: vi.fn(async () => ({ ok: true, response: { ok: true, action: "reload", id: "x" } })), + workspacePlugins: vi.fn(async () => null), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "x", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderManager(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + return { store, http }; +} + +describe("PluginManager — tables + locked core", () => { + it("renders the loaded table with tier groups and the discovered table", async () => { + await renderManager(); + expect(screen.getByText("Loaded")).toBeInTheDocument(); + expect(screen.getByText("Discovered (not loaded)")).toBeInTheDocument(); + // Tier subheadings (Core / Provider / Standard) — each also appears as a + // row badge, so assert at least one occurrence of each. + expect(screen.getAllByText("Core").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Provider").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Standard").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Missing (directory gone)")).toBeInTheDocument(); + expect(screen.getByText("gone")).toBeInTheDocument(); + }); + + it("core rows show a lock instead of a checkbox", async () => { + await renderManager(); + // The kernel row has no checkbox (the lock glyph instead). + expect(screen.queryByRole("checkbox", { name: /Unload kernel/i })).not.toBeInTheDocument(); + // Provider + standard rows have checkboxes. + expect(screen.getByRole("checkbox", { name: /Unload opencode/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Unload heartbeat/i })).toBeChecked(); + }); + + it("disabled-with-reason rows show their reason", async () => { + await renderManager(); + expect(screen.getByText("activation threw")).toBeInTheDocument(); + }); +}); + +describe("PluginManager — cascade-uncheck warning (§8)", () => { + it("unchecking a plugin with loaded dependents warns first (no request yet)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http depends on heartbeat → unchecking heartbeat cascades. + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + expect(http.unload).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: /Unload heartbeat/i })).toBeInTheDocument(); + expect(screen.getByText(/also unchecks: transport-http/)).toBeInTheDocument(); + }); + + it("accepting the warning issues the forced unload (the cascade is server-side)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Unload with force" })); + expect(http.unload).toHaveBeenCalledWith("heartbeat", true); + expect(screen.queryByRole("dialog", { name: /Unload heartbeat/i })).not.toBeInTheDocument(); + }); + + it("cancelling the warning issues nothing", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("checkbox", { name: /Unload heartbeat/i })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(http.unload).not.toHaveBeenCalled(); + }); + + it("unchecking WITHOUT dependents unloads directly (no warning)", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + // transport-http has no loaded dependents (it DEPENDS on heartbeat, not + // the reverse) — and it is not a provider, so the last-provider lock + // (which correctly disables `opencode`, the fixture's only provider) + // does not apply. + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + expect(http.unload).toHaveBeenCalledWith("transport-http", false); + expect(screen.queryByRole("dialog", { name: /Unload/i })).not.toBeInTheDocument(); + }); + + it("a refused unload offers force with the global-stop copy (§8 in-use force)", async () => { + const user = userEvent.setup(); + const http = fakeHttp({ + unload: vi.fn( + async (): Promise<PluginOpResult> => ({ + ok: false, + error: "the engine refused — the plugin is in use", + reason: "engine-refused", + }), + ), + }); + await renderManager(http); + await user.click(screen.getByRole("checkbox", { name: /Unload transport-http/i })); + // The refusal is surfaced both in the manager's error line and the + // confirm dialog's notice (the dialog appears after the async refusal). + expect( + await screen.findAllByText(/the engine refused/).then((els) => els.length), + ).toBeGreaterThanOrEqual(1); + expect(await screen.findByText(/stops ALL agents globally/i)).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Force unload" })); + expect(http.unload).toHaveBeenLastCalledWith("transport-http", true); + }); +}); + +describe("PluginManager — last-provider lock (§8)", () => { + it("the last loaded provider's checkbox is disabled with a tooltip", async () => { + const single: PluginListResponse = { + loaded: [ + { + id: "opencode", + name: "OpenCode", + version: "2.0.0", + tier: "providers", + trust: "bundled", + locked: false, + dependsOn: [], + }, + { + id: "kernel", + name: "Kernel", + version: "1.0.0", + tier: "core", + trust: "bundled", + locked: true, + dependsOn: [], + }, + ], + disabledWithReason: [], + discovered: [], + missing: [], + }; + const store = createPluginsStore(fakeHttp({ list: async () => single })); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + const checkbox = screen.getByRole("checkbox", { name: /Unload opencode/i }); + expect(checkbox).toBeDisabled(); + // The explanatory copy is wired via the title attribute (the tooltip). + expect(checkbox.getAttribute("title")).toMatch(/last provider/i); + }); +}); + +describe("PluginManager — load with auto-checked prereqs (§8)", () => { + it("checking a discovered plugin loads it and reflects the stateful refetch", async () => { + const user = userEvent.setup(); + // Stateful fake: after `load("fresh")`, the list reports fresh as loaded + // (the store refetches from the server after the op — the checkbox must + // reflect the NEW list, not a local guess). + let freshLoaded = false; + const stateful: PluginsHttp = { + ...fakeHttp(), + list: vi.fn(async () => + freshLoaded + ? { + ...listResponse, + discovered: [], + loaded: [ + ...listResponse.loaded, + { + id: "fresh", + name: "Fresh", + version: "3.0.0", + tier: "standard", + trust: "external", + locked: false, + dependsOn: [], + }, + ], + } + : listResponse, + ), + load: vi.fn(async (id: string) => { + if (id === "fresh") freshLoaded = true; + return { ok: true, response: { ok: true, action: "load", id } }; + }), + } as unknown as PluginsHttp; + const store = createPluginsStore(stateful); + await store.refresh(); + render(PluginManager, { props: { plugins: store, onClose: vi.fn() } }); + await user.click(screen.getByRole("checkbox", { name: /Load fresh/i })); + expect(stateful.load).toHaveBeenCalledWith("fresh"); + expect(screen.getByRole("checkbox", { name: /Unload fresh/i })).toBeChecked(); + }); + + it("the Scan button triggers a manual scan", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Scan/ })); + expect(http.scan).toHaveBeenCalledOnce(); + }); + + it("the Reload affordance calls reload for a loaded row", async () => { + const user = userEvent.setup(); + const { http } = await renderManager(); + await user.click(screen.getByRole("button", { name: /Reload transport-http/i })); + expect(http.reload).toHaveBeenCalledWith("transport-http"); + }); +}); diff --git a/src/features/plugins/ui/PluginRow.svelte b/src/features/plugins/ui/PluginRow.svelte new file mode 100644 index 0000000..73e0b18 --- /dev/null +++ b/src/features/plugins/ui/PluginRow.svelte @@ -0,0 +1,120 @@ +<script lang="ts"> + import type { PluginNode } from "../logic/types"; + import { tierLabel } from "../logic/types"; + + let { + node, + highlight, + blockReason = null, + busy = false, + onToggle, + onReload, + onHover, + onUnhover, + }: { + node: PluginNode; + /** Hover-highlight state for this row: a direct dep, a dependent, or none. */ + highlight: "dep" | "dependent" | null; + /** + * Why the unload control is disabled (core-locked / last-provider), or + * null when interactive. Missing rows are always disabled (no plugin). + */ + blockReason?: string | null; + /** Whether a mutation for this row is in flight (spinner in the controls). */ + busy?: boolean; + onToggle: (node: PluginNode, checked: boolean) => void; + onReload?: (node: PluginNode) => void; + onHover: (id: string) => void; + onUnhover: (id: string) => void; + } = $props(); + + // Missing rows have no checkbox at all — there is no plugin to load. + const hasCheckbox = $derived(node.section !== "missing"); + // Core rows are LOCKED (no load/unload control) per §3/§5. + const interactive = $derived(blockReason === null && !busy); + const rowClass = $derived( + [ + node.section === "missing" || node.locked || (!node.loaded && node.section === "discovered") + ? "opacity-50" + : "", + highlight === "dep" + ? "outline outline-1 outline-info bg-info/10" + : highlight === "dependent" + ? "outline outline-1 outline-warning bg-warning/10" + : "", + ] + .filter(Boolean) + .join(" "), + ); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> +<tr + class={rowClass} + onmouseover={() => onHover(node.id)} + onmouseout={() => onUnhover(node.id)} + onfocus={() => onHover(node.id)} + onblur={() => onUnhover(node.id)} +> + <td class="w-10"> + {#if hasCheckbox} + {#if node.locked} + <span + class="inline-block w-4 text-center opacity-40" + title={blockReason ?? "Core plugins cannot be unloaded"} + aria-label="Locked (core)">🔒</span + > + {:else} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={node.loaded} + disabled={!interactive} + title={blockReason ?? undefined} + aria-label={`${node.loaded ? "Unload" : "Load"} ${node.id}`} + onchange={(e) => onToggle(node, e.currentTarget.checked)} + /> + {/if} + {/if} + </td> + <td> + <div class="flex flex-col"> + <span class="font-medium">{node.name}</span> + {#if node.reason !== null} + <span class="text-xs text-error">{node.reason}</span> + {/if} + {#if node.frontendIssue !== undefined} + <!-- plan 03: the FE half's state — "incompatible frontend plugin" or the activation error --> + <span class="text-xs text-error">{node.frontendIssue}</span> + {/if} + {#if node.dependsOn.length > 0} + <span class="text-xs opacity-60">depends on: {node.dependsOn.join(", ")}</span> + {/if} + </div> + </td> + <td class="text-xs"> + <span class="badge badge-ghost badge-sm">{tierLabel(node.tier)}</span> + {#if node.trust !== null} + <span class="badge badge-ghost badge-sm">{node.trust}</span> + {/if} + </td> + <td class="text-xs opacity-60">{node.version}</td> + <td class="text-right"> + {#if node.loaded && onReload !== undefined} + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={!interactive} + title="Reload this plugin's code (picks up updates)" + aria-label={`Reload ${node.id}`} + onclick={() => onReload?.(node)} + > + {#if busy} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ↻ + {/if} + </button> + {/if} + </td> +</tr> diff --git a/src/features/plugins/ui/PluginTable.svelte b/src/features/plugins/ui/PluginTable.svelte new file mode 100644 index 0000000..717f839 --- /dev/null +++ b/src/features/plugins/ui/PluginTable.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import type { PluginNode } from "../logic/types"; + import { tierLabel } from "../logic/types"; + import { groupByTier } from "../logic/view-model"; + import PluginRow from "./PluginRow.svelte"; + + let { + title, + rows, + blockReasonFor, + highlightFor, + busyIds, + onToggle, + onReload, + onHover, + onUnhover, + }: { + title: string; + rows: readonly PluginNode[]; + /** + * Why a row's control is disabled (core-locked / last-provider), or null + * when interactive. Injected by the manager (it owns the graph context). + */ + blockReasonFor: (id: string) => string | null; + /** + * The §8 hover-highlight state for one row: a highlighted dependency, a + * highlighted dependent, or none. Injected by the manager (it owns the + * hovered id + the graph). + */ + highlightFor: (id: string) => "dep" | "dependent" | null; + /** Ids with an in-flight mutation (spinner in their controls). */ + busyIds: ReadonlySet<string>; + onToggle: (node: PluginNode, checked: boolean) => void; + onReload?: (node: PluginNode) => void; + onHover: (id: string) => void; + onUnhover: (id: string) => void; + } = $props(); + + // Tier-grouped rows (Core → Providers → Standard) per the plan-07 decision. + const groups = $derived(groupByTier(rows)); +</script> + +<section class="flex flex-col gap-1"> + <h4 class="text-xs font-semibold uppercase opacity-60">{title}</h4> + {#if rows.length === 0} + <p class="text-xs opacity-50">Nothing here.</p> + {:else} + <table class="table table-sm"> + <thead> + <tr> + <th class="w-10"> + <span class="sr-only">Load/unload</span> + </th> + <th>Plugin</th> + <th>Class</th> + <th>Version</th> + <th class="text-right"> + <span class="sr-only">Actions</span> + </th> + </tr> + </thead> + {#each groups as group (group.tier)} + <tbody> + <tr> + <td colspan="5" class="bg-base-200/40 text-xs font-semibold uppercase opacity-60"> + {tierLabel(group.tier)} + </td> + </tr> + {#each group.rows as node (node.id)} + <PluginRow + {node} + highlight={highlightFor(node.id)} + blockReason={blockReasonFor(node.id)} + busy={busyIds.has(node.id)} + {onToggle} + {...(onReload ? { onReload } : {})} + {onHover} + {onUnhover} + /> + {/each} + </tbody> + {/each} + </table> + {/if} +</section> diff --git a/src/features/plugins/ui/ResetLayoutConfirm.svelte b/src/features/plugins/ui/ResetLayoutConfirm.svelte new file mode 100644 index 0000000..1f26601 --- /dev/null +++ b/src/features/plugins/ui/ResetLayoutConfirm.svelte @@ -0,0 +1,30 @@ +<script lang="ts"> + /** + * The "Reset layout to default?" confirm (plan 04 §4.4) — the dialog copy + * names the consequence exactly: this workspace's layout returns to the + * default; plugin and dropdown customizations are lost (there is NO + * undo — the no-revert semantics make the reset the recovery path). + */ + let { + onConfirm, + onCancel, + }: { + onConfirm: () => void; + onCancel: () => void; + } = $props(); +</script> + +<div class="modal modal-open" role="dialog" aria-label="Reset layout to default?"> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold text-warning">Reset layout to default?</h3> + <p class="py-3 text-sm"> + This workspace's layout returns to the default; plugin and dropdown customizations are lost. + </p> + <div class="modal-action"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}>Reset</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onCancel}></div> +</div> diff --git a/src/features/plugins/ui/UnloadConfirmModal.svelte b/src/features/plugins/ui/UnloadConfirmModal.svelte new file mode 100644 index 0000000..6ae95cc --- /dev/null +++ b/src/features/plugins/ui/UnloadConfirmModal.svelte @@ -0,0 +1,40 @@ +<script lang="ts"> + let { + headline, + lines, + confirmLabel, + onCancel, + onConfirm, + }: { + /** The warning headline (e.g. "Unload heartbeat?"). */ + headline: string; + /** The warning body lines: what else unchecks + the force blast radius. */ + lines: readonly string[]; + confirmLabel: string; + onCancel: () => void; + onConfirm: () => void; + } = $props(); +</script> + +<div class="modal modal-open" role="dialog" aria-label={headline}> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold text-warning">{headline}</h3> + <div class="flex flex-col gap-1 py-3"> + {#each lines as line (line)} + <p class="text-sm">{line}</p> + {/each} + <p class="mt-1 text-xs text-error"> + Force stops ALL agents globally — every active turn in every workspace — not just agents + using this plugin. + </p> + </div> + <div class="modal-action"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onCancel}>Cancel</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onConfirm}> + {confirmLabel} + </button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onCancel}></div> +</div> diff --git a/src/features/plugins/ui/WorkspacePluginModal.svelte b/src/features/plugins/ui/WorkspacePluginModal.svelte new file mode 100644 index 0000000..0ba7cd6 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.svelte @@ -0,0 +1,171 @@ +<script lang="ts"> + import { onMount } from "svelte"; + import type { PluginsStore } from "../store.svelte"; + import { rowBlockReason, rowChecked, rowSelectable, stateLabel } from "../logic/workspace-row"; + import type { WorkspaceLayoutPort } from "../logic/types"; + import ResetLayoutConfirm from "./ResetLayoutConfirm.svelte"; + + let { + workspaceId, + workspaceTitle, + plugins, + layout, + onClose, + }: { + workspaceId: string; + /** The workspace's display title (modal heading). */ + workspaceTitle: string; + plugins: PluginsStore; + /** + * The layout port (plan 04 §4.4) — gates + powers the "Reset layout to + * default" affordance. Absent → the section doesn't render (tests). + */ + layout?: WorkspaceLayoutPort; + onClose: () => void; + } = $props(); + + onMount(() => { + // Load only when the view isn't already showing THIS workspace (reopening + // the same modal never flashes; a different id reloads it). + if (plugins.workspaceView?.workspaceId !== workspaceId) { + void plugins.openWorkspace(workspaceId); + } + }); + + // The default workspace is the template (§7): its states are what other + // workspaces inherit when they first open. + const isTemplate = $derived(workspaceId === "default"); + + async function toggle(pluginId: string, checked: boolean): Promise<void> { + await plugins.setWorkspaceState(workspaceId, pluginId, checked ? "enabled" : "disabled"); + // The per-workspace activation rule (layout): toggling a plugin may have + // changed which layout plugin serves THIS workspace — re-probe so the + // newly-enabled provider's tree (or the no-provider degrade) renders + // without a reload. The store ignores non-active workspaces. + layout?.refresh(workspaceId); + } + + // ── The reset affordance (plan 04 §4.4) ───────────────────────────────── + let resetOpen = $state(false); + let resetting = $state(false); + let resetError = $state<string | null>(null); + + async function confirmReset(): Promise<void> { + if (layout === undefined || resetting) return; + resetting = true; + resetError = null; + const failure = await layout.reset(workspaceId); + resetting = false; + resetOpen = false; + if (failure !== null) resetError = failure; + } +</script> + +<div class="modal modal-open" role="dialog" aria-label={`Plugins for ${workspaceTitle}`}> + <div class="modal-box max-w-2xl"> + <h3 class="text-lg font-bold">Plugins — {workspaceTitle}</h3> + <p class="mt-1 text-xs opacity-60"> + What THIS workspace uses. Unloaded plugins are greyed out — only the global plugin manager + loads code. + </p> + {#if isTemplate} + <p class="mt-1 text-xs text-info"> + This is the default workspace — other workspaces inherit these states when they first open. + </p> + {/if} + + {#if plugins.error} + <p class="mt-2 text-xs text-error" role="alert">{plugins.error}</p> + {/if} + + {#if plugins.workspaceView === null} + <div class="flex h-24 items-center justify-center"> + <span class="loading loading-spinner loading-sm opacity-60"></span> + </div> + {:else} + <table class="table table-sm mt-2"> + <thead> + <tr> + <th class="w-10"> + <span class="sr-only">Enabled</span> + </th> + <th>Plugin</th> + <th>State</th> + </tr> + </thead> + <tbody> + {#each plugins.workspaceView.rows as row (row.pluginId)} + <tr class={row.loaded ? "" : "opacity-50"}> + <td> + {#if rowSelectable(row)} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={rowChecked(row)} + aria-label={`${rowChecked(row) ? "Disable" : "Enable"} ${row.pluginId} in this workspace`} + onchange={(e) => toggle(row.pluginId, e.currentTarget.checked)} + /> + {:else} + <input + type="checkbox" + class="checkbox checkbox-sm" + checked={rowChecked(row)} + disabled + aria-label={`${row.pluginId} (unavailable in this workspace)`} + title={rowBlockReason(row) ?? undefined} + /> + {/if} + </td> + <td> + <span class="font-medium">{row.pluginId}</span> + <span class="badge badge-ghost badge-sm ml-2 text-xs">{row.tier}</span> + </td> + <td class="text-xs opacity-70"> + {stateLabel(row.state)} + </td> + </tr> + {/each} + </tbody> + </table> + {/if} + + {#if layout} + <div class="mt-3 border-t border-base-300 pt-3"> + <div class="flex items-center justify-between gap-3"> + <div> + <p class="text-sm font-medium">Layout</p> + {#if layout.providerActive} + <p class="text-xs opacity-60">Managed by the active layout plugin.</p> + {:else} + <p class="text-xs opacity-60">No layout plugin enabled — no layout to reset.</p> + {/if} + </div> + <button + type="button" + class="btn btn-warning btn-sm" + disabled={!layout.providerActive || resetting} + title={layout.providerActive + ? "Restore this workspace's default layout" + : "No layout plugin enabled"} + onclick={() => (resetOpen = true)} + > + Reset layout to default + </button> + </div> + {#if resetError} + <p class="mt-1 text-xs text-error" role="alert">Resetting the layout failed: {resetError}</p> + {/if} + </div> + {/if} + + <div class="modal-action"> + <button type="button" class="btn btn-sm" onclick={onClose}>Close</button> + </div> + </div> + <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions --> + <div class="modal-backdrop" onclick={onClose}></div> +</div> + +{#if resetOpen && layout} + <ResetLayoutConfirm onConfirm={() => void confirmReset()} onCancel={() => (resetOpen = false)} /> +{/if} diff --git a/src/features/plugins/ui/WorkspacePluginModal.test.ts b/src/features/plugins/ui/WorkspacePluginModal.test.ts new file mode 100644 index 0000000..4c75ab3 --- /dev/null +++ b/src/features/plugins/ui/WorkspacePluginModal.test.ts @@ -0,0 +1,258 @@ +import type { WorkspacePluginsResponse } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { PluginsHttp } from "../adapter/http"; +import type { WorkspaceLayoutPort } from "../logic/types"; +import { createPluginsStore } from "../store.svelte"; +import WorkspacePluginModal from "./WorkspacePluginModal.svelte"; + +const wsResponse: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + // Core: enabled + locked (the server materializes core to enabled and + // refuses its PUT with a 409 core-locked — plan 06 §6.6). + { pluginId: "kernel", tier: "core", state: "enabled", loaded: true, locked: true }, + // Loaded standard: interactive. + { pluginId: "mcp", tier: "standard", state: "undefined", loaded: true, locked: false }, + // UNLOADED: greyed out + unselectable (§7 strict layering). + { pluginId: "notloaded", tier: "standard", state: "disabled", loaded: false, locked: false }, + ], + enabledProviderCount: 1, +}; + +function fakeHttp(overrides: Partial<PluginsHttp> = {}): PluginsHttp { + return { + list: vi.fn(async () => null), + scan: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + reload: vi.fn(), + workspacePlugins: vi.fn(async () => wsResponse), + setWorkspaceState: vi.fn(async () => ({ + ok: true, + response: { workspaceId: "ws", pluginId: "mcp", state: "enabled" }, + })), + providersStatus: vi.fn(async () => ({ workspaceId: "ws", enabledProviderCount: 1 })), + ...overrides, + } as unknown as PluginsHttp; +} + +async function renderModal(http: PluginsHttp = fakeHttp()) { + const store = createPluginsStore(http); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { workspaceId: "ws", workspaceTitle: "My Workspace", plugins: store, onClose: vi.fn() }, + }); + return { store, http }; +} + +/** A layout port (plan 04 §4.4) scripted per test. */ +function fakeLayoutPort( + providerActive = true, + reset: WorkspaceLayoutPort["reset"] = vi.fn(async () => null), + refresh: WorkspaceLayoutPort["refresh"] = vi.fn(), +): WorkspaceLayoutPort { + return { providerActive, reset, refresh }; +} + +describe("WorkspacePluginModal", () => { + it("renders the workspace's plugin rows", async () => { + await renderModal(); + expect(screen.getByText("Plugins — My Workspace")).toBeInTheDocument(); + expect(screen.getByText("kernel")).toBeInTheDocument(); + expect(screen.getByText("mcp")).toBeInTheDocument(); + }); + + it("checkboxes reflect the workspace state (enabled=checked, undefined=unchecked)", async () => { + await renderModal(); + expect(screen.getByRole("checkbox", { name: /kernel/i })).toBeChecked(); + expect(screen.getByRole("checkbox", { name: /Enable mcp/i })).not.toBeChecked(); + }); + + it("unloaded plugins are greyed out AND unselectable (§7)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /notloaded/i }); + expect(checkbox).toBeDisabled(); + // The row is visually greyed (the opacity marker class). + expect(checkbox.closest("tr")?.className).toContain("opacity-50"); + // The tooltip points at the global manager. + expect(checkbox.getAttribute("title")).toMatch(/plugin manager/i); + }); + + it("core rows render locked (checked but unselectable — the server refuses the PUT)", async () => { + await renderModal(); + const checkbox = screen.getByRole("checkbox", { name: /kernel/i }); + expect(checkbox).toBeDisabled(); + expect(checkbox.getAttribute("title")).toMatch(/Core plugins are enabled by default/i); + }); + + it("checking a selectable row writes enabled via the store", async () => { + const user = userEvent.setup(); + const { http } = await renderModal(); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(http.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "enabled"); + }); + + it("unchecking a checked row writes disabled", async () => { + const user = userEvent.setup(); + const enabled: WorkspacePluginsResponse = { + workspaceId: "ws", + plugins: [ + { pluginId: "mcp", tier: "standard", state: "enabled", loaded: true, locked: false }, + ], + enabledProviderCount: 1, + }; + const fake = fakeHttp({ workspacePlugins: async () => enabled }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Disable mcp/i })); + expect(fake.setWorkspaceState).toHaveBeenCalledWith("ws", "mcp", "disabled"); + }); + + it("a successful toggle calls the layout port's refresh (the per-workspace activation may change the served tree)", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + expect(refresh).toHaveBeenCalledWith("ws"); + }); + + it("a REFUSED toggle (state write fails) still calls refresh — the probe is the idempotent catch-up", async () => { + const user = userEvent.setup(); + const refresh = vi.fn(); + const fake = fakeHttp({ + setWorkspaceState: async () => ({ + ok: false, + error: "Plugin is not loaded — load it globally first", + }), + }); + const store = createPluginsStore(fake); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + layout: fakeLayoutPort( + true, + vi.fn(async () => null), + refresh, + ), + onClose: vi.fn(), + }, + }); + await user.click(screen.getByRole("checkbox", { name: /Enable mcp/i })); + // The attempted state change re-probes either way (a refused write changed + // nothing, so the probe re-syncs the same tree — harmless and consistent). + expect(refresh).toHaveBeenCalledWith("ws"); + }); + + it("shows the template hint for the default workspace", async () => { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("default"); + render(WorkspacePluginModal, { + props: { + workspaceId: "default", + workspaceTitle: "Default", + plugins: store, + onClose: vi.fn(), + }, + }); + expect(screen.getByText(/other workspaces inherit these states/i)).toBeInTheDocument(); + }); +}); + +describe("WorkspacePluginModal — the reset-layout affordance (plan 04 §4.4)", () => { + async function renderWithLayout(layout?: ReturnType<typeof fakeLayoutPort>) { + const store = createPluginsStore(fakeHttp()); + await store.openWorkspace("ws"); + render(WorkspacePluginModal, { + props: { + workspaceId: "ws", + workspaceTitle: "My Workspace", + plugins: store, + ...(layout ? { layout } : {}), + onClose: vi.fn(), + }, + }); + return layout; + } + + it("renders an ENABLED reset button when a layout provider is active", async () => { + await renderWithLayout(fakeLayoutPort(true)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeEnabled(); + }); + + it("the button is DISABLED with the 'no layout plugin enabled' reason when no provider is active", async () => { + await renderWithLayout(fakeLayoutPort(false)); + const button = screen.getByRole("button", { name: /reset layout to default/i }); + expect(button).toBeDisabled(); + expect(button.getAttribute("title")).toMatch(/no layout plugin enabled/i); + expect(screen.getByText(/no layout plugin enabled/i)).toBeInTheDocument(); + }); + + it("no layout port → the layout section doesn't render at all (tests / no wiring)", async () => { + await renderWithLayout(undefined); + expect(screen.queryByRole("button", { name: /reset layout to default/i })).toBeNull(); + }); + + it("confirming the dialog resets the layout (and the dialog copy names the consequence)", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + // The confirm dialog names the consequence exactly (§4.4 copy). + expect( + screen.getByText( + /layout returns to the default; plugin and dropdown customizations are lost/i, + ), + ).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(reset).toHaveBeenCalledWith("ws"); + }); + + it("cancelling the dialog never resets", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => null); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(reset).not.toHaveBeenCalled(); + }); + + it("a failed reset surfaces the reason as an alert", async () => { + const user = userEvent.setup(); + const reset = vi.fn(async () => "no layout plugin enabled"); + await renderWithLayout(fakeLayoutPort(true, reset)); + + await user.click(screen.getByRole("button", { name: /reset layout to default/i })); + await user.click(screen.getByRole("button", { name: /^reset$/i })); + expect(await screen.findByRole("alert")).toHaveTextContent(/resetting the layout failed/i); + }); +}); diff --git a/src/features/search-settings/index.ts b/src/features/search-settings/index.ts new file mode 100644 index 0000000..a6ea160 --- /dev/null +++ b/src/features/search-settings/index.ts @@ -0,0 +1,22 @@ +export type { + LoadSearchBackendConfig, + SaveSearchBackend, + SearchBackendAvailability, + SearchBackendConfig, + SearchBackendConfigResult, +} from "./logic/view-model"; +export { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./logic/view-model"; +export { default as SearchBackendField } from "./ui/SearchBackendField.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "Search Settings", + description: "Global search backend selection for the web_search tool (GET/PUT /search/config)", +} as const; diff --git a/src/features/search-settings/logic/view-model.test.ts b/src/features/search-settings/logic/view-model.test.ts new file mode 100644 index 0000000..7f3ceb5 --- /dev/null +++ b/src/features/search-settings/logic/view-model.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + buildSearchBackendOptions, + NONE_BACKEND_ID, + NONE_BACKEND_LABEL, + normalizeSearchBackendConfig, + type SearchBackendConfig, + searchBackendChanged, + searchBackendLabel, +} from "./view-model"; + +const CONFIG: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: true }, + ], +}; + +describe("buildSearchBackendOptions", () => { + it("lists the available backends in wire order plus the None off state last", () => { + expect(buildSearchBackendOptions(CONFIG)).toEqual([ + { id: "firecrawl", label: "Firecrawl (self-hosted)", disabled: false }, + { id: "umans", label: "Umans (Exa)", disabled: false }, + { id: "none", label: NONE_BACKEND_LABEL, disabled: false }, + ]); + }); + + it("disables unconfigured backends with a suffix (listed, never an error)", () => { + const config: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: false }, + ], + }; + const options = buildSearchBackendOptions(config); + expect(options[1]).toEqual({ + id: "umans", + label: "Umans (Exa) (not configured)", + disabled: true, + }); + expect(options[2]?.id).toBe(NONE_BACKEND_ID); + }); + + it("prepends an unknown persisted selection so the select can render it", () => { + const config: SearchBackendConfig = { backend: "gone", available: [] }; + const options = buildSearchBackendOptions(config); + expect(options[0]).toEqual({ id: "gone", label: "gone (unknown)", disabled: false }); + expect(options.at(-1)?.id).toBe(NONE_BACKEND_ID); + }); + + it("does not duplicate the none option when 'none' is selected", () => { + const config: SearchBackendConfig = { backend: NONE_BACKEND_ID, available: [] }; + const options = buildSearchBackendOptions(config); + expect(options.filter((o) => o.id === NONE_BACKEND_ID)).toHaveLength(1); + }); +}); + +describe("searchBackendLabel", () => { + it("maps a backend id to its advertised label", () => { + expect(searchBackendLabel(CONFIG, "umans")).toBe("Umans (Exa)"); + }); + + it("maps 'none' to the off-state label", () => { + expect(searchBackendLabel(CONFIG, NONE_BACKEND_ID)).toBe(NONE_BACKEND_LABEL); + }); + + it("falls back to the raw id for an unknown backend", () => { + expect(searchBackendLabel(CONFIG, "gone")).toBe("gone"); + }); +}); + +describe("searchBackendChanged", () => { + it("detects a real change and a no-op reselect", () => { + expect(searchBackendChanged("umans", "firecrawl")).toBe(true); + expect(searchBackendChanged("firecrawl", "firecrawl")).toBe(false); + expect(searchBackendChanged(NONE_BACKEND_ID, NONE_BACKEND_ID)).toBe(false); + }); +}); + +describe("normalizeSearchBackendConfig", () => { + it("normalizes a well-formed response", () => { + expect( + normalizeSearchBackendConfig({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }), + ).toEqual({ + backend: "umans", + available: [{ id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }], + }); + }); + + it("degrades a malformed/partial response instead of crashing the renderer", () => { + expect(normalizeSearchBackendConfig(null)).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect(normalizeSearchBackendConfig({})).toEqual({ backend: NONE_BACKEND_ID, available: [] }); + expect( + normalizeSearchBackendConfig({ + backend: "firecrawl", + available: [null, 42, { id: "" }, { label: "no id" }, { id: "umans" }], + }), + ).toEqual({ + backend: "firecrawl", + available: [{ id: "umans", label: "umans", configured: false }], + }); + }); +}); diff --git a/src/features/search-settings/logic/view-model.ts b/src/features/search-settings/logic/view-model.ts new file mode 100644 index 0000000..5d172c9 --- /dev/null +++ b/src/features/search-settings/logic/view-model.ts @@ -0,0 +1,124 @@ +/** + * Pure core for the search-settings feature — zero DOM, zero effects, zero + * Svelte. + * + * The "Search backend" dropdown (Settings pane) picks the engine backing the + * `web_search` tool's SEARCH mode. The selection is GLOBAL and persisted on + * the backend (`GET/PUT /search/config`); decided entries are exactly + * "Firecrawl (self-hosted)" + "Umans (Exa)" plus "None (search disabled)" as + * the off state. Scrape/crawl always route to the self-hosted Firecrawl + * backend regardless of the selection; the tool's `map` mode is removed + * entirely (backend plan). + */ + +/** + * One selectable backend as the backend's wire advertises it (mirrors + * `@dispatch/wire`'s `SearchBackendAvailability`). + */ +export interface SearchBackendAvailability { + readonly id: string; + readonly label: string; + /** The availability gate (umans: `UMANS_API_KEY` resolves; firecrawl: always). */ + readonly configured: boolean; +} + +/** The global search-backend config (`GET /search/config` response). */ +export interface SearchBackendConfig { + /** The selected backend id, or `"none"` (search off). */ + readonly backend: string; + readonly available: readonly SearchBackendAvailability[]; +} + +// ── Injected ports (consumer-defines-port; the composition root adapts the +// store's HTTP calls to these shapes). ────────────────────────────────────── + +/** Outcome of `GET/PUT /search/config`. `unavailable` marks the 503 degrade + * (the backend's search-config service not loaded) so the UI can disable the + * field with a "search not available" hint instead of a generic error. */ +export type SearchBackendConfigResult = + | { readonly ok: true; readonly config: SearchBackendConfig } + | { readonly ok: false; readonly error: string; readonly unavailable?: boolean }; + +export type LoadSearchBackendConfig = () => Promise<SearchBackendConfigResult>; + +export type SaveSearchBackend = (backend: string) => Promise<SearchBackendConfigResult>; + +/** The explicit off-state option label. */ +export const NONE_BACKEND_ID = "none"; +export const NONE_BACKEND_LABEL = "None (search disabled)"; + +/** One rendered `<option>` of the dropdown. */ +export interface SearchBackendOption { + readonly id: string; + readonly label: string; + /** Unconfigured backends stay LISTED but disabled — never an error. */ + readonly disabled: boolean; +} + +/** + * Build the dropdown options from the config: every advertised backend in + * wire order (unconfigured ones disabled with a "not configured" suffix), + * then the "None (search disabled)" off state appended last. If the persisted + * selection is not among them (an unknown id from a newer/older backend), it + * is prepended so the select can render it truthfully. + */ +export function buildSearchBackendOptions( + config: SearchBackendConfig, +): readonly SearchBackendOption[] { + const options: SearchBackendOption[] = config.available.map((b) => ({ + id: b.id, + label: b.configured ? b.label : `${b.label} (not configured)`, + disabled: !b.configured, + })); + if ( + config.backend !== NONE_BACKEND_ID && + !config.available.some((b) => b.id === config.backend) + ) { + options.unshift({ id: config.backend, label: `${config.backend} (unknown)`, disabled: false }); + } + options.push({ id: NONE_BACKEND_ID, label: NONE_BACKEND_LABEL, disabled: false }); + return options; +} + +/** The human-facing label for a backend id (the saved / error messages). */ +export function searchBackendLabel(config: SearchBackendConfig, id: string): string { + if (id === NONE_BACKEND_ID) { + return NONE_BACKEND_LABEL; + } + return config.available.find((b) => b.id === id)?.label ?? id; +} + +/** Whether selecting `selected` would change the persisted backend. */ +export function searchBackendChanged(selected: string, persisted: string): boolean { + return selected !== persisted; +} + +/** + * Normalize the untyped JSON at the network seam so a malformed/partial + * response can never crash the renderer (mirrors `normalizeHeartbeatConfig`). + * Unknown entries degrade to unconfigured; a missing backend falls back to + * `"none"` rendering (the safest default for a UI that must not lie). + */ +export function normalizeSearchBackendConfig(raw: unknown): SearchBackendConfig { + const obj = (raw ?? {}) as { + backend?: unknown; + available?: unknown; + }; + const available = Array.isArray(obj.available) ? obj.available : []; + return { + backend: + typeof obj.backend === "string" && obj.backend.length > 0 ? obj.backend : NONE_BACKEND_ID, + available: available.flatMap((entry) => { + if (entry === null || typeof entry !== "object") return []; + const e = entry as { id?: unknown; label?: unknown; configured?: unknown }; + if (typeof e.id !== "string" || e.id.length === 0) return []; + return [ + { + id: e.id, + label: typeof e.label === "string" ? e.label : e.id, + configured: e.configured === true, + }, + ]; + }), + }; +} diff --git a/src/features/search-settings/ui/SearchBackendField.svelte b/src/features/search-settings/ui/SearchBackendField.svelte new file mode 100644 index 0000000..c1ae450 --- /dev/null +++ b/src/features/search-settings/ui/SearchBackendField.svelte @@ -0,0 +1,142 @@ +<script lang="ts"> + /** + * The "Search backend" dropdown (Settings pane) — picks the engine backing + * the `web_search` tool's SEARCH mode. GLOBAL setting persisted on the + * backend (`GET/PUT /search/config`); SAVE ON CHANGE (no Set button). + * Unconfigured backends stay listed but disabled; "None (search disabled)" + * is the off state. The config affects search mode only — scrape/crawl + * always route to the self-hosted Firecrawl backend. + * + * Thin shell: seeds/reverts the select from the last PERSISTED value, shows + * saving/saved/error feedback, and degrades to a disabled field with a + * "search not available" hint when the backend's search-config service is + * not loaded (503) — matching heartbeat's degrade philosophy. + */ + import { untrack } from "svelte"; + import { + buildSearchBackendOptions, + type LoadSearchBackendConfig, + NONE_BACKEND_ID, + type SaveSearchBackend, + searchBackendChanged, + searchBackendLabel, + type SearchBackendConfig, + } from "../logic/view-model"; + + let { + load, + save, + }: { + /** Fetch the persisted global config (called once on mount). */ + load: LoadSearchBackendConfig; + /** Persist a new backend selection (save-on-change). */ + save: SaveSearchBackend; + } = $props(); + + let config = $state<SearchBackendConfig | null>(null); + let loading = $state(true); + let loadUnavailable = $state(false); + let loadError = $state<string | null>(null); + let selected = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let error = $state<string | null>(null); + let justSaved = $state<string | null>(null); + + $effect(() => { + // Load ONCE on first mount (the setting is GLOBAL — no workspace + // re-seeding); guard against strict-mode double effects via the flag. + untrack(async () => { + if (config !== null || loadUnavailable) return; + loading = true; + loadError = null; + const result = await load(); + loading = false; + if (result.ok) { + config = result.config; + selected = result.config.backend; + lastSeed = result.config.backend; + } else { + loadUnavailable = result.unavailable === true; + loadError = result.error; + } + }); + }); + + const options = $derived(config === null ? [] : buildSearchBackendOptions(config)); + const persisted = $derived(lastSeed); + + async function handleChange(): Promise<void> { + if (saving || config === null) return; + const next = selected; + if (!searchBackendChanged(next, persisted)) return; + saving = true; + error = null; + justSaved = null; + const result = await save(next); + saving = false; + if (result.ok) { + config = result.config; + // Reflect the persisted value back (the backend is authoritative). + selected = result.config.backend; + lastSeed = result.config.backend; + justSaved = searchBackendLabel(result.config, result.config.backend); + } else { + error = result.error; + // Revert the select to the last persisted value on failure. + selected = persisted; + } + } + + function onSelectChange(): void { + error = null; + justSaved = null; + void handleChange(); + } +</script> + +<div class="flex flex-col gap-1"> + <span class="text-xs font-semibold uppercase opacity-60">Search backend</span> + {#if loading} + <div class="skeleton h-7 w-full" aria-label="Search backend loading"></div> + {:else if config === null} + <select + class="select select-bordered select-sm w-full text-xs" + disabled + aria-label="Search backend" + > + <option>{loadUnavailable ? "Search not available" : (loadError ?? "Unavailable")}</option> + </select> + {#if loadError && !loadUnavailable} + <p class="text-xs text-error">{loadError}</p> + {/if} + {:else} + <select + class="select select-bordered select-sm w-full text-xs" + bind:value={selected} + disabled={saving} + onchange={onSelectChange} + aria-label="Search backend" + > + {#each options as option (option.id)} + <option value={option.id} disabled={option.disabled}>{option.label}</option> + {/each} + </select> + {#if saving} + <p class="text-xs opacity-60 flex items-center gap-1"> + <span class="loading loading-spinner loading-xs"></span> + Saving… + </p> + {:else if error} + <p class="text-xs text-error">{error}</p> + {:else if justSaved !== null} + <p class="text-xs text-success">Saved: {justSaved}.</p> + {/if} + {/if} + {#if !loadUnavailable} + <p class="text-xs opacity-50"> + Affects the web_search tool's <em>search</em> mode only — scrape and crawl always use the + self-hosted Firecrawl backend, and the tool's map mode has been removed. + </p> + {/if} +</div> diff --git a/src/features/search-settings/ui/SearchBackendField.test.ts b/src/features/search-settings/ui/SearchBackendField.test.ts new file mode 100644 index 0000000..d8597ef --- /dev/null +++ b/src/features/search-settings/ui/SearchBackendField.test.ts @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { SearchBackendConfig, SearchBackendConfigResult } from "../logic/view-model"; +import SearchBackendField from "./SearchBackendField.svelte"; + +const CONFIG: SearchBackendConfig = { + backend: "firecrawl", + available: [ + { id: "firecrawl", label: "Firecrawl (self-hosted)", configured: true }, + { id: "umans", label: "Umans (Exa)", configured: false }, + ], +}; + +function okLoad(backend = "firecrawl"): () => Promise<SearchBackendConfigResult> { + return async () => ({ ok: true, config: { ...CONFIG, backend } }); +} + +function okSave(backend: string): () => Promise<SearchBackendConfigResult> { + const saves: string[] = []; + const impl = async (value: string): Promise<SearchBackendConfigResult> => { + saves.push(value); + return { ok: true, config: { ...CONFIG, backend: value } }; + }; + // attach the recorder for assertions + (impl as unknown as { saves: string[] }).saves = saves; + void backend; + return impl as () => Promise<SearchBackendConfigResult>; +} + +describe("SearchBackendField", () => { + it("renders the options from the loaded config (unconfigured disabled, None last)", async () => { + render(SearchBackendField, { props: { load: okLoad(), save: okSave("none") } }); + const select = await screen.findByLabelText("Search backend"); + await waitFor(() => expect(select).toBeEnabled()); + const options = select.querySelectorAll("option"); + const labels = [...options].map((o) => o.textContent); + expect(labels).toEqual([ + "Firecrawl (self-hosted)", + "Umans (Exa) (not configured)", + "None (search disabled)", + ]); + expect(options[1]?.disabled).toBe(true); + expect((select as HTMLSelectElement).value).toBe("firecrawl"); + }); + + it("saves on change and shows the saved confirmation with the new label", async () => { + const user = userEvent.setup(); + const save = okSave("umans"); + render(SearchBackendField, { props: { load: okLoad(), save } }); + + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + + // Select the umans entry (disabled here would block it, so flip configured + // via a custom config below); instead select "none" which is always enabled. + await user.selectOptions(select, "none"); + await waitFor(() => expect(screen.getByText(/Saved/)).toBeInTheDocument()); + expect(screen.getByText(/Saved: None \(search disabled\)\./)).toBeInTheDocument(); + const saves = (save as unknown as { saves: string[] }).saves; + expect(saves).toEqual(["none"]); + expect(select.value).toBe("none"); + }); + + it("reverts the selection to the persisted value when the save fails", async () => { + const user = userEvent.setup(); + const save = async (): Promise<SearchBackendConfigResult> => ({ + ok: false, + error: "Set search backend failed (HTTP 500)", + }); + render(SearchBackendField, { props: { load: okLoad(), save } }); + + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + await user.selectOptions(select, "none"); + + expect(await screen.findByText(/HTTP 500/)).toBeInTheDocument(); + expect(select.value).toBe("firecrawl"); + }); + + it("renders disabled with a 'search not available' hint on the 503 degrade", async () => { + const load = async (): Promise<SearchBackendConfigResult> => ({ + ok: false, + error: "Search config failed (HTTP 503)", + unavailable: true, + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + const select = await screen.findByLabelText("Search backend"); + await waitFor(() => expect(select).toBeDisabled()); + expect(screen.getByText(/Search not available/)).toBeInTheDocument(); + // No help text in the unavailable state (nothing to explain — search is off). + expect(screen.queryByText(/search.*mode only/i)).not.toBeInTheDocument(); + }); + + it("renders a loading skeleton while the initial GET is in flight", async () => { + let resolveLoad!: (r: SearchBackendConfigResult) => void; + const load = () => + new Promise<SearchBackendConfigResult>((resolve) => { + resolveLoad = resolve; + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + expect(screen.getByLabelText("Search backend loading")).toBeInTheDocument(); + resolveLoad({ ok: true, config: CONFIG }); + await screen.findByLabelText("Search backend"); + }); + + it("prepends an unknown persisted selection as a selectable option", async () => { + const load = async (): Promise<SearchBackendConfigResult> => ({ + ok: true, + config: { backend: "gone", available: [] }, + }); + render(SearchBackendField, { props: { load, save: okSave("none") } }); + const select = (await screen.findByLabelText("Search backend")) as HTMLSelectElement; + await waitFor(() => expect(select).toBeEnabled()); + expect(select.value).toBe("gone"); + const labels = [...select.querySelectorAll("option")].map((o) => o.textContent); + expect(labels).toEqual(["gone (unknown)", "None (search disabled)"]); + }); +}); diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts index 1e21e3d..f6a3041 100644 --- a/src/features/surface-host/index.ts +++ b/src/features/surface-host/index.ts @@ -1,3 +1,4 @@ +export { MESSAGE_QUEUE_RENDERER_ID, messageQueueCount } from "./logic/message-queue"; export { buildInvoke, planSurface } from "./logic/plan"; export type { FieldView, SurfaceRenderPlan } from "./logic/types"; export { default as SurfaceView } from "./ui/SurfaceView.svelte"; diff --git a/src/features/surface-host/logic/message-queue.test.ts b/src/features/surface-host/logic/message-queue.test.ts index 8d55eb7..93256b4 100644 --- a/src/features/surface-host/logic/message-queue.test.ts +++ b/src/features/surface-host/logic/message-queue.test.ts @@ -1,6 +1,12 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; import type { QueuedMessage } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { parseMessageQueuePayload } from "./message-queue"; +import { + messageQueueCount, + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, +} from "./message-queue"; const msg = (id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage => ({ id, @@ -46,3 +52,89 @@ describe("parseMessageQueuePayload", () => { expect(parseMessageQueuePayload(payload)).toBeNull(); }); }); + +describe("selectVisibleMessages", () => { + it("returns the snapshot unchanged when nothing is cancelled", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + expect(selectVisibleMessages(messages, new Set())).toBe(messages); + }); + + it("hides the optimistically-cancelled row", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m2"])).map((m) => m.id)).toEqual(["m1", "m3"]); + }); + + it("hides multiple cancelled rows", () => { + const messages = [msg("m1", "a"), msg("m2", "b"), msg("m3", "c")]; + expect(selectVisibleMessages(messages, new Set(["m1", "m3"])).map((m) => m.id)).toEqual(["m2"]); + }); + + it("tolerates a cancelled id not present in the snapshot (no-op)", () => { + const messages = [msg("m1", "a")]; + expect(selectVisibleMessages(messages, new Set(["ghost"])).map((m) => m.id)).toEqual(["m1"]); + }); +}); + +describe("reconcileCancelledIds", () => { + it("returns an empty set when nothing was cancelled", () => { + const result = reconcileCancelledIds([msg("m1", "a")], new Set()); + expect(result.size).toBe(0); + }); + + it("drops ids the surface confirmed gone (no longer queued)", () => { + // m1 still queued (cancel pending), m2 confirmed gone (left the snapshot). + const messages = [msg("m1", "a")]; + const result = reconcileCancelledIds(messages, new Set(["m1", "m2"])); + expect([...result]).toEqual(["m1"]); + }); + + it("returns the SAME set identity when nothing changed (no spurious cycle)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const cancelled = new Set(["m1", "m2"]); + expect(reconcileCancelledIds(messages, cancelled)).toBe(cancelled); + }); + + it("returns an empty set when all cancels were confirmed", () => { + const messages = [msg("m1", "a")]; + expect(reconcileCancelledIds(messages, new Set(["m2", "m3"])).size).toBe(0); + }); + + it("keeps a still-queued cancelled id (cancel still pending)", () => { + const messages = [msg("m1", "a"), msg("m2", "b")]; + const result = reconcileCancelledIds(messages, new Set(["m2"])); + expect([...result]).toEqual(["m2"]); + }); +}); + +describe("messageQueueCount", () => { + const spec = (payload: unknown): SurfaceSpec => ({ + id: "message-queue", + region: "inline", + title: "Message queue", + fields: [{ kind: "custom", rendererId: "message-queue", payload }], + }); + + it("counts messages, including image-only input", () => { + expect(messageQueueCount(spec({ messages: [msg("m1", ""), msg("m2", "next")] }))).toBe(2); + }); + + it("distinguishes an empty queue from an unavailable snapshot", () => { + expect(messageQueueCount(spec({ messages: [] }))).toBe(0); + expect(messageQueueCount(null)).toBeUndefined(); + }); + + it("returns unknown for missing fields or a different renderer", () => { + expect(messageQueueCount({ ...spec(null), fields: [] })).toBeUndefined(); + expect( + messageQueueCount({ + ...spec(null), + fields: [{ kind: "custom", rendererId: "other", payload: { messages: [msg("m1", "x")] } }], + }), + ).toBeUndefined(); + }); + + it("returns unknown for malformed payloads", () => { + expect(messageQueueCount(spec({ messages: "nope" }))).toBeUndefined(); + expect(messageQueueCount(spec({ messages: [{ id: "m1" }] }))).toBeUndefined(); + }); +}); diff --git a/src/features/surface-host/logic/message-queue.ts b/src/features/surface-host/logic/message-queue.ts index 79707a5..a49d4d0 100644 --- a/src/features/surface-host/logic/message-queue.ts +++ b/src/features/surface-host/logic/message-queue.ts @@ -1,3 +1,4 @@ +import type { SurfaceSpec } from "@dispatch/ui-contract"; import type { QueuedMessage } from "@dispatch/wire"; /** @@ -41,5 +42,75 @@ export function parseMessageQueuePayload(payload: unknown): MessageQueueData | n return { messages }; } +/** Queue size from a validated snapshot; absent/malformed data stays unknown. */ +export function messageQueueCount(spec: SurfaceSpec | null): number | undefined { + const field = spec?.fields.find( + (field) => field.kind === "custom" && field.rendererId === MESSAGE_QUEUE_RENDERER_ID, + ); + if (field?.kind !== "custom") return undefined; + return parseMessageQueuePayload(field.payload)?.messages.length; +} + /** The `rendererId` the message-queue extension's `custom` surface field uses. */ export const MESSAGE_QUEUE_RENDERER_ID = "message-queue"; + +/** + * Optimistic-removal view-model for the queue list. + * + * The `chat.queue.cancel` op is fire-and-forget + idempotent: success is + * confirmed by the `message-queue` SURFACE updating (the cancelled message + * leaves the snapshot), not by a reply. To avoid a flash of the row lingering + * for a round-trip, the renderer hides a row the instant the user clicks cancel + * (tracking the cancelled id locally), then reconciles when the surface pushes + * the post-cancel snapshot. These two pure helpers drive that — the component + * holds the cancelled-id set as a thin `$state` wrapper and delegates all + * decisions here. + */ + +/** + * The messages the renderer should show: the surface snapshot MINUS any + * optimistically-cancelled ids (a cancel whose surface confirmation hasn't + * arrived yet). Pure — no mutation of inputs. + */ +export function selectVisibleMessages( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): readonly QueuedMessage[] { + if (cancelledIds.size === 0) return messages; + return messages.filter((m) => !cancelledIds.has(m.id)); +} + +/** + * Reconcile the cancelled-id set against a NEW surface snapshot: keep only the + * ids that are STILL queued (the cancel is pending — its surface confirmation + * hasn't landed). Drop ids that have left the snapshot: the server confirmed + * the removal (or the message drained as steering / the queue cleared), so the + * optimistic hide is no longer needed. This keeps the set bounded — it never + * outlives the rows it tracks. Pure — returns a NEW set (callers assign it to + * the reactive `$state`). + */ +export function reconcileCancelledIds( + messages: readonly QueuedMessage[], + cancelledIds: ReadonlySet<string>, +): ReadonlySet<string> { + if (cancelledIds.size === 0) return EMPTY_STRING_SET; + const stillQueued = new Set<string>(); + for (const m of messages) { + if (cancelledIds.has(m.id)) stillQueued.add(m.id); + } + // Same set back → return the input identity so the component's `$state` setter + // sees no change (avoids a spurious reactive cycle). + if (stillQueued.size === cancelledIds.size) { + let same = true; + for (const id of cancelledIds) { + if (!stillQueued.has(id)) { + same = false; + break; + } + } + if (same) return cancelledIds; + } + return stillQueued; +} + +const EMPTY_STRING_SET: ReadonlySet<string> = new Set<string>(); diff --git a/src/features/surface-host/ui/MessageQueueList.svelte b/src/features/surface-host/ui/MessageQueueList.svelte index 554fa02..b260de2 100644 --- a/src/features/surface-host/ui/MessageQueueList.svelte +++ b/src/features/surface-host/ui/MessageQueueList.svelte @@ -1,21 +1,92 @@ <script lang="ts"> - import { parseMessageQueuePayload } from "../logic/message-queue"; + import { + parseMessageQueuePayload, + reconcileCancelledIds, + selectVisibleMessages, + } from "../logic/message-queue"; - let { payload }: { readonly payload: unknown } = $props(); + let { + payload, + onCancel, + }: { + readonly payload: unknown; + /** + * Cancel (remove) a single queued message by id (`chat.queue.cancel`). + * Required-but-nullable (not optional) so a parent can thread a + * `| undefined` callback through under `exactOptionalPropertyTypes`: + * `undefined` → a read-only list (no × affordance), e.g. a generic surface + * context with no conversation scope. The list still reconciles from the + * surface either way. + */ + readonly onCancel: ((messageId: string) => void) | undefined; + } = $props(); // Parse defensively; an unparseable payload yields null → render nothing // (graceful skip, per the custom-field contract). const data = $derived(parseMessageQueuePayload(payload)); + + // Optimistic-removal: a cancelled id is hidden the instant the user clicks, + // ahead of the surface's post-cancel snapshot. Reconcile on every payload + // change so a confirmed-removed id (no longer in the snapshot) is dropped + // from the set — keeping it bounded (pure helpers in logic/message-queue). + let cancelledIds = $state<ReadonlySet<string>>(new Set()); + + $effect(() => { + const parsed = data; + if (parsed === null) return; + const next = reconcileCancelledIds(parsed.messages, cancelledIds); + if (next !== cancelledIds) cancelledIds = next; + }); + + const visible = $derived( + data === null ? [] : selectVisibleMessages(data.messages, cancelledIds), + ); + + function handleCancel(messageId: string): void { + // Optimistically hide the row + fire the cancel (fire-and-forget; the + // surface update reconciles). Idempotent server-side, so a double-click or + // a cancel of an already-drained message is a silent no-op — no rollback. + if (cancelledIds.has(messageId)) return; + const next = new Set(cancelledIds); + next.add(messageId); + cancelledIds = next; + onCancel?.(messageId); + } </script> {#if data !== null && data.messages.length > 0} <ul class="flex flex-col gap-1 text-sm"> - {#each data.messages as msg (msg.id)} - <li class="rounded-box bg-base-200 px-3 py-2"> - <p class="whitespace-pre-wrap">{msg.text}</p> - <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> - {new Date(msg.queuedAt).toLocaleTimeString()} - </time> + {#each visible as msg (msg.id)} + <li class="flex items-start gap-2 rounded-box bg-base-200 px-3 py-2"> + <div class="min-w-0 flex-1"> + <p class="whitespace-pre-wrap break-words">{msg.text}</p> + <time class="text-xs opacity-50" datetime={new Date(msg.queuedAt).toISOString()}> + {new Date(msg.queuedAt).toLocaleTimeString()} + </time> + </div> + {#if onCancel !== undefined} + <button + type="button" + class="btn btn-ghost btn-xs btn-square shrink-0 opacity-60 hover:opacity-100" + title="Cancel this queued message" + aria-label="Cancel this queued message" + onclick={() => handleCancel(msg.id)} + > + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + fill="none" + stroke="currentColor" + stroke-width="2.5" + stroke-linecap="round" + stroke-linejoin="round" + class="h-3.5 w-3.5" + > + <line x1="18" y1="6" x2="6" y2="18"></line> + <line x1="6" y1="6" x2="18" y2="18"></line> + </svg> + </button> + {/if} </li> {/each} </ul> diff --git a/src/features/surface-host/ui/MessageQueueList.test.ts b/src/features/surface-host/ui/MessageQueueList.test.ts new file mode 100644 index 0000000..53044b3 --- /dev/null +++ b/src/features/surface-host/ui/MessageQueueList.test.ts @@ -0,0 +1,135 @@ +import type { QueuedMessage } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import MessageQueueList from "./MessageQueueList.svelte"; + +function msg(id: string, text: string, queuedAt = 1_700_000_000_000): QueuedMessage { + return { id, text, queuedAt }; +} + +/** Build the message-queue surface field payload. */ +function payload(messages: readonly QueuedMessage[]): { messages: readonly QueuedMessage[] } { + return { messages }; +} + +describe("MessageQueueList", () => { + it("renders each queued message's text", () => { + render(MessageQueueList, { + props: { + payload: payload([msg("m1", "steer left"), msg("m2", "go right")]), + onCancel: undefined, + }, + }); + expect(screen.getByText("steer left")).toBeInTheDocument(); + expect(screen.getByText("go right")).toBeInTheDocument(); + }); + + it("renders nothing when the queue is empty", () => { + const { container } = render(MessageQueueList, { + props: { payload: payload([]), onCancel: undefined }, + }); + expect(container.querySelector("ul")).toBeNull(); + }); + + it("renders nothing for a malformed payload (graceful skip)", () => { + const { container } = render(MessageQueueList, { + props: { payload: { nope: true }, onCancel: undefined }, + }); + expect(container.querySelector("ul")).toBeNull(); + }); + + it("omits the cancel button when no onCancel is wired (read-only)", () => { + render(MessageQueueList, { + props: { payload: payload([msg("m1", "steer")]), onCancel: undefined }, + }); + expect(screen.queryByRole("button", { name: /cancel/i })).toBeNull(); + }); + + it("renders a cancel button per row when onCancel is wired", () => { + render(MessageQueueList, { + props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel: vi.fn() }, + }); + expect(screen.getAllByRole("button", { name: /cancel/i })).toHaveLength(2); + }); + + it("clicking cancel fires onCancel with the row's message id and optimistically hides the row", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render(MessageQueueList, { + props: { payload: payload([msg("m1", "keep me"), msg("m2", "cancel me")]), onCancel }, + }); + + // Both rows visible before click. + expect(screen.getByText("keep me")).toBeInTheDocument(); + expect(screen.getByText("cancel me")).toBeInTheDocument(); + + const buttons = screen.getAllByRole("button", { name: /cancel/i }); + // Cancel the SECOND row (m2). Buttons mirror row order. + const cancelM2 = buttons[1]; + if (cancelM2 === undefined) throw new Error("expected two cancel buttons"); + await user.click(cancelM2); + + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCancel).toHaveBeenCalledWith("m2"); + // m2 is optimistically hidden immediately; m1 remains. + expect(screen.getByText("keep me")).toBeInTheDocument(); + expect(screen.queryByText("cancel me")).toBeNull(); + }); + + it("does not fire onCancel twice for a double-click on the same row (idempotent client-side)", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + render(MessageQueueList, { + props: { payload: payload([msg("m1", "x")]), onCancel }, + }); + + const button = screen.getByRole("button", { name: /cancel/i }); + await user.click(button); + // The row is gone after the first click; the button left the DOM, so a + // second click on the stale element is a no-op — onCancel fires once. + await user.click(button).catch(() => {}); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it("reconciles from the surface: a cancelled id gone from the snapshot clears the optimistic hide", async () => { + const user = userEvent.setup(); + const onCancel = vi.fn(); + const { rerender } = render(MessageQueueList, { + props: { payload: payload([msg("m1", "a"), msg("m2", "b")]), onCancel }, + }); + + // Cancel m1 — it hides optimistically. + const cancelButtons = screen.getAllByRole("button", { name: /cancel/i }); + const cancelM1 = cancelButtons[0]; + if (cancelM1 === undefined) throw new Error("expected a cancel button"); + await user.click(cancelM1); + expect(screen.queryByText("a")).toBeNull(); + expect(screen.getByText("b")).toBeInTheDocument(); + + // The surface pushes the post-cancel snapshot: m1 is gone (server confirmed). + // A NEW message m3 arrives in the same snapshot. The list renders m2 + m3. + rerender({ payload: payload([msg("m2", "b"), msg("m3", "c")]), onCancel }); + expect(screen.queryByText("a")).toBeNull(); + expect(screen.getByText("b")).toBeInTheDocument(); + expect(screen.getByText("c")).toBeInTheDocument(); + }); + + it("re-shows a row if the surface snapshot still contains a cancelled id (cancel not yet confirmed)", async () => { + // Edge case: the cancel is in flight and the surface hasn't updated yet, but + // a re-render with the SAME snapshot must keep the row hidden (optimistic). + const user = userEvent.setup(); + const onCancel = vi.fn(); + const samePayload = payload([msg("m1", "a")]); + const { rerender } = render(MessageQueueList, { + props: { payload: samePayload, onCancel }, + }); + + await user.click(screen.getByRole("button", { name: /cancel/i })); + expect(screen.queryByText("a")).toBeNull(); + + // Re-render with the same (stale) snapshot — the row stays hidden. + rerender({ payload: payload([msg("m1", "a")]), onCancel }); + expect(screen.queryByText("a")).toBeNull(); + }); +}); diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index aed8d03..8ce8ade 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -11,7 +11,22 @@ import TodoList from "./TodoList.svelte"; import Toggle from "./Toggle.svelte"; - let { spec, onInvoke }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); + let { + spec, + onInvoke, + onCancelQueuedMessage, + }: { + spec: SurfaceSpec; + onInvoke: (msg: InvokeMessage) => void; + /** + * Cancel a queued message by id — threaded ONLY to the `message-queue` + * renderer. Optional + scoped: generic surfaces (which pass nothing) keep + * rendering a read-only queue list. Kept as a typed callback (never a + * stringly-typed bus); the renderer dispatch is on `rendererId` (a renderer + * KIND), never the surface id. + */ + onCancelQueuedMessage?: (messageId: string) => void; + } = $props(); const plan = $derived(planSurface(spec)); // Consecutive stats render together as one aligned table; everything else is @@ -40,7 +55,7 @@ {#if group.field.rendererId === "table"} <SurfaceTable payload={group.field.payload} /> {:else if group.field.rendererId === "message-queue"} - <MessageQueueList payload={group.field.payload} /> + <MessageQueueList payload={group.field.payload} onCancel={onCancelQueuedMessage} /> {:else if group.field.rendererId === "todo"} <TodoList payload={group.field.payload} /> {/if} diff --git a/src/features/tabs/index.ts b/src/features/tabs/index.ts index 7520215..145a3d9 100644 --- a/src/features/tabs/index.ts +++ b/src/features/tabs/index.ts @@ -1,3 +1,17 @@ +export type { GroupTone, TabGroupForest, TabGroupNode } from "./tab-groups"; +export { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + GROUP_TONES, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + groupTone, + lastRowIsGroup, + visibleGroupParents, +} from "./tab-groups"; export type { Tab, TabsState } from "./tabs"; export { activeTab, @@ -9,6 +23,7 @@ export { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; diff --git a/src/features/tabs/tab-flip.test.ts b/src/features/tabs/tab-flip.test.ts new file mode 100644 index 0000000..43e966f --- /dev/null +++ b/src/features/tabs/tab-flip.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { cardFlipId, forestFlipIds, groupFlipId, isGroupFlipId, leavingFlipIds } from "./tab-flip"; +import { buildTabGroups } from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id, + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +describe("flip ids", () => { + it("distinguishes a card from the group box of the same conversation", () => { + expect(cardFlipId("p")).not.toBe(groupFlipId("p")); + expect(isGroupFlipId(groupFlipId("p"))).toBe(true); + expect(isGroupFlipId(cardFlipId("p"))).toBe(false); + }); +}); + +describe("forestFlipIds", () => { + it("lists every card plus a box for each tab that has visible subagents", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("solo")]); + expect([...forestFlipIds(forest)].sort()).toEqual( + ["c", "g", "group:c", "group:p", "p", "solo"].sort(), + ); + }); + + it("has no boxes when nothing is grouped", () => { + expect([...forestFlipIds(buildTabGroups([tab("a"), tab("b")]))]).toEqual(["a", "b"]); + }); +}); + +describe("leavingFlipIds", () => { + it("returns the rendered ids the next render drops, in rendered order", () => { + const before = buildTabGroups([tab("p"), tab("c", "p"), tab("solo")]); + // Closing the only subagent drops its card AND the parent's group box. + const after = buildTabGroups([tab("p"), tab("solo")]); + expect(leavingFlipIds([...forestFlipIds(before)], forestFlipIds(after))).toEqual([ + "group:p", + "c", + ]); + }); + + it("is empty when a tab is only added", () => { + const next = forestFlipIds(buildTabGroups([tab("a"), tab("b", "a")])); + expect(leavingFlipIds(["a"], next)).toEqual([]); + }); +}); diff --git a/src/features/tabs/tab-flip.ts b/src/features/tabs/tab-flip.ts new file mode 100644 index 0000000..3bb4c03 --- /dev/null +++ b/src/features/tabs/tab-flip.ts @@ -0,0 +1,45 @@ +import type { TabGroupForest, TabGroupNode } from "./tab-groups"; + +/** + * Flip ids — the PURE identity scheme behind the tab grid's FLIP animation. + * + * Every animated element carries a `data-flip-id`: a tab card uses its + * conversation id, a group box uses `group:<parent conversation id>`. The ids + * are stable across re-renders even when the DOM node is replaced (a card + * becoming a double-width group parent is a NEW node), which is what lets + * GSAP Flip morph the old element into the new one. + */ + +const GROUP_PREFIX = "group:"; + +/** Flip id of a tab card. */ +export function cardFlipId(conversationId: string): string { + return conversationId; +} + +/** Flip id of the group box wrapping `parentConversationId` + its subagents. */ +export function groupFlipId(parentConversationId: string): string { + return `${GROUP_PREFIX}${parentConversationId}`; +} + +/** Whether a flip id names a group box (vs a tab card). */ +export function isGroupFlipId(flipId: string): boolean { + return flipId.startsWith(GROUP_PREFIX); +} + +/** Every flip id the grid renders for `forest` (cards + group boxes). */ +export function forestFlipIds(forest: TabGroupForest): Set<string> { + const out = new Set<string>(); + const walk = (node: TabGroupNode): void => { + out.add(cardFlipId(node.tab.conversationId)); + if (node.children.length > 0) out.add(groupFlipId(node.tab.conversationId)); + for (const child of node.children) walk(child); + }; + for (const root of forest.roots) walk(root); + return out; +} + +/** The currently-rendered flip ids that the NEXT render drops (order kept). */ +export function leavingFlipIds(current: readonly string[], next: ReadonlySet<string>): string[] { + return current.filter((id) => !next.has(id)); +} diff --git a/src/features/tabs/tab-groups.test.ts b/src/features/tabs/tab-groups.test.ts new file mode 100644 index 0000000..46b04e7 --- /dev/null +++ b/src/features/tabs/tab-groups.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it } from "vitest"; +import { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + groupBleedPx, + groupDescendants, + groupGutterPx, + groupParentCandidates, + groupTone, + lastRowIsGroup, + visibleGroupParents, +} from "./tab-groups"; +import type { Tab } from "./tabs"; + +function tab(id: string, parent?: string): Tab { + return { + conversationId: id, + model: "m", + title: id.toUpperCase(), + workspaceId: "w", + ...(parent !== undefined ? { tabGroupParentId: parent } : {}), + }; +} + +const ids = (tabs: readonly Tab[]): string[] => tabs.map((t) => t.conversationId); + +describe("visibleGroupParents", () => { + it("keeps a parent that is another open tab", () => { + const parents = visibleGroupParents([tab("a"), tab("b", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBe("a"); + }); + + it("drops a parent that is not open (closed / other workspace) or is the tab itself", () => { + const parents = visibleGroupParents([tab("a", "gone"), tab("b", "b")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + }); + + it("treats every member of a cycle as a root, but keeps hangers-on below them", () => { + const parents = visibleGroupParents([tab("a", "b"), tab("b", "a"), tab("c", "a")]); + expect(parents.get("a")).toBeNull(); + expect(parents.get("b")).toBeNull(); + expect(parents.get("c")).toBe("a"); + }); +}); + +describe("buildTabGroups", () => { + it("returns flat roots and depth 0 when nothing is grouped", () => { + const forest = buildTabGroups([tab("a"), tab("b"), tab("c")]); + expect(forest.maxDepth).toBe(0); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "b", "c"]); + expect(forest.roots.every((n) => n.children.length === 0)).toBe(true); + }); + + it("nests children under their parent in tab order, preserving root order", () => { + // Child listed BEFORE its parent still nests under it. + const forest = buildTabGroups([tab("x", "p"), tab("a"), tab("p"), tab("y", "p")]); + expect(forest.maxDepth).toBe(1); + expect(forest.roots.map((n) => n.tab.conversationId)).toEqual(["a", "p"]); + const p = forest.roots[1]; + expect(p?.children.map((n) => n.tab.conversationId)).toEqual(["x", "y"]); + }); + + it("supports nested groups (subagents with subagents) and reports the depth", () => { + const forest = buildTabGroups([tab("p"), tab("c", "p"), tab("g", "c"), tab("gg", "g")]); + expect(forest.maxDepth).toBe(3); + const c = forest.roots[0]?.children[0]; + expect(c?.tab.conversationId).toBe("c"); + expect(c?.children[0]?.children[0]?.tab.conversationId).toBe("gg"); + }); + + it("renders every tab exactly once", () => { + const tabs = [tab("a", "b"), tab("b", "a"), tab("c", "a"), tab("d", "nope"), tab("e", "c")]; + const seen: string[] = []; + const walk = (nodes: ReturnType<typeof buildTabGroups>["roots"]): void => { + for (const n of nodes) { + seen.push(n.tab.conversationId); + walk(n.children); + } + }; + walk(buildTabGroups(tabs).roots); + expect(seen.sort()).toEqual(["a", "b", "c", "d", "e"]); + }); +}); + +describe("groupDescendants / directGroupChildren / groupParentCandidates", () => { + const tabs = [tab("p"), tab("c1", "p"), tab("c2", "p"), tab("g", "c1"), tab("solo")]; + + it("collects transitive descendants", () => { + expect([...groupDescendants(tabs, "p")].sort()).toEqual(["c1", "c2", "g"]); + expect([...groupDescendants(tabs, "c1")]).toEqual(["g"]); + expect(groupDescendants(tabs, "solo").size).toBe(0); + }); + + it("lists direct children only", () => { + expect(directGroupChildren(tabs, "p")).toEqual(["c1", "c2"]); + }); + + it("offers every tab except self, descendants (cycle) and the current parent", () => { + expect(ids(groupParentCandidates(tabs, "c1"))).toEqual(["c2", "solo"]); + expect(ids(groupParentCandidates(tabs, "p"))).toEqual(["solo"]); + expect(ids(groupParentCandidates(tabs, "solo"))).toEqual(["p", "c1", "c2", "g"]); + }); +}); + +describe("cascadeCloseIds (the ids a cascading tab close removes)", () => { + const tabs = [ + tab("p"), + tab("c1", "p"), + tab("c2", "p"), + tab("g", "c1"), // sub-sub-agent: grouped under c1, still cascades from p + tab("solo"), + ]; + + it("returns the tab first, then its transitive descendants in tab order", () => { + expect(cascadeCloseIds(tabs, "p")).toEqual(["p", "c1", "c2", "g"]); + }); + + it("starts the cascade at a mid-level tab, taking only its own subtree", () => { + expect(cascadeCloseIds(tabs, "c1")).toEqual(["c1", "g"]); + }); + + it("returns just the tab itself when it has no subagents (closes without a prompt)", () => { + expect(cascadeCloseIds(tabs, "solo")).toEqual(["solo"]); + expect(cascadeCloseIds(tabs, "c2")).toEqual(["c2"]); + }); + + it("matches the rendered forest for cycles and manual regrouping", () => { + const cycle = [tab("p", "c1"), tab("c1", "p"), tab("g", "c1")]; + expect(cascadeCloseIds(cycle, "p")).toEqual(["p"]); + expect(cascadeCloseIds(cycle, "c1")).toEqual(["c1", "g"]); + const regrouped = [tab("p"), tab("c1", "solo"), tab("g", "c1"), tab("solo")]; + expect(cascadeCloseIds(regrouped, "p")).toEqual(["p"]); + expect(cascadeCloseIds(regrouped, "solo")).toEqual(["solo", "c1", "g"]); + }); + + it("ignores tabs whose group parent is no longer open", () => { + const stale = [tab("p"), tab("orphan", "gone")]; + expect(cascadeCloseIds(stale, "p")).toEqual(["p"]); + }); +}); + +describe("groupTone", () => { + it("rotates three colours by level, starting at the outermost box", () => { + expect([1, 2, 3, 4, 5, 6, 7].map(groupTone)).toEqual([ + "secondary", + "success", + "accent", + "secondary", + "success", + "accent", + "secondary", + ]); + }); + + it("never gives a box the colour of its parent or grandparent", () => { + for (let level = 3; level <= 20; level++) { + expect(groupTone(level)).not.toBe(groupTone(level - 1)); + expect(groupTone(level)).not.toBe(groupTone(level - 2)); + } + }); + + it("never uses primary (reserved for the active tab)", () => { + for (let level = 1; level <= 6; level++) expect(groupTone(level)).not.toBe("primary"); + }); + + it("stays in range for out-of-range levels", () => { + expect(groupTone(0)).toBe("accent"); + expect(groupTone(-1)).toBe("success"); + }); +}); + +describe("groupBleedPx / groupGutterPx", () => { + it("is zero without groups", () => { + expect(groupGutterPx(0)).toBe(0); + expect(groupBleedPx(1, 0)).toBe(0); + }); + + it("bleeds outer boxes further than inner ones, the outermost filling the gutter", () => { + const max = 3; + const bleeds = [1, 2, 3].map((l) => groupBleedPx(l, max)); + expect(bleeds[0]).toBe(groupGutterPx(max)); + expect(bleeds[0]).toBeGreaterThan(bleeds[1] ?? 0); + expect(bleeds[1]).toBeGreaterThan(bleeds[2] ?? 0); + expect(bleeds[2]).toBeGreaterThan(0); + }); + + it("keeps the gutter bounded for deep nesting", () => { + expect(groupGutterPx(12)).toBeLessThanOrEqual(24); + expect(groupBleedPx(12, 12)).toBeGreaterThan(0); + }); +}); + +describe("lastRowIsGroup (mirrors grid row-dense placement)", () => { + const leaf = (id: string) => ({ tab: tab(id), children: [] }); + const group = (id: string) => ({ tab: tab(id), children: [leaf(`${id}-kid`)] }); + + it("is false with only cards", () => { + expect(lastRowIsGroup([])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), leaf("c")])).toBe(false); + }); + + it("is true when a nested group comes last", () => { + expect(lastRowIsGroup([leaf("a"), group("g")])).toBe(true); + expect(lastRowIsGroup([group("g")])).toBe(true); + }); + + it("stays true when a later card back-fills an earlier half row", () => { + // [a, _] then [g, g]; b fills a's half row → g is still the last row. + expect(lastRowIsGroup([leaf("a"), group("g"), leaf("b")])).toBe(true); + }); + + it("is false when a later card has to open a new row below the group", () => { + expect(lastRowIsGroup([group("g"), leaf("a")])).toBe(false); + expect(lastRowIsGroup([leaf("a"), leaf("b"), group("g"), leaf("c")])).toBe(false); + }); +}); diff --git a/src/features/tabs/tab-groups.ts b/src/features/tabs/tab-groups.ts new file mode 100644 index 0000000..ff3ddd4 --- /dev/null +++ b/src/features/tabs/tab-groups.ts @@ -0,0 +1,211 @@ +import type { Tab } from "./tabs"; + +/** + * Subagent tab groups — the PURE model behind the tab grid. + * + * Each tab may name a tab group parent (`Tab.tabGroupParentId`, the effective + * value from the backend). The tab grid renders the tabs as a forest: a tab + * with ≥1 visible child is a GROUP (double-width card + a darker box wrapping + * it and its children); everything else is a single half-width card. Groups + * nest (subagents can summon subagents). Purely presentational. + */ + +export interface TabGroupNode { + readonly tab: Tab; + /** Visible children, in tab order. Non-empty ⇒ this node renders as a group. */ + readonly children: readonly TabGroupNode[]; +} + +export interface TabGroupForest { + /** Top-level nodes, in tab order. */ + readonly roots: readonly TabGroupNode[]; + /** Deepest group-box nesting level (0 = no groups, 1 = only top-level groups, …). */ + readonly maxDepth: number; +} + +/** + * The VISIBLE tab group parent of every tab in `tabs`: its `tabGroupParentId` + * when that names ANOTHER tab in the same set, else null. A tab whose + * ancestor chain loops back to itself (a cycle — never produced by the + * backend, but tolerated) is treated as a root, so rendering never loops. + */ +export function visibleGroupParents(tabs: readonly Tab[]): ReadonlyMap<string, string | null> { + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + const direct = (id: string): string | null => { + const p = byId.get(id)?.tabGroupParentId; + return p !== undefined && p !== id && byId.has(p) ? p : null; + }; + const out = new Map<string, string | null>(); + for (const tab of tabs) { + const id = tab.conversationId; + const parent = direct(id); + let cyclic = false; + const seen = new Set<string>([id]); + let cursor = parent; + while (cursor !== null) { + if (cursor === id) { + cyclic = true; + break; + } + if (seen.has(cursor)) break; // a loop above us that doesn't include us + seen.add(cursor); + cursor = direct(cursor); + } + out.set(id, cyclic ? null : parent); + } + return out; +} + +/** Build the group forest for one workspace's tab set (input order preserved). */ +export function buildTabGroups(tabs: readonly Tab[]): TabGroupForest { + const parents = visibleGroupParents(tabs); + const childIds = new Map<string, string[]>(); + for (const tab of tabs) { + const p = parents.get(tab.conversationId) ?? null; + if (p === null) continue; + const list = childIds.get(p); + if (list === undefined) childIds.set(p, [tab.conversationId]); + else list.push(tab.conversationId); + } + const byId = new Map(tabs.map((t) => [t.conversationId, t])); + let maxDepth = 0; + const build = (tab: Tab, depth: number): TabGroupNode => { + const ids = childIds.get(tab.conversationId) ?? []; + if (ids.length > 0) maxDepth = Math.max(maxDepth, depth + 1); + const children: TabGroupNode[] = []; + for (const cid of ids) { + const child = byId.get(cid); + if (child !== undefined) children.push(build(child, depth + 1)); + } + return { tab, children }; + }; + const roots = tabs + .filter((t) => (parents.get(t.conversationId) ?? null) === null) + .map((t) => build(t, 0)); + return { roots, maxDepth }; +} + +/** Every (transitive) descendant id of `conversationId` in the VISIBLE grouping. */ +export function groupDescendants(tabs: readonly Tab[], conversationId: string): Set<string> { + const parents = visibleGroupParents(tabs); + const out = new Set<string>(); + let grew = true; + while (grew) { + grew = false; + for (const [id, p] of parents) { + if (out.has(id) || id === conversationId || p === null) continue; + if (p === conversationId || out.has(p)) { + out.add(id); + grew = true; + } + } + } + return out; +} + +/** + * The tabs removed by a CASCADING close of `conversationId` (the user chose + * "also close its subagents"): the tab itself first, then its transitive + * visible-group descendants in tab order. ONE decision covers the whole + * subtree — closing never re-prompts per level. A single-element result (the + * tab alone) means the tab has no subagents and closes without a prompt. + */ +export function cascadeCloseIds(tabs: readonly Tab[], conversationId: string): string[] { + const descendants = groupDescendants(tabs, conversationId); + const ids = [conversationId]; + for (const tab of tabs) { + if (descendants.has(tab.conversationId)) ids.push(tab.conversationId); + } + return ids; +} + +/** + * The tabs `conversationId` may be moved under (the "Add to group" menu): + * every other tab EXCEPT itself, its descendants (that would form a cycle), + * and its current visible group parent (already there). Tab order preserved. + */ +export function groupParentCandidates(tabs: readonly Tab[], conversationId: string): Tab[] { + const parents = visibleGroupParents(tabs); + const current = parents.get(conversationId) ?? null; + const descendants = groupDescendants(tabs, conversationId); + return tabs.filter( + (t) => + t.conversationId !== conversationId && + t.conversationId !== current && + !descendants.has(t.conversationId), + ); +} + +/** Ids of the tabs DIRECTLY grouped under `conversationId` (visible grouping). */ +export function directGroupChildren(tabs: readonly Tab[], conversationId: string): string[] { + const parents = visibleGroupParents(tabs); + return tabs + .filter((t) => parents.get(t.conversationId) === conversationId) + .map((t) => t.conversationId); +} + +/** + * Whether a group box's LAST grid row is a nested group box (rather than a + * card). Mirrors the grid's `row dense` auto-placement: the group parent's + * double-width card fills row 0; a card takes the first free half-row (or + * opens a new row); a nested group (spans both columns) always opens a new + * row. Used to trim the outer box's bottom padding so the gap below a + * nested box matches the gap at its sides (an even-looking box). + */ +export function lastRowIsGroup(children: readonly TabGroupNode[]): boolean { + let openHalfRows = 0; // rows with exactly one free cell + let lastIsGroup = false; // the row-0 parent card is not a group row + for (const child of children) { + if (child.children.length > 0) { + lastIsGroup = true; // opens a new (last) row + } else if (openHalfRows > 0) { + openHalfRows -= 1; // back-fills an earlier half row; last row unchanged + } else { + openHalfRows += 1; // opens a new (last) row, half-filled + lastIsGroup = false; + } + } + return lastIsGroup; +} + +/** + * The DaisyUI theme colour a group box at nesting `level` (1 = outermost) is + * tinted with. Three colours ROTATE by level, so a box always differs from + * the box around it AND from the one around that (with two colours a box + * would match its grandparent). `primary` is deliberately excluded — it marks + * the active tab, which must stay legible inside any box. + */ +export const GROUP_TONES = ["secondary", "success", "accent"] as const; +export type GroupTone = (typeof GROUP_TONES)[number]; + +export function groupTone(level: number): GroupTone { + const n = GROUP_TONES.length; + const i = (((Math.trunc(level) - 1) % n) + n) % n; // non-negative even for level < 1 + return GROUP_TONES[i] ?? GROUP_TONES[0]; +} + +/** + * Horizontal bleed (px) of a group box at nesting `level` (1 = outermost) + * when the deepest box is `maxDepth`. A box bleeds OUTWARD by this much (a + * negative margin + equal padding), so the cards inside every box sit on + * exactly the same two grid columns as top-level cards — all cards keep + * IDENTICAL dimensions at every depth. Outer boxes bleed more than inner + * ones so nested edges stay visible. The step shrinks for deep nesting so the + * total gutter (`groupGutterPx`) stays bounded. + */ +export function groupBleedPx(level: number, maxDepth: number): number { + if (maxDepth <= 0 || level < 1 || level > maxDepth) return 0; + return (maxDepth - level + 1) * groupStepPx(maxDepth); +} + +/** The horizontal gutter the grid must reserve so the outermost box fits. */ +export function groupGutterPx(maxDepth: number): number { + return maxDepth <= 0 ? 0 : maxDepth * groupStepPx(maxDepth); +} + +const MAX_STEP_PX = 6; +const MAX_GUTTER_PX = 18; + +function groupStepPx(maxDepth: number): number { + return Math.max(2, Math.min(MAX_STEP_PX, Math.floor(MAX_GUTTER_PX / maxDepth))); +} diff --git a/src/features/tabs/tabs-store.svelte.ts b/src/features/tabs/tabs-store.svelte.ts index d044b1f..8e63171 100644 --- a/src/features/tabs/tabs-store.svelte.ts +++ b/src/features/tabs/tabs-store.svelte.ts @@ -1,3 +1,4 @@ +import type { NamedAgentSelectionSnapshot } from "../named-agents"; import type { Tab, TabsState } from "./tabs"; import { initialState, @@ -7,7 +8,10 @@ import { openTab as reduceOpenTab, selectTab as reduceSelectTab, setModel as reduceSetModel, + setSnapshot as reduceSetSnapshot, + setTabGroupParent as reduceSetTabGroupParent, setTitle as reduceSetTitle, + setWorkspaceId as reduceSetWorkspaceId, activeTab as selectActiveTab, } from "./tabs"; @@ -27,7 +31,15 @@ export interface TabsStore { selectTab(conversationId: string): void; closeTab(conversationId: string): void; setModel(conversationId: string, model: string): void; + /** + * Set (or clear, with null) a tab's Named Agent snapshot (plan §7). The caller + * keeps the snapshot's captured `model` in sync with the tab's `model`. + */ + setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void; setTitle(conversationId: string, title: string): void; + setWorkspaceId(conversationId: string, workspaceId: string): void; + /** Set (string) or clear (null) a tab's tab group parent (visual subagent group). */ + setTabGroupParent(conversationId: string, parentId: string | null): void; } export function createTabsStore(storage: TabsStorage): TabsStore { @@ -66,8 +78,17 @@ export function createTabsStore(storage: TabsStorage): TabsStore { setModel(conversationId: string, model: string): void { apply(reduceSetModel(state, conversationId, model)); }, + setSnapshot(conversationId: string, snapshot: NamedAgentSelectionSnapshot | null): void { + apply(reduceSetSnapshot(state, conversationId, snapshot)); + }, setTitle(conversationId: string, title: string): void { apply(reduceSetTitle(state, conversationId, title)); }, + setWorkspaceId(conversationId: string, workspaceId: string): void { + apply(reduceSetWorkspaceId(state, conversationId, workspaceId)); + }, + setTabGroupParent(conversationId: string, parentId: string | null): void { + apply(reduceSetTabGroupParent(state, conversationId, parentId)); + }, }; } diff --git a/src/features/tabs/tabs.test.ts b/src/features/tabs/tabs.test.ts index ec93076..696e994 100644 --- a/src/features/tabs/tabs.test.ts +++ b/src/features/tabs/tabs.test.ts @@ -10,6 +10,7 @@ import { newDraft, selectTab, setModel, + setTabGroupParent, setTitle, shortHandle, } from "./tabs"; @@ -223,3 +224,31 @@ describe("shortHandle", () => { expect(shortHandle("abcd", ["abcd", "abcd1234"])).toBe("abcd"); }); }); + +describe("setTabGroupParent", () => { + const base: TabsState = { + tabs: [ + { conversationId: "a", model: "m", title: "A", workspaceId: "w" }, + { conversationId: "b", model: "m", title: "B", workspaceId: "w" }, + ], + activeConversationId: "a", + }; + + it("sets the tab group parent on the named tab only", () => { + const next = setTabGroupParent(base, "b", "a"); + expect(next.tabs[1]?.tabGroupParentId).toBe("a"); + expect(next.tabs[0]).toBe(base.tabs[0]); + expect(next.activeConversationId).toBe("a"); + }); + + it("clears the key entirely with null", () => { + const grouped = setTabGroupParent(base, "b", "a"); + const cleared = setTabGroupParent(grouped, "b", null); + expect(cleared.tabs[1]).toEqual(base.tabs[1]); + expect("tabGroupParentId" in (cleared.tabs[1] ?? {})).toBe(false); + }); + + it("is a no-op for an unknown tab", () => { + expect(setTabGroupParent(base, "zz", "a").tabs).toEqual(base.tabs); + }); +}); diff --git a/src/features/tabs/tabs.ts b/src/features/tabs/tabs.ts index 63cda35..b2dad7d 100644 --- a/src/features/tabs/tabs.ts +++ b/src/features/tabs/tabs.ts @@ -1,9 +1,25 @@ +import type { NamedAgentSelectionSnapshot } from "../named-agents"; + export interface Tab { readonly conversationId: string; readonly model: string; readonly title: string; /** The workspace this tab belongs to (the workspace's URL slug). */ readonly workspaceId: string; + /** + * The Named Agent selection snapshot, when this conversation was selected via + * a Named Agent (plan §7). Retained so the conversation keeps showing the + * friendly label (and its captured raw model) even after the Named Agent is + * renamed/removed. Absent for a raw model selection. + */ + readonly snapshot?: NamedAgentSelectionSnapshot; + /** + * The EFFECTIVE tab group parent (backend `tabGroupParentId`): the + * conversation whose subagent group this tab is visually nested under in + * the tab grid. Purely presentational — distinct from the lineage + * `parentConversationId`. Absent = ungrouped (a top-level tab). + */ + readonly tabGroupParentId?: string; } export interface TabsState { @@ -73,7 +89,36 @@ export function closeTab(state: TabsState, conversationId: string): TabsState { } export function setModel(state: TabsState, conversationId: string, model: string): TabsState { - const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t)); + // A raw-model selection clears any Named Agent snapshot (a raw selection is + // not "via a Named Agent"). Omit the key entirely (`exactOptionalPropertyTypes`). + const tabs = state.tabs.map((t) => + t.conversationId !== conversationId ? t : omitSnapshot({ ...t, model }), + ); + return { tabs, activeConversationId: state.activeConversationId }; +} + +/** Drop the optional `snapshot` key (used to clear a Named Agent selection). */ +function omitSnapshot(tab: Tab): Tab { + if (!("snapshot" in tab)) return tab; + const { snapshot: _omit, ...rest } = tab; + void _omit; + return rest; +} + +/** + * Set (or clear) a tab's Named Agent selection snapshot (plan §7). The snapshot's + * captured `model` should equal the tab's `model` (the resolved raw model); this + * reducer does not rewrite `model` — the caller keeps them in sync. Pass `null` + * to clear (a raw model selection). + */ +export function setSnapshot( + state: TabsState, + conversationId: string, + snapshot: NamedAgentSelectionSnapshot | null, +): TabsState { + const tabs = state.tabs.map((t) => + t.conversationId === conversationId ? { ...t, ...(snapshot === null ? {} : { snapshot }) } : t, + ); return { tabs, activeConversationId: state.activeConversationId }; } @@ -82,6 +127,37 @@ export function setTitle(state: TabsState, conversationId: string, title: string return { tabs, activeConversationId: state.activeConversationId }; } +/** + * Set (string) or clear (null) a tab's tab group parent. Omits the key when + * cleared (`exactOptionalPropertyTypes`). No-op for an unknown tab. + */ +export function setTabGroupParent( + state: TabsState, + conversationId: string, + parentId: string | null, +): TabsState { + const tabs = state.tabs.map((t) => { + if (t.conversationId !== conversationId) return t; + if (parentId !== null) return { ...t, tabGroupParentId: parentId }; + if (!("tabGroupParentId" in t)) return t; + const { tabGroupParentId: _omit, ...rest } = t; + void _omit; + return rest; + }); + return { tabs, activeConversationId: state.activeConversationId }; +} + +export function setWorkspaceId( + state: TabsState, + conversationId: string, + workspaceId: string, +): TabsState { + const tabs = state.tabs.map((t) => + t.conversationId === conversationId ? { ...t, workspaceId } : t, + ); + return { tabs, activeConversationId: state.activeConversationId }; +} + export function activeTab(state: TabsState): Tab | null { if (state.activeConversationId === null) return null; return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null; diff --git a/src/features/tabs/ui.test.ts b/src/features/tabs/ui.test.ts index ae2a021..8f775a1 100644 --- a/src/features/tabs/ui.test.ts +++ b/src/features/tabs/ui.test.ts @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from "@testing-library/svelte"; +import { fireEvent, render, screen, within } from "@testing-library/svelte"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { gsap } from "gsap"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Tab } from "./tabs"; import TabList from "./ui/TabList.svelte"; @@ -165,7 +166,7 @@ describe("TabList", () => { expect(screen.getByText("7c2d")).toBeInTheDocument(); }); - it("renders each tab as a single vertical row (flex-col list, not a horizontal strip)", () => { + it("renders the tabs as a 2-column grid of same-size cards", () => { render(TabList, { props: { tabs: sampleTabs, @@ -176,14 +177,17 @@ describe("TabList", () => { }, }); - // The scroll region containing the tab rows is a vertical flex column. const tabs = screen.getAllByRole("tab"); - expect(tabs.length).toBeGreaterThan(0); - const region = tabs[0]?.parentElement; - expect(region).toHaveClass("flex-col"); + const grid = tabs[0]?.parentElement; + expect(grid).toHaveClass("grid", "grid-cols-2"); + // Every card has the same fixed height; none is double-width without a group. + for (const t of tabs) { + expect(t).toHaveClass("h-[4.5rem]"); + expect(t).not.toHaveClass("col-span-2"); + } }); - it("fixes the tab list region at 40vh so a long set scrolls internally", () => { + it("fixes the tab grid region at 70vh so a long set scrolls internally", () => { render(TabList, { props: { tabs: sampleTabs, @@ -195,8 +199,8 @@ describe("TabList", () => { }); const tabs = screen.getAllByRole("tab"); - const region = tabs[0]?.parentElement; - expect(region).toHaveClass("h-[40vh]"); + const region = tabs[0]?.parentElement?.parentElement; + expect(region).toHaveClass("h-[70vh]"); expect(region).toHaveClass("overflow-y-auto"); }); @@ -295,4 +299,527 @@ describe("TabList", () => { // c3 has no status → no spinner at all. expect(idleTab.querySelector(".loading")).toBeNull(); }); + + describe("subagent groups", () => { + const grouped: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { + conversationId: "c1", + model: "m", + title: "Child 1", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "c2", + model: "m", + title: "Child 2", + workspaceId: "w", + tabGroupParentId: "p", + }, + { + conversationId: "g", + model: "m", + title: "Grandchild", + workspaceId: "w", + tabGroupParentId: "c1", + }, + { conversationId: "solo", model: "m", title: "Solo", workspaceId: "w" }, + ]; + + function renderGrouped(onSetTabGroup = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup, + }, + }); + return onSetTabGroup; + } + + const card = (id: string): HTMLElement => { + const el = document.querySelector<HTMLElement>(`[role="tab"][data-conversation-id="${id}"]`); + if (el === null) throw new Error(`card ${id} not found`); + return el; + }; + + it("renders a group parent double-width inside a darker group box with its subagents", () => { + renderGrouped(); + const box = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + expect(box).not.toBeNull(); + expect(box).toHaveClass("col-span-2"); + expect(card("p")).toHaveClass("col-span-2"); + expect(card("p")).toHaveTextContent("2 subagents"); + expect(box?.contains(card("c1"))).toBe(true); + expect(box?.contains(card("c2"))).toBe(true); + expect(box?.contains(card("solo"))).toBe(false); + // Subagents are regular half-width cards. + expect(card("c2")).not.toHaveClass("col-span-2"); + }); + + it("nests a subagent that has its own subagents in an inner box", () => { + renderGrouped(); + const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]'); + expect(inner).not.toBeNull(); + expect(outer?.contains(inner)).toBe(true); + expect(inner?.dataset.level).toBe("2"); + expect(card("c1")).toHaveClass("col-span-2"); + expect(inner?.contains(card("g"))).toBe(true); + // Inner boxes bleed less than outer ones, so nested edges stay visible + // while the cards keep identical widths. + const bleed = (el: HTMLElement | null) => Number.parseFloat(el?.style.paddingLeft ?? ""); + expect(bleed(inner)).toBeGreaterThan(0); + expect(bleed(outer)).toBeGreaterThan(bleed(inner)); + // Equal internal padding on all four sides (an even-looking box). + for (const box of [outer, inner]) { + const s = box?.style; + expect([s?.paddingTop, s?.paddingRight, s?.paddingBottom]).toEqual([ + s?.paddingLeft, + s?.paddingLeft, + s?.paddingLeft, + ]); + } + // Each level is tinted with its own rotating theme colour. + expect(outer?.dataset.tone).toBe("secondary"); + expect(inner?.dataset.tone).toBe("success"); + // The tint lives on each box's own backdrop layer (FLIP fades it in). + const tint = (box: HTMLElement | null) => + box + ?.querySelector<HTMLElement>(":scope > [data-group-backdrop]") + ?.style.getPropertyValue("--group-tone"); + expect(tint(outer)).toBe("var(--color-secondary)"); + expect(tint(inner)).toBe("var(--color-success)"); + expect(outer?.style.getPropertyValue("margin-inline")).toBe(`-${outer?.style.paddingLeft}`); + }); + + it("returns the group parent to a half-width card once all its subagent tabs close", async () => { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + expect(card("p")).toHaveClass("col-span-2"); + + // Close one subagent: still a group (1 left). + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + expect(card("p")).toHaveClass("col-span-2"); + expect(card("p")).toHaveTextContent("1 subagent"); + + // Close the rest (c1 + its own subagent g): back to a plain half-width card. + await rerender({ + ...props, + tabs: grouped.filter((t) => ["p", "solo"].includes(t.conversationId)), + }); + expect(card("p")).not.toHaveClass("col-span-2"); + expect(card("p")).not.toHaveTextContent("subagent"); + expect(document.querySelector("[data-tab-group]")).toBeNull(); + }); + + describe("FLIP animation (motion allowed)", () => { + /** Jump every running GSAP animation to `progress` (0–1), delays included. */ + function seekAnimations(progress: number): void { + for (const t of gsap.globalTimeline.getChildren(true, true, false)) t.progress(progress); + } + + // jsdom has no matchMedia, which the grid reads as "no motion" — stub + // the outermost edge so the GSAP Flip path actually runs. + beforeEach(() => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: false }) as unknown as MediaQueryList), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const props = { + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }; + + it("leaves an inert ghost of a closed tab (and its dissolved box) that is never a tab", async () => { + const two = grouped.filter((t) => ["p", "c1", "solo"].includes(t.conversationId)); + const { rerender } = render(TabList, { props: { ...props, tabs: two } }); + // jsdom lays nothing out (every box is 0×0) — give elements a real size. + const rect = vi + .spyOn(Element.prototype, "getBoundingClientRect") + .mockReturnValue(new DOMRect(0, 0, 120, 72)); + // Close c1: its card AND p's group box leave. + await rerender({ ...props, tabs: two.filter((t) => t.conversationId !== "c1") }); + rect.mockRestore(); + + const ghosts = [...document.querySelectorAll<HTMLElement>("[data-tab-ghost]")]; + expect(ghosts).toHaveLength(2); + for (const g of ghosts) { + expect(g).toHaveAttribute("aria-hidden", "true"); + expect(g.querySelector("[data-conversation-id],[data-flip-id],[role]")).toBeNull(); + } + // Paint order: the dissolving box's tint goes FIRST in its enclosing + // grid (under every card); the closed card's ghost goes on top. + const grid = screen.getByRole("tablist"); + const boxGhost = grid.firstElementChild as HTMLElement; + expect(boxGhost).toHaveAttribute("data-tab-ghost"); + // A clipping frame around a copy of the tint only — no card copies. + expect(boxGhost.childElementCount).toBe(1); + expect(boxGhost.firstElementChild?.childElementCount).toBe(0); + // Ghosts SHRINK (a clipped height), they don't fade or scale: halfway + // through, each is shorter than it started; at the end they're gone. + const full = ghosts.map((g) => Number.parseFloat(g.style.height)); + seekAnimations(0.5); + ghosts.forEach((g, i) => { + expect(g.style.overflow).toBe("hidden"); + expect(g.style.opacity).toBe(""); + expect(Number.parseFloat(g.style.height)).toBeLessThan(full[i] ?? 0); + }); + const cardGhost = ghosts.find((g) => g !== boxGhost); + expect(cardGhost?.parentElement).toBe(grid.parentElement); + expect(cardGhost).toHaveTextContent("Child 1"); + // Ghosts are invisible to queries: only the real remaining tabs count. + expect(screen.getAllByRole("tab").map((t) => t.dataset.conversationId)).toEqual([ + "p", + "solo", + ]); + expect(screen.getAllByRole("button", { name: "Close tab" })).toHaveLength(2); + seekAnimations(1); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + }); + + it("measures each element at most twice per change (no layout thrash)", async () => { + // Regression: GSAP's Flip plugin cost ~430ms of main-thread work per + // close with ~40 nested tabs. The hand-rolled FLIP reads every element's + // box once before the change and once after — never per tween. + const { rerender } = render(TabList, { props: { ...props, tabs: grouped } }); + const elements = document.querySelectorAll("[data-flip-id]").length; + const spy = vi.spyOn(Element.prototype, "getBoundingClientRect"); + try { + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + // 2 per element + 1 host read per ghost (one ghost here: c2's card). + expect(spy.mock.calls.length).toBeLessThanOrEqual(2 * elements + 1); + } finally { + spy.mockRestore(); + } + }); + + it("does not ghost anything when a subagent is summoned", async () => { + const base = grouped.filter((t) => ["p", "solo"].includes(t.conversationId)); + const { rerender } = render(TabList, { props: { ...props, tabs: base } }); + await rerender({ ...props, tabs: grouped }); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + expect(screen.getAllByRole("tab")).toHaveLength(grouped.length); + // New cards start fully clipped (0 visible height) and grow in from + // the top edge; once done no motion style is left behind. + const ids = ["c1", "c2", "g"]; + for (const id of ids) expect(card(id).style.clipPath).toBe("inset(0% 0% 100% 0%)"); + seekAnimations(1); + for (const id of ids) { + expect(card(id).style.clipPath).toBe(""); + expect(card(id).style.transform).toBe(""); + } + }); + }); + + it("never animates when the user prefers reduced motion", async () => { + vi.stubGlobal( + "matchMedia", + vi.fn(() => ({ matches: true }) as unknown as MediaQueryList), + ); + try { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "c2") }); + expect(document.querySelector("[data-tab-ghost]")).toBeNull(); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("keeps the parent wide while a nested descendant's own parent remains open", async () => { + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + // Close the grandchild only: c1 goes back to half width, p stays a group. + await rerender({ ...props, tabs: grouped.filter((t) => t.conversationId !== "g") }); + expect(card("c1")).not.toHaveClass("col-span-2"); + expect(document.querySelector('[data-tab-group="c1"]')).toBeNull(); + expect(card("p")).toHaveClass("col-span-2"); + }); + + it("trims the bottom padding under a nested box on the last row to match its side gaps", () => { + // p's children: c2 (card) then c1 (nested group) → c1's box is the last row. + const lastIsGroup: readonly Tab[] = [ + { conversationId: "p", model: "m", title: "Parent", workspaceId: "w" }, + { conversationId: "c2", model: "m", title: "C2", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "c1", model: "m", title: "C1", workspaceId: "w", tabGroupParentId: "p" }, + { conversationId: "g", model: "m", title: "G", workspaceId: "w", tabGroupParentId: "c1" }, + ]; + render(TabList, { + props: { + tabs: lastIsGroup, + activeConversationId: "p", + onSelect: vi.fn(), + onClose: vi.fn(), + onNewDraft: vi.fn(), + }, + }); + const outer = document.querySelector<HTMLElement>('[data-tab-group="p"]'); + const inner = document.querySelector<HTMLElement>('[data-tab-group="c1"]'); + const px = (v: string | undefined) => Number.parseFloat(v ?? ""); + const outerSide = px(outer?.style.paddingLeft); + const innerBleed = px(inner?.style.paddingLeft); + // Side gap between the two box edges = outer bleed − inner bleed; the + // bottom gap must equal it. + expect(px(outer?.style.paddingBottom)).toBe(outerSide - innerBleed); + expect(px(outer?.style.paddingTop)).toBe(outerSide); + // The inner box ends in a card → untouched, even on all sides. + expect(px(inner?.style.paddingBottom)).toBe(innerBleed); + }); + + it("right-click opens the menu; 'Remove from group' ungroups the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c2"), { clientX: 10, clientY: 10 }); + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Remove from group/ })); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + expect(screen.queryByRole("menu")).toBeNull(); + }); + + it("'Add to group' lists valid parents (no self/descendants/current) and moves the tab", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("c1")); + const candidates = [...document.querySelectorAll<HTMLElement>("[data-group-candidate]")].map( + (el) => el.dataset.groupCandidate, + ); + expect(candidates).toEqual(["c2", "solo"]); + const solo = document.querySelector<HTMLElement>('[data-group-candidate="solo"]'); + if (solo === null) throw new Error("solo candidate missing"); + await fireEvent.click(solo); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", "solo"); + }); + + it("a group parent's menu can ungroup all of its direct subagents", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("p")); + expect(screen.queryByRole("menuitem", { name: /Remove from group/ })).toBeNull(); + await fireEvent.click(screen.getByRole("menuitem", { name: /Ungroup 2 subagents/ })); + expect(onSetTabGroup).toHaveBeenCalledTimes(2); + expect(onSetTabGroup).toHaveBeenCalledWith("c1", null); + expect(onSetTabGroup).toHaveBeenCalledWith("c2", null); + }); + + it("Escape closes the menu without changing anything", async () => { + const onSetTabGroup = renderGrouped(); + await fireEvent.contextMenu(card("solo")); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("menu")).toBeNull(); + expect(onSetTabGroup).not.toHaveBeenCalled(); + }); + + it("touch-and-hold opens the menu and swallows the follow-up click", async () => { + vi.useFakeTimers(); + try { + const onSelect = vi.fn(); + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect, + onClose: vi.fn(), + onNewDraft: vi.fn(), + onSetTabGroup: vi.fn(), + }, + }); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.getByRole("menu")).toBeInTheDocument(); + await fireEvent.click(card("solo")); + expect(onSelect).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("a touch that moves away cancels the long-press", async () => { + vi.useFakeTimers(); + try { + renderGrouped(); + await fireEvent.pointerDown(card("solo"), { pointerType: "touch", clientX: 5, clientY: 5 }); + await fireEvent.pointerMove(card("solo"), { + pointerType: "touch", + clientX: 5, + clientY: 60, + }); + await vi.advanceTimersByTimeAsync(600); + expect(screen.queryByRole("menu")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); + + describe("closing a tab with subagents (confirm + cascade)", () => { + const dialog = (): HTMLElement => + screen.getByRole("dialog", { name: "Close subagents too?" }); + + async function clickCloseOn(id: string): Promise<void> { + await fireEvent.click(within(card(id)).getByRole("button", { name: "Close tab" })); + } + + function renderClosable(onClose = vi.fn()) { + render(TabList, { + props: { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }, + }); + return onClose; + } + + it("closes a tab WITHOUT subagents directly — no confirm dialog", async () => { + const onClose = renderClosable(); + await clickCloseOn("solo"); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("solo"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("shows ONE confirm dialog for a group parent (never one per descendant)", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + // 3 subagent tabs: c1, c2 and the nested grandchild g. + expect(dialog()).toHaveTextContent("3 subagent tabs"); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("'No, keep them open' closes only the tab itself", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /No, keep them open/ })); + expect(onClose).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledWith("p"); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("'Yes, close all' cascades to every descendant — no further prompts", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + // p first, then its transitive descendants in tab order (c1, c2, g). + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["p", "c1", "c2", "g"]); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("cascades from a mid-level subagent, taking only its own subtree", async () => { + const onClose = renderClosable(); + await clickCloseOn("c1"); + expect(dialog()).toHaveTextContent("1 subagent tab"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((c) => c[0])).toEqual(["c1", "g"]); + }); + + it("restores keyboard focus to the opener after cancellation", async () => { + renderClosable(); + const user = userEvent.setup(); + const opener = within(card("p")).getByRole("button", { name: "Close tab" }); + await user.click(opener); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(opener).toHaveFocus(); + }); + + it("Cancel and backdrop dismiss without closing any tab", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(); + await fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + await clickCloseOn("p"); + await fireEvent.submit(dialog().querySelector("form") as HTMLFormElement); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("keeps the confirmed subtree snapshot when new tabs arrive", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ + ...props, + tabs: [ + ...grouped, + { + conversationId: "late", + model: "m", + title: "Late", + workspaceId: "w", + tabGroupParentId: "p", + }, + ], + }); + expect(dialog()).toHaveTextContent("3 subagent tabs"); + await fireEvent.click(screen.getByRole("button", { name: /Yes, close all/ })); + expect(onClose.mock.calls.map((call) => call[0])).toEqual(["p", "c1", "c2", "g"]); + }); + + it("dismisses when the target disappears (remote close or workspace switch)", async () => { + const onClose = vi.fn(); + const props = { + tabs: grouped, + activeConversationId: "p", + onSelect: vi.fn(), + onClose, + onNewDraft: vi.fn(), + }; + const { rerender } = render(TabList, { props }); + await clickCloseOn("p"); + await rerender({ ...props, tabs: grouped.filter((tab) => tab.conversationId !== "p") }); + expect(screen.queryByRole("dialog")).toBeNull(); + await rerender(props); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it("Escape dismisses the dialog without closing anything", async () => { + const onClose = renderClosable(); + await clickCloseOn("p"); + await fireEvent(dialog(), new Event("cancel", { cancelable: true })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/features/tabs/ui/CloseSubagentsConfirm.svelte b/src/features/tabs/ui/CloseSubagentsConfirm.svelte new file mode 100644 index 0000000..7ffda6c --- /dev/null +++ b/src/features/tabs/ui/CloseSubagentsConfirm.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * The close-subagents confirm dialog: shown when the user closes a tab that + * still has subagent tabs in its (visible) subagent group. ONE dialog for + * the whole subtree — "Yes" cascades the close to every transitive + * descendant (sub-sub-agents included, no further prompts); "No" closes + * only the tab itself. Escape / backdrop dismisses without closing anything. + * Native modal dialog with daisyUI chrome: top-layer rendering, focus + * containment, and inert background even inside the animated sidebar. + */ + let { + tab, + subagentCount, + onKeep, + onCloseAll, + onDismiss, + }: { + /** The tab being closed (drives the title shown in the dialog). */ + tab: Tab; + /** Number of tabs in the tab's subagent group (transitive). */ + subagentCount: number; + /** "No" — close only this tab, keep the subagents open. */ + onKeep: () => void; + /** "Yes" — close this tab AND cascade to all its subagents. */ + onCloseAll: () => void; + /** Escape / backdrop — close nothing. */ + onDismiss: () => void; + } = $props(); + function openModal(node: HTMLDialogElement) { + const opener = document.activeElement; + node.showModal(); + return { + destroy() { + node.close(); + if (opener instanceof HTMLElement && opener.isConnected) opener.focus(); + }, + }; + } +</script> + +<dialog + use:openModal + class="modal" + aria-label="Close subagents too?" + aria-describedby="close-subagents-description" + oncancel={(event) => { + event.preventDefault(); + onDismiss(); + }} +> + <div class="modal-box max-w-md"> + <h3 class="text-lg font-bold">Close subagents too?</h3> + <div id="close-subagents-description" class="flex flex-col gap-1 py-3"> + <p class="text-sm"> + "{tab.title}" has {subagentCount} + {subagentCount === 1 ? "subagent tab" : "subagent tabs"} (including nested ones). + </p> + <p class="text-sm"> + Close them as well? Their own subagents are closed too, and any in-flight turns are + stopped. + </p> + </div> + <div class="modal-action flex-wrap"> + <button type="button" class="btn btn-ghost btn-sm" onclick={onDismiss}>Cancel</button> + <button type="button" class="btn btn-sm" onclick={onKeep}>No, keep them open</button> + <button type="button" class="btn btn-warning btn-sm" onclick={onCloseAll}> + Yes, close all {subagentCount + 1} + </button> + </div> + </div> + <form + method="dialog" + class="modal-backdrop" + onsubmit={(event) => { + event.preventDefault(); + onDismiss(); + }} + > + <button type="submit" aria-label="Dismiss close confirmation">close</button> + </form> +</dialog> diff --git a/src/features/tabs/ui/TabCard.svelte b/src/features/tabs/ui/TabCard.svelte new file mode 100644 index 0000000..ae891c0 --- /dev/null +++ b/src/features/tabs/ui/TabCard.svelte @@ -0,0 +1,234 @@ +<script lang="ts"> + import { cardFlipId } from "../tab-flip"; + import type { Tab } from "../tabs"; + + /** + * One tab in the tab grid — a fixed-height card. Every card has IDENTICAL + * dimensions; a group parent (`wide`) simply spans both grid columns. + * Right-click (or touch-and-hold) asks the owner to open the tab menu. + */ + let { + tab, + handle, + active, + status, + wide = false, + subagentCount = 0, + onSelect, + onClose, + onRename, + onMenu, + }: { + tab: Tab; + /** Short-handle (git-style unique prefix) shown in the id badge. */ + handle: string; + active: boolean; + /** Lifecycle status (drives the spinner), when known. */ + status: string | undefined; + /** Group parent: spans both grid columns. */ + wide?: boolean; + /** Number of direct subagents (shown on a group parent). */ + subagentCount?: number; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onRename?: ((conversationId: string, title: string) => void) | undefined; + /** Open the tab menu at viewport coordinates. */ + onMenu: (conversationId: string, x: number, y: number) => void; + } = $props(); + + // Inline rename: double-click the title to edit, Enter/blur to save. + let editing = $state(false); + let editValue = $state(""); + let editEl = $state<HTMLInputElement>(); + + function startRename(): void { + if (onRename === undefined) return; + editing = true; + editValue = tab.title; + queueMicrotask(() => editEl?.focus()); + } + + function commitRename(): void { + if (!editing) return; + const trimmed = editValue.trim(); + if (trimmed.length > 0 && onRename !== undefined) onRename(tab.conversationId, trimmed); + editing = false; + } + + // Click-to-copy the FULL conversation id; the badge text is selected as the + // feedback (and as a manual-copy fallback when the clipboard is unavailable). + async function copyId(el: HTMLElement): Promise<void> { + const range = document.createRange(); + range.selectNodeContents(el); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + const clipboard = navigator.clipboard; + if (clipboard === undefined) return; + try { + await clipboard.writeText(tab.conversationId); + } catch { + // Selection already lets the user copy manually. + } + } + + // Touch-and-hold → menu (iOS Safari never fires `contextmenu`; Android does, + // and opening twice at the same spot is idempotent). + const LONG_PRESS_MS = 500; + const MOVE_TOLERANCE_PX = 10; + let pressTimer: ReturnType<typeof setTimeout> | null = null; + let pressX = 0; + let pressY = 0; + let suppressClick = false; + + function cancelPress(): void { + if (pressTimer !== null) clearTimeout(pressTimer); + pressTimer = null; + } + + function onPointerDown(e: PointerEvent): void { + if (e.pointerType !== "touch") return; + cancelPress(); + pressX = e.clientX; + pressY = e.clientY; + pressTimer = setTimeout(() => { + pressTimer = null; + suppressClick = true; + onMenu(tab.conversationId, pressX, pressY); + }, LONG_PRESS_MS); + } + + function onPointerMove(e: PointerEvent): void { + if (pressTimer === null) return; + if (Math.hypot(e.clientX - pressX, e.clientY - pressY) > MOVE_TOLERANCE_PX) cancelPress(); + } + + $effect(() => cancelPress); +</script> + +<div + class="tab-card group/card relative flex h-[4.5rem] min-w-0 cursor-pointer flex-col justify-between rounded-md border px-2 py-1.5 text-sm transition-colors {active + ? 'border-primary bg-primary/15' + : 'border-base-content/10 bg-base-300 hover:border-base-content/25'}" + class:col-span-2={wide} + role="tab" + tabindex="0" + aria-selected={active} + title={tab.title} + data-conversation-id={tab.conversationId} + data-flip-id={cardFlipId(tab.conversationId)} + data-wide={wide ? "true" : undefined} + onclick={() => { + if (suppressClick) { + suppressClick = false; + return; + } + onSelect(tab.conversationId); + }} + onkeydown={(e) => { + if (e.target !== e.currentTarget) return; + if (e.key === "Enter") onSelect(tab.conversationId); + else if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) { + e.preventDefault(); + const r = e.currentTarget.getBoundingClientRect(); + onMenu(tab.conversationId, r.left + 8, r.bottom); + } + }} + oncontextmenu={(e) => { + e.preventDefault(); + cancelPress(); + onMenu(tab.conversationId, e.clientX, e.clientY); + }} + onpointerdown={onPointerDown} + onpointermove={onPointerMove} + onpointerup={cancelPress} + onpointercancel={cancelPress} + onpointerleave={cancelPress} +> + <div class="flex min-w-0 items-center gap-1"> + <button + type="button" + class="shrink-0 select-text rounded bg-base-100 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" + data-copy-id={tab.conversationId} + title="Click to copy conversation id" + aria-label={`Copy conversation id ${tab.conversationId}`} + onclick={(e) => { + e.stopPropagation(); + void copyId(e.currentTarget); + }} + > + {handle} + </button> + {#if wide && subagentCount > 0} + <span + class="shrink-0 truncate rounded bg-base-100 px-1 py-0.5 text-[10px] leading-none text-base-content/60" + data-subagent-count={subagentCount} + > + {subagentCount} + {subagentCount === 1 ? "subagent" : "subagents"} + </span> + {/if} + <span class="flex-1"></span> + {#if status === "queued"} + <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> + <span + class="loading loading-ring loading-xs shrink-0 text-primary" + aria-label="Queued" + title="Waiting for a concurrency slot" + ></span> + {:else if status === "active"} + <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> + {/if} + <button + type="button" + class="btn btn-ghost btn-xs -mr-1 h-5 min-h-5 w-5 shrink-0 p-0 text-base-content/60" + aria-label="Close tab" + onclick={(e) => { + e.stopPropagation(); + onClose(tab.conversationId); + }} + > + × + </button> + </div> + {#if editing} + <input + bind:this={editEl} + bind:value={editValue} + class="w-full min-w-0 rounded bg-base-100 px-1 py-0.5 text-left text-xs outline outline-1 outline-primary" + onclick={(e) => e.stopPropagation()} + onkeydown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + editing = false; + } + }} + onblur={commitRename} + /> + {:else} + <span + class="line-clamp-2 min-w-0 break-words text-left text-xs leading-tight" + role="button" + tabindex="-1" + title={tab.title} + ondblclick={(e) => { + e.stopPropagation(); + startRename(); + }} + > + {tab.title} + </span> + {/if} +</div> + +<style> + /* Touch-and-hold opens the tab menu — suppress the native callout/selection. */ + .tab-card { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; + } +</style> diff --git a/src/features/tabs/ui/TabGroupBox.svelte b/src/features/tabs/ui/TabGroupBox.svelte new file mode 100644 index 0000000..5b644e0 --- /dev/null +++ b/src/features/tabs/ui/TabGroupBox.svelte @@ -0,0 +1,90 @@ +<script lang="ts"> + import type { Snippet } from "svelte"; + import type { TabGroupNode } from "../tab-groups"; + import { groupFlipId } from "../tab-flip"; + import { groupBleedPx, groupTone, lastRowIsGroup } from "../tab-groups"; + import type { Tab } from "../tabs"; + import TabGroupBox from "./TabGroupBox.svelte"; + + /** + * A subagent group: a tinted box spanning both grid columns that wraps the + * group parent's (double-width) card + its subagents' cards. Recursive — + * a subagent with its own subagents renders a nested box. The box BLEEDS + * outward (negative margin + equal padding, `groupBleedPx`) so the cards + * inside stay on the same two columns as top-level cards: every card keeps + * identical dimensions at every depth. The padding is EQUAL on all four + * sides (= the bleed) so the box looks even — except below a nested box on + * the last row, where it is trimmed to match that box's side gaps. Each + * box is tinted with one of three theme colours ROTATING by level + * (`groupTone`), painted OPAQUE (mixed into the sidebar well colour) so a + * nested box never blends with the box around it, plus a border in the same + * colour so every box edge reads at a glance. The tint lives on its own + * BACKDROP layer so the FLIP animation (`tab-flip.ts`) can fade a new + * group's tint in while its cards fly into place. + */ + let { + node, + level, + maxDepth, + card, + }: { + node: TabGroupNode; + /** Nesting level of THIS box (1 = outermost). */ + level: number; + maxDepth: number; + /** Renders one card: (tab, wide, subagentCount). */ + card: Snippet<[Tab, boolean, number]>; + } = $props(); + + const bleed = $derived(groupBleedPx(level, maxDepth)); + const tone = $derived(groupTone(level)); + // Even spacing: when the last row is a nested box, the gap below it would + // be this box's full padding while its side gaps are only the DIFFERENCE + // in bleed (the nested box bleeds outward too) — trim the bottom to match. + const padBottom = $derived( + lastRowIsGroup(node.children) ? bleed - groupBleedPx(level + 1, maxDepth) : bleed, + ); +</script> + +<div + class="relative col-span-2" + style:margin-inline="-{bleed}px" + style:padding="{bleed}px {bleed}px {padBottom}px" + role="group" + aria-label={`Subagents of ${node.tab.title}`} + data-tab-group={node.tab.conversationId} + data-level={level} + data-tone={tone} + data-flip-id={groupFlipId(node.tab.conversationId)} +> + <div + class="tab-group-backdrop pointer-events-none absolute inset-0 rounded-lg" + style:--group-tone="var(--color-{tone})" + data-group-backdrop + aria-hidden="true" + ></div> + <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5"> + {@render card(node.tab, true, node.children.length)} + {#each node.children as child (child.tab.conversationId)} + {#if child.children.length > 0} + <TabGroupBox node={child} level={level + 1} {maxDepth} {card} /> + {:else} + {@render card(child.tab, false, 0)} + {/if} + {/each} + </div> +</div> + +<style> + /* Opaque tint: the tone mixed into the sidebar's `.inset-well` colour (the + surface the tab grid sits on), so nested boxes replace — never compound + with — the colour around them. */ + .tab-group-backdrop { + background-color: color-mix( + in oklab, + var(--group-tone) 16%, + var(--inset-well-bg, var(--color-base-200)) + ); + box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--group-tone) 55%, transparent); + } +</style> diff --git a/src/features/tabs/ui/TabList.svelte b/src/features/tabs/ui/TabList.svelte index 6d85065..8b388f0 100644 --- a/src/features/tabs/ui/TabList.svelte +++ b/src/features/tabs/ui/TabList.svelte @@ -1,183 +1,220 @@ <script lang="ts"> - import type { Tab } from "../tabs"; - import { shortHandle } from "../tabs"; + import { untrack } from "svelte"; + import { forestFlipIds } from "../tab-flip"; + import { + buildTabGroups, + cascadeCloseIds, + directGroupChildren, + groupGutterPx, + groupParentCandidates, + visibleGroupParents, + } from "../tab-groups"; + import type { Tab } from "../tabs"; + import { shortHandle } from "../tabs"; + import CloseSubagentsConfirm from "./CloseSubagentsConfirm.svelte"; + import TabCard from "./TabCard.svelte"; + import TabGroupBox from "./TabGroupBox.svelte"; + import TabMenu from "./TabMenu.svelte"; + import { createTabFlip } from "./tab-flip"; - let { - tabs, - activeConversationId, - statusFor, - onSelect, - onClose, - onNewDraft, - onRename, - }: { - tabs: readonly Tab[]; - activeConversationId: string | null; - /** Returns the conversation's lifecycle status, or undefined when unknown. */ - statusFor?: (conversationId: string) => string | undefined; - onSelect: (conversationId: string) => void; - onClose: (conversationId: string) => void; - onNewDraft: () => void; - onRename?: (conversationId: string, title: string) => void; - } = $props(); + let { + tabs, + activeConversationId, + statusFor, + onSelect, + onClose, + onNewDraft, + onRename, + onSetTabGroup, + }: { + tabs: readonly Tab[]; + activeConversationId: string | null; + /** Returns the conversation's lifecycle status, or undefined when unknown. */ + statusFor?: (conversationId: string) => string | undefined; + onSelect: (conversationId: string) => void; + onClose: (conversationId: string) => void; + onNewDraft: () => void; + onRename?: (conversationId: string, title: string) => void; + /** + * Move a tab into a subagent group (parent id) or out of it (null). Purely + * presentational grouping. When absent, the tab menu is disabled. + */ + onSetTabGroup?: (conversationId: string, parentId: string | null) => void; + } = $props(); - // Git-style short handle (shortest unique prefix) per open tab — the visible - // "tab ID". Derived from the set of open conversation ids; pure helper. - const handles = $derived.by(() => { - const ids = tabs.map((t) => t.conversationId); - const map = new Map<string, string>(); - for (const id of ids) map.set(id, shortHandle(id, ids)); - return map; - }); + // Git-style short handle (shortest unique prefix) per open tab — the visible + // "tab ID". Derived from the set of open conversation ids; pure helper. + const handles = $derived.by(() => { + const ids = tabs.map((t) => t.conversationId); + const map = new Map<string, string>(); + for (const id of ids) map.set(id, shortHandle(id, ids)); + return map; + }); + const handleFor = (id: string): string => handles.get(id) ?? id; - // Inline rename: double-click a tab's title to edit, Enter/blur to save. - let editingId = $state<string | null>(null); - let editValue = $state(""); - let editEl = $state<HTMLInputElement>(); + // The subagent-group forest (pure) + the gutter the outermost box bleeds into. + const forest = $derived(buildTabGroups(tabs)); + const gutter = $derived(groupGutterPx(forest.maxDepth)); - function startRename(tab: Tab): void { - if (onRename === undefined) return; - editingId = tab.conversationId; - editValue = tab.title; - // Focus the input after it renders. - queueMicrotask(() => editEl?.focus()); - } + // FLIP: a summoned subagent / closed tab / regroup animates (GSAP Flip). + // Capture positions (+ ghost leaving elements) BEFORE the DOM updates, play + // AFTER. Keyed on `forest` only; the DOM work itself is untracked. + let scrollerEl = $state<HTMLDivElement | undefined>(); + const flip = createTabFlip(() => scrollerEl); + $effect.pre(() => { + const next = forestFlipIds(forest); + untrack(() => flip.capture(next)); + }); + $effect(() => { + void forest; + untrack(() => flip.play()); + }); - function commitRename(): void { - const id = editingId; - if (id !== null && onRename !== undefined) { - const trimmed = editValue.trim(); - if (trimmed.length > 0) onRename(id, trimmed); - } - editingId = null; - } + // Tab menu (right-click / touch-and-hold). Local UI state owned here. + let menu = $state<{ conversationId: string; x: number; y: number } | null>(null); + const menuTab = $derived( + menu === null ? null : (tabs.find((t) => t.conversationId === menu?.conversationId) ?? null), + ); + const menuGroupParent = $derived.by(() => { + if (menuTab === null) return null; + const pid = visibleGroupParents(tabs).get(menuTab.conversationId) ?? null; + return pid === null ? null : (tabs.find((t) => t.conversationId === pid) ?? null); + }); + const menuChildren = $derived( + menuTab === null ? [] : directGroupChildren(tabs, menuTab.conversationId), + ); + const menuCandidates = $derived( + menuTab === null ? [] : groupParentCandidates(tabs, menuTab.conversationId), + ); - function cancelRename(): void { - editingId = null; - } + function openMenu(conversationId: string, x: number, y: number): void { + if (onSetTabGroup === undefined) return; + menu = { conversationId, x, y }; + } - // Click-to-copy the agent (conversation) id: clicking the ID badge copies the - // FULL conversationId to the clipboard (the stable, useful id — the badge - // only shows a short prefix) and highlights the badge text as feedback. The - // highlight (text selection) is the indicator — no text is swapped, so the - // badge keeps a stable width. If the clipboard write fails, the selection is - // already in place so the user can Ctrl+C the text manually. - async function copyId(conversationId: string, el: HTMLElement): Promise<void> { - selectText(el); - const clipboard = navigator.clipboard; - if (clipboard === undefined) return; - try { - await clipboard.writeText(conversationId); - } catch { - // Selection already lets the user copy manually. - } - } + // Close-with-confirm: a tab whose (visible) subagent group is non-empty + // gets ONE confirm dialog covering its whole subtree (never one per level). + // The dialog's ids are snapshotted at open time; a tab the backend closed + // in the meantime is a no-op for `onClose`. A plain tab closes directly. + let closeConfirm = $state<{ tab: Tab; cascadeIds: readonly string[] } | null>(null); - function selectText(el: HTMLElement): void { - const range = document.createRange(); - range.selectNodeContents(el); - const selection = window.getSelection(); - selection?.removeAllRanges(); - selection?.addRange(range); - } + // A remote close or workspace switch must not leave an actionable dialog + // for a tab that is no longer in this list. + $effect(() => { + if ( + closeConfirm !== null && + !tabs.some((tab) => tab.conversationId === closeConfirm?.tab.conversationId) + ) { + closeConfirm = null; + } + }); + + function requestClose(conversationId: string): void { + menu = null; + const cascadeIds = cascadeCloseIds(tabs, conversationId); + if (cascadeIds.length <= 1) { + onClose(conversationId); + return; + } + const tab = tabs.find((t) => t.conversationId === conversationId); + if (tab === undefined) { + onClose(conversationId); + return; + } + closeConfirm = { tab, cascadeIds }; + } </script> +{#snippet card(tab: Tab, wide: boolean, subagentCount: number)} + <TabCard + {tab} + handle={handleFor(tab.conversationId)} + active={tab.conversationId === activeConversationId} + status={statusFor?.(tab.conversationId)} + {wide} + {subagentCount} + {onSelect} + onClose={requestClose} + {onRename} + onMenu={openMenu} + /> +{/snippet} + <div class="flex flex-col gap-2"> - <!-- Single-column vertical tab list. Fixed at 40% of the viewport height so a - long tab set scrolls inside this region instead of growing the whole sidebar. --> - <div class="flex h-[40vh] flex-col gap-1 overflow-y-auto pr-1"> - {#each tabs as tab (tab.conversationId)} - <div - class="flex items-center gap-1.5 rounded px-2 py-1.5 text-sm hover:bg-base-300" - class:bg-base-300={tab.conversationId === activeConversationId} - role="tab" - tabindex="0" - aria-selected={tab.conversationId === activeConversationId} - title={tab.title} - onclick={() => onSelect(tab.conversationId)} - onkeydown={(e) => { - if (e.key === "Enter") onSelect(tab.conversationId); - }} - > - <button - type="button" - class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60 transition-colors hover:bg-primary hover:text-primary-content" - data-copy-id={tab.conversationId} - title="Click to copy conversation id" - aria-label={`Copy conversation id ${tab.conversationId}`} - onclick={(e) => { - e.stopPropagation(); - void copyId(tab.conversationId, e.currentTarget); - }} - > - {handles.get(tab.conversationId) ?? tab.conversationId} - </button> - {#if editingId === tab.conversationId} - <input - bind:this={editEl} - bind:value={editValue} - class="min-w-0 flex-1 rounded bg-base-100 px-1 py-0.5 text-left text-sm outline outline-1 outline-primary" - onclick={(e) => e.stopPropagation()} - onkeydown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - commitRename(); - } else if (e.key === "Escape") { - e.preventDefault(); - cancelRename(); - } - }} - onblur={commitRename} - /> - {:else} - <span - class="min-w-0 flex-1 cursor-pointer truncate text-left" - role="button" - tabindex="-1" - title={tab.title} - ondblclick={(e) => { - e.stopPropagation(); - startRename(tab); - }} - > - {tab.title} - </span> - {/if} - {#if statusFor?.(tab.conversationId) === "queued"} - <!-- Waiting for a concurrency slot — a ring (vs the dots of `active`). --> - <span - class="loading loading-ring loading-xs shrink-0 text-primary" - aria-label="Queued" - title="Waiting for a concurrency slot" - ></span> - {:else if statusFor?.(tab.conversationId) === "active"} - <span class="loading loading-dots loading-xs shrink-0 text-primary"></span> - {/if} - <button - class="btn btn-ghost btn-xs shrink-0" - aria-label="Close tab" - onclick={(e) => { - e.stopPropagation(); - onClose(tab.conversationId); - }} - > - × - </button> - </div> - {/each} - </div> + <!-- 2-column grid of same-size tab cards, fixed at 70% of the viewport height + so a long tab set scrolls inside this region instead of growing the + sidebar. Subagent groups span both columns; `dense` lets a later card + fill the half-row a group would otherwise leave empty. --> + <div + bind:this={scrollerEl} + class="relative h-[70vh] overflow-y-auto py-0.5" + style:padding-inline="{gutter}px" + data-tab-grid + onscroll={() => (menu = null)} + > + <div class="relative grid grid-flow-row-dense grid-cols-2 gap-1.5" role="tablist"> + {#each forest.roots as node (node.tab.conversationId)} + {#if node.children.length > 0} + <TabGroupBox {node} level={1} maxDepth={forest.maxDepth} {card} /> + {:else} + {@render card(node.tab, false, 0)} + {/if} + {/each} + </div> + </div> - <button - type="button" - class="btn btn-ghost btn-sm w-full border border-base-300" - class:btn-primary={activeConversationId === null} - aria-label="New chat" - onclick={() => onNewDraft()} - > - {#if activeConversationId === null} - New Chat - {:else} - + New chat - {/if} - </button> + <button + type="button" + class="btn btn-ghost btn-sm w-full border border-base-300" + class:btn-primary={activeConversationId === null} + aria-label="New chat" + onclick={() => onNewDraft()} + > + {#if activeConversationId === null} + New Chat + {:else} + + New chat + {/if} + </button> </div> + +{#if menu !== null && menuTab !== null && onSetTabGroup !== undefined} + {@const setGroup = onSetTabGroup} + {@const target = menuTab.conversationId} + {@const children = [...menuChildren]} + <TabMenu + tab={menuTab} + handle={handleFor(target)} + x={menu.x} + y={menu.y} + groupParent={menuGroupParent} + childCount={children.length} + candidates={menuCandidates} + {handleFor} + onAddToGroup={(parentId) => setGroup(target, parentId)} + onRemoveFromGroup={() => setGroup(target, null)} + onUngroupChildren={() => { + for (const id of children) setGroup(id, null); + }} + onDismiss={() => (menu = null)} + /> +{/if} + +{#if closeConfirm !== null} + {@const target = closeConfirm.tab} + {@const cascadeIds = [...closeConfirm.cascadeIds]} + {@const closeOwner = onClose} + <CloseSubagentsConfirm + tab={target} + subagentCount={cascadeIds.length - 1} + onKeep={() => { + closeOwner(target.conversationId); + closeConfirm = null; + }} + onCloseAll={() => { + for (const id of cascadeIds) closeOwner(id); + closeConfirm = null; + }} + onDismiss={() => (closeConfirm = null)} + /> +{/if} diff --git a/src/features/tabs/ui/TabMenu.svelte b/src/features/tabs/ui/TabMenu.svelte new file mode 100644 index 0000000..513463f --- /dev/null +++ b/src/features/tabs/ui/TabMenu.svelte @@ -0,0 +1,145 @@ +<script lang="ts"> + import type { Tab } from "../tabs"; + + /** + * The tab context menu (right-click / touch-and-hold on a tab card): add the + * tab to a subagent group, remove it from its group, or release its own + * subagents. Purely presentational grouping. Fixed-positioned at the + * pointer, clamped into the viewport; closes on Escape / outside press / + * scroll / resize. + */ + let { + tab, + handle, + x, + y, + groupParent, + childCount, + candidates, + handleFor, + onAddToGroup, + onRemoveFromGroup, + onUngroupChildren, + onDismiss, + }: { + tab: Tab; + handle: string; + /** Requested viewport position (pointer). */ + x: number; + y: number; + /** The tab's current visible group parent, or null when top-level. */ + groupParent: Tab | null; + /** Number of tabs directly grouped under this tab. */ + childCount: number; + /** Tabs this tab may be moved under. */ + candidates: readonly Tab[]; + handleFor: (conversationId: string) => string; + onAddToGroup: (parentId: string) => void; + onRemoveFromGroup: () => void; + onUngroupChildren: () => void; + onDismiss: () => void; + } = $props(); + + let menuEl = $state<HTMLDivElement>(); + let width = $state(0); + let height = $state(0); + const MARGIN = 8; + + const left = $derived( + Math.max(MARGIN, Math.min(x, (globalThis.innerWidth ?? 0) - width - MARGIN)), + ); + const top = $derived( + Math.max(MARGIN, Math.min(y, (globalThis.innerHeight ?? 0) - height - MARGIN)), + ); + + $effect(() => { + menuEl?.focus(); + }); + + function act(fn: () => void): void { + fn(); + onDismiss(); + } +</script> + +<svelte:window + onkeydown={(e) => { + if (e.key === "Escape") onDismiss(); + }} + onpointerdown={(e) => { + if (menuEl !== undefined && !menuEl.contains(e.target as Node)) onDismiss(); + }} + onresize={onDismiss} +/> + +<div + bind:this={menuEl} + bind:clientWidth={width} + bind:clientHeight={height} + class="fixed z-50 flex w-64 max-w-[calc(100vw-1rem)] flex-col rounded-lg border border-base-content/15 bg-base-100 p-1 text-sm shadow-xl outline-none" + style:left="{left}px" + style:top="{top}px" + role="menu" + tabindex="-1" + aria-label={`Tab menu for ${tab.title}`} + oncontextmenu={(e) => e.preventDefault()} +> + <div class="flex min-w-0 items-center gap-1.5 px-2 py-1 text-xs text-base-content/60"> + <span class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none"> + {handle} + </span> + <span class="truncate">{tab.title}</span> + </div> + + {#if groupParent !== null} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onRemoveFromGroup)} + > + Remove from group + <span class="block truncate text-xs text-base-content/50"> + Currently under {handleFor(groupParent.conversationId)} · {groupParent.title} + </span> + </button> + {/if} + + {#if childCount > 0} + <button + type="button" + role="menuitem" + class="rounded px-2 py-1.5 text-left hover:bg-base-300" + onclick={() => act(onUngroupChildren)} + > + Ungroup {childCount} + {childCount === 1 ? "subagent" : "subagents"} + </button> + {/if} + + <div class="mt-1 border-t border-base-content/10 px-2 pt-1.5 pb-1 text-xs text-base-content/60"> + Add to group under… + </div> + {#if candidates.length === 0} + <div class="px-2 py-1.5 text-xs text-base-content/40">No other tabs</div> + {:else} + <div class="flex max-h-56 flex-col overflow-y-auto"> + {#each candidates as candidate (candidate.conversationId)} + <button + type="button" + role="menuitem" + class="flex min-w-0 items-center gap-1.5 rounded px-2 py-1.5 text-left hover:bg-base-300" + data-group-candidate={candidate.conversationId} + onclick={() => act(() => onAddToGroup(candidate.conversationId))} + > + <span + class="shrink-0 rounded bg-base-300 px-1 py-0.5 font-mono text-[10px] leading-none text-base-content/60" + > + {handleFor(candidate.conversationId)} + </span> + <span class="truncate">{candidate.title}</span> + </button> + {/each} + </div> + {/if} +</div> diff --git a/src/features/tabs/ui/tab-flip.ts b/src/features/tabs/ui/tab-flip.ts new file mode 100644 index 0000000..ac9daaf --- /dev/null +++ b/src/features/tabs/ui/tab-flip.ts @@ -0,0 +1,374 @@ +import { gsap } from "gsap"; +import { isGroupFlipId, leavingFlipIds } from "../tab-flip"; + +/** + * FLIP animation for the tab grid — the DOM EFFECT half (the pure id scheme + * lives in `../tab-flip.ts`). + * + * Driven by the owning component in two calls: + * - `capture(nextIds)` — BEFORE the DOM updates: measure every + * `[data-flip-id]` element (First), and build an inert GHOST for each + * element the next render drops (a closed tab / dissolved group box). + * - `play()` — AFTER the DOM updates: measure again (Last), then run three + * STAGGERED phases, each only when it has something to do — + * 1. SHRINK — ghosts collapse to 0 height (everything else holds still); + * 2. MOVE — cards slide from their old box to their new one, and each + * group box's tint morphs from its old rect to its new one; + * 3. GROW — new cards (and new group boxes' tints) grow from 0 height. + * + * PERFORMANCE: this is a hand-rolled FLIP, not GSAP's Flip plugin. Flip + * recorded computed styles per element and re-measured while building its + * timeline — ~430ms of main-thread work per close with ~40 nested tabs, which + * both delayed the click and swallowed the shrink phase. Here each change + * costs exactly two batched layout reads (one in `capture`, one in `play`); + * every write happens after its batch of reads. Each phase is ONE GSAP tween + * on a plain progress value whose `onUpdate` writes inline styles directly — + * GSAP never reads an element's computed style (per-element CSS tweens did, + * forcing a style recalc per moved card). Cards only TRANSLATE (plus a + * width tween when a card turns into / out of a double-width group parent); + * a group box never moves itself — only its absolutely-positioned, empty tint + * layer does (inset tween), so no card is ever double-translated. + * + * Like the sidebar panels (`layout/ui/panel-motion.ts`), shrink/grow change + * only a HEIGHT, clipped — contents keep their natural size. Grow is a + * `clip-path` reveal so the settled grid never shifts under it. + * + * Data-driven, not click-driven: it animates ANY change to the tab set (a + * summoned subagent, a close from another device, a regroup). Re-entrant — + * `capture` measures the in-flight VISUAL boxes, `play` clears in-flight + * motion before measuring the new layout. Disabled when the user prefers + * reduced motion (or the environment can't tell, e.g. jsdom). + */ +export interface TabFlip { + capture(nextIds: ReadonlySet<string>): void; + play(): void; +} + +const SHRINK = 0.1; +const MOVE = 0.1; +const GROW = 0.1; +/** Close: starts slow, finishes fast (accelerates away). */ +const SHRINK_EASE = "power2.in"; +/** Move: slow → fast → slow. */ +const MOVE_EASE = "power2.inOut"; +/** Open: starts fast, settles slowly (the mirror of close). */ +const GROW_EASE = "power2.out"; +const GHOST_ATTR = "data-tab-ghost"; +/** Fully clipped from the bottom edge up (0 visible height). */ +const CLIP_HIDDEN = "inset(0% 0% 100% 0%)"; +/** Every inline style this module may leave on a live element mid-flight. */ +const MOTION_PROPS = ["transform", "width", "clip-path", "top", "right", "bottom", "left"]; + +interface Captured { + /** Visual box (incl. any in-flight motion) per flip id. Cards: the card; boxes: the tint. */ + readonly rects: ReadonlyMap<string, DOMRect>; + /** Built but not yet started — their shrink starts in `play`. */ + readonly ghosts: readonly HTMLElement[]; +} + +export function createTabFlip(getScroller: () => HTMLElement | undefined): TabFlip { + let captured: Captured | null = null; + /** Live elements carrying motion styles, so `play` can reset exactly those. */ + const animating = new Set<HTMLElement>(); + /** The running MOVE + GROW phase tweens (ghost shrinks run independently). */ + let phases: gsap.core.Tween[] = []; + + return { + capture(nextIds) { + captured = null; + const scroller = getScroller(); + if (scroller === undefined || !motionAllowed()) return; + const els = flipElements(scroller); + if (els.length === 0) return; + + // READS (one layout). + const rects = new Map<string, DOMRect>(); + for (const el of els) rects.set(idOf(el), measureTarget(el).getBoundingClientRect()); + const leaving = leavingFlipIds([...rects.keys()], nextIds); + const byId = new Map(els.map((el) => [idOf(el), el])); + const plans = leaving + .map((id) => byId.get(id)) + .filter((el): el is HTMLElement => el !== undefined) + .map((el) => planGhost(scroller, el, rects.get(idOf(el)))) + .filter((p): p is GhostPlan => p !== null); + + // WRITES. + captured = { rects, ghosts: plans.map(insertGhost) }; + }, + + play() { + const prev = captured; + captured = null; + const scroller = getScroller(); + if (prev === null || scroller === undefined) return; + + // Reset in-flight motion on live elements so the read below is the + // settled layout (their previous VISUAL boxes are already in `prev`). + for (const tween of phases) tween.kill(); + phases = []; + for (const el of animating) clearMotion(el); + animating.clear(); + + // READS (one layout). + const next = flipElements(scroller).map((el) => { + const target = measureTarget(el); + return { target, id: idOf(el), rect: target.getBoundingClientRect() }; + }); + + // Plan (pure arithmetic — no DOM access). + const entering: HTMLElement[] = []; + const movers: Mover[] = []; + for (const { target, id, rect } of next) { + const before = prev.rects.get(id); + if (before === undefined) entering.push(target); + else if (!sameBox(before, rect)) + movers.push(planMove(target, isGroupFlipId(id), before, rect)); + } + const moveAt = prev.ghosts.length > 0 ? SHRINK : 0; + const growAt = moveAt + (movers.length > 0 ? MOVE : 0); + + // WRITES — first frame of every phase NOW (movers hold at their old + // boxes, new elements are fully clipped) so nothing flashes in between. + for (const m of movers) m.render(0); + for (const el of entering) el.style.clipPath = CLIP_HIDDEN; + + // 1. SHRINK — one tween drives every ghost. + if (prev.ghosts.length > 0) { + const ghosts = prev.ghosts.map((g) => ({ g, h: Number.parseFloat(g.style.height) })); + gsap.to( + { p: 0 }, + { + p: 1, + duration: SHRINK, + ease: SHRINK_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + for (const { g, h } of ghosts) g.style.height = `${h * (1 - p)}px`; + }, + onComplete: () => { + for (const { g } of ghosts) g.remove(); + }, + }, + ); + } + + // 2. MOVE — one tween drives every card slide + tint morph. + if (movers.length > 0) { + for (const m of movers) animating.add(m.el); + phases.push( + gsap.to( + { p: 0 }, + { + p: 1, + duration: MOVE, + delay: moveAt, + ease: MOVE_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + for (const m of movers) m.render(p); + }, + onComplete: () => { + for (const m of movers) { + clearMotion(m.el); + animating.delete(m.el); + } + }, + }, + ), + ); + } + + // 3. GROW — one tween reveals every new card / tint from its top edge. + if (entering.length > 0) { + for (const el of entering) animating.add(el); + phases.push( + gsap.to( + { p: 0 }, + { + p: 1, + duration: GROW, + delay: growAt, + ease: GROW_EASE, + onUpdate(this: gsap.core.Tween) { + const p = this.targets<{ p: number }>()[0]?.p ?? 1; + const clip = `inset(0% 0% ${(1 - p) * 100}% 0%)`; + for (const el of entering) el.style.clipPath = clip; + }, + onComplete: () => { + for (const el of entering) { + clearMotion(el); + animating.delete(el); + } + }, + }, + ), + ); + } + }, + }; +} + +interface Mover { + readonly el: HTMLElement; + /** Write the element's styles at move progress `p` (0 = old box, 1 = home). */ + render(p: number): void; +} + +/** + * A card slides (and, when it turned into / out of a double-width group + * parent, resizes) from its old box; a group tint morphs its insets from its + * old rect. Both end at their natural layout (`p = 1` ⇒ zero offsets). + */ +function planMove(el: HTMLElement, isTint: boolean, from: DOMRect, to: DOMRect): Mover { + if (isTint) { + const top = from.top - to.top; + const left = from.left - to.left; + const right = to.right - from.right; + const bottom = to.bottom - from.bottom; + return { + el, + render(p) { + const k = 1 - p; + el.style.top = `${top * k}px`; + el.style.left = `${left * k}px`; + el.style.right = `${right * k}px`; + el.style.bottom = `${bottom * k}px`; + }, + }; + } + const dx = from.left - to.left; + const dy = from.top - to.top; + const resized = Math.abs(from.width - to.width) >= 0.5; + return { + el, + render(p) { + const k = 1 - p; + el.style.transform = `translate(${dx * k}px, ${dy * k}px)`; + if (resized) el.style.width = `${to.width + (from.width - to.width) * k}px`; + }, + }; +} + +function clearMotion(el: HTMLElement): void { + for (const prop of MOTION_PROPS) el.style.removeProperty(prop); +} + +function motionAllowed(): boolean { + return ( + typeof window.matchMedia === "function" && + !window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +function flipElements(scroller: HTMLElement): HTMLElement[] { + return [...scroller.querySelectorAll<HTMLElement>("[data-flip-id]")]; +} + +function idOf(el: HTMLElement): string { + return el.dataset.flipId ?? ""; +} + +/** What VISUALLY represents a flip element: a card itself, a group box's tint. */ +function measureTarget(el: HTMLElement): HTMLElement { + if (!isGroupFlipId(idOf(el))) return el; + return el.querySelector<HTMLElement>(":scope > [data-group-backdrop]") ?? el; +} + +/** Whether two rects are the same box (sub-pixel tolerance). */ +function sameBox(a: DOMRect, b: DOMRect): boolean { + const close = (x: number, y: number) => Math.abs(x - y) < 0.5; + return ( + close(a.left, b.left) && + close(a.top, b.top) && + close(a.width, b.width) && + close(a.height, b.height) + ); +} + +interface GhostPlan { + readonly source: HTMLElement; + readonly host: HTMLElement; + readonly isGroup: boolean; + readonly left: number; + readonly top: number; + readonly width: number; + readonly height: number; +} + +/** + * READ half of a ghost: where an inert copy of a leaving element must sit. + * + * Paint order matters (cards are `relative`, so everything here stacks in DOM + * order): a leaving CARD's ghost goes on the scroller, ON TOP of the grid. A + * leaving GROUP BOX copies only its tint and goes FIRST in the box's + * enclosing grid: above the enclosing box's own tint, but BELOW every card. + */ +function planGhost( + scroller: HTMLElement, + el: HTMLElement, + rect: DOMRect | undefined, +): GhostPlan | null { + const isGroup = isGroupFlipId(idOf(el)); + const source = measureTarget(el); + const host = isGroup ? el.parentElement : scroller; + if (host === null || rect === undefined || (isGroup && source === el)) return null; + const h = host.getBoundingClientRect(); + return { + source, + host, + isGroup, + left: rect.left - h.left - host.clientLeft + host.scrollLeft, + top: rect.top - h.top - host.clientTop + host.scrollTop, + width: rect.width, + height: rect.height, + }; +} + +/** + * WRITE half of a ghost: a clipping frame (whose height later shrinks to 0) + * around a full-size copy — only the frame animates, the copy never resizes. + */ +function insertGhost(plan: GhostPlan): HTMLElement { + const copy = plan.source.cloneNode(!plan.isGroup) as HTMLElement; + // A ghost is never a tab/group to queries, tests or assistive tech. + for (const node of [copy, ...copy.querySelectorAll<HTMLElement>("*")]) { + for (const attr of [ + "data-flip-id", + "data-conversation-id", + "data-group-backdrop", + "role", + "id", + ]) { + node.removeAttribute(attr); + } + } + Object.assign(copy.style, { + position: "absolute", + inset: "auto", + left: "0", + top: "0", + width: `${plan.width}px`, + height: `${plan.height}px`, + margin: "0", + transform: "none", + clipPath: "none", + }); + const ghost = document.createElement("div"); + ghost.setAttribute(GHOST_ATTR, ""); + ghost.setAttribute("aria-hidden", "true"); + ghost.inert = true; + Object.assign(ghost.style, { + position: "absolute", + left: `${plan.left}px`, + top: `${plan.top}px`, + width: `${plan.width}px`, + height: `${plan.height}px`, + overflow: "hidden", + pointerEvents: "none", + zIndex: plan.isGroup ? "auto" : "2", + }); + ghost.appendChild(copy); + if (plan.isGroup) plan.host.prepend(ghost); + else plan.host.appendChild(ghost); + return ghost; +} diff --git a/src/features/workspaces/adapter/http.test.ts b/src/features/workspaces/adapter/http.test.ts index 18d8939..d6409ca 100644 --- a/src/features/workspaces/adapter/http.test.ts +++ b/src/features/workspaces/adapter/http.test.ts @@ -79,15 +79,37 @@ describe("createWorkspaceHttp", () => { expect(result).toEqual({ ok: false, error: "invalid slug" }); }); - it("get returns null on 404", async () => { + it("get reports a missing lookup on 404", async () => { const http = createWorkspaceHttp(BASE, fakeFetch([{ status: 404 }])); - expect(await http.get("nope")).toBeNull(); + expect(await http.get("nope")).toEqual({ kind: "missing" }); }); it("get returns the workspace on 200", async () => { - const ws = { id: "x", title: "X", defaultCwd: "/home", createdAt: 1, lastActivityAt: 2 }; + const ws = { + id: "x", + title: "X", + defaultCwd: "/home", + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; const http = createWorkspaceHttp(BASE, fakeFetch([{ body: ws }])); - expect(await http.get("x")).toEqual(ws); + expect(await http.get("x")).toEqual({ kind: "found", workspace: ws }); + }); + + it("get distinguishes a failed response from 404 (not 'missing')", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 500, body: { error: "db down" } }]), + ); + expect(await http.get("x")).toEqual({ kind: "failed", error: "db down" }); + }); + + it("get reports a network error as failed (not 'missing')", async () => { + const http = createWorkspaceHttp(BASE, fakeFetch([new Error("network")])); + expect(await http.get("x")).toEqual({ kind: "failed", error: "network" }); }); it("setTitle PUTs the title", async () => { @@ -186,4 +208,67 @@ describe("createWorkspaceHttp", () => { const result = await http.unstar("a"); expect(result).toEqual({ ok: false, error: "Failed to unstar workspace" }); }); + + it("list passes the archived filter as a query param", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list("true"); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces?archived=true`); + }); + + it("list omits the query param when no filter is given", async () => { + const fetchImpl = fakeFetch([{ body: { workspaces: [] } }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + await http.list(); + expect(fetchImpl).toHaveBeenCalledWith(`${BASE}/workspaces`); + }); + + it("archive PUTs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: true, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.archive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "PUT" }); + }); + + it("unarchive DELETEs /archive with no body and returns the updated workspace", async () => { + const ws = { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + }; + const fetchImpl = fakeFetch([{ body: ws }]); + const http = createWorkspaceHttp(BASE, fetchImpl); + const result = await http.unarchive("a"); + expect(result).toEqual({ ok: true, value: ws }); + const call = (fetchImpl as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call?.[0]).toBe(`${BASE}/workspaces/a/archive`); + expect(call?.[1]).toEqual({ method: "DELETE" }); + }); + + it("archive surfaces the backend error on a 409 (default workspace)", async () => { + const http = createWorkspaceHttp( + BASE, + fakeFetch([{ status: 409, body: { error: 'The "default" workspace cannot be archived' } }]), + ); + const result = await http.archive("default"); + expect(result).toEqual({ ok: false, error: 'The "default" workspace cannot be archived' }); + }); }); diff --git a/src/features/workspaces/adapter/http.ts b/src/features/workspaces/adapter/http.ts index 5673881..3f7f058 100644 --- a/src/features/workspaces/adapter/http.ts +++ b/src/features/workspaces/adapter/http.ts @@ -9,6 +9,7 @@ import type { WorkspaceListResponse, WorkspaceResponse, } from "@dispatch/transport-contract"; +import type { WorkspaceLookup } from "../logic/create"; /** * Workspace HTTP effects — the injected edge that talks to the backend's @@ -19,24 +20,34 @@ import type { * safe empty/null on failure (non-fatal — the UI falls back gracefully). * * Endpoints ([email protected]): - * - `GET /workspaces` → list + * - `GET /workspaces` (optional `?archived=true|false|all`, default `false` = active only) → list * - `PUT /workspaces/:id` (create-on-miss, idempotent) → ensure - * - `GET /workspaces/:id` (404 → null) → get + * - `GET /workspaces/:id` (found | 404-missing | failed) → get * - `PUT /workspaces/:id/title` → rename * - `PUT /workspaces/:id/default-cwd` → set/clear default cwd * - `PUT /workspaces/:id/default-computer` → set/clear default computer (SSH handoff #2) * - `PUT /workspaces/:id/star` (create-on-miss) → star (concurrency priority) * - `DELETE /workspaces/:id/star` (create-on-miss) → unstar + * - `PUT /workspaces/:id/archive` (create-on-miss; 409 for "default") → archive (hide from home grid) + * - `DELETE /workspaces/:id/archive` (create-on-miss; 409 for "default") → unarchive * - `DELETE /workspaces/:id` (409 for "default") → delete */ export type WorkspaceResult<T> = | { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: string }; +/** Which workspaces `GET /workspaces` returns: active-only (default), archived-only, or all. */ +export type WorkspaceArchivedFilter = "true" | "false" | "all"; + export interface WorkspaceHttp { - list(): Promise<readonly WorkspaceEntry[]>; + list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]>; ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; - get(id: string): Promise<Workspace | null>; + /** + * Authoritative existence probe. 404 → `missing`, a network/server failure → + * `failed` (distinct on purpose: the create gate must not treat a broken + * check as "doesn't exist"), 200 → the workspace (`found`). + */ + get(id: string): Promise<WorkspaceLookup>; setTitle(id: string, title: string): Promise<WorkspaceResult<Workspace>>; setDefaultCwd(id: string, defaultCwd: string | null): Promise<WorkspaceResult<Workspace>>; setDefaultComputer(id: string, computerId: string | null): Promise<WorkspaceResult<Workspace>>; @@ -44,6 +55,10 @@ export interface WorkspaceHttp { star(id: string): Promise<WorkspaceResult<Workspace>>; /** Unstar a workspace. Create-on-miss; no body. */ unstar(id: string): Promise<WorkspaceResult<Workspace>>; + /** Archive a workspace (hide from the home grid; data untouched). Create-on-miss; no body. */ + archive(id: string): Promise<WorkspaceResult<Workspace>>; + /** Unarchive a workspace (restore to the home grid). Create-on-miss; no body. */ + unarchive(id: string): Promise<WorkspaceResult<Workspace>>; delete(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } @@ -58,9 +73,10 @@ async function errText(res: Response): Promise<string> { export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): WorkspaceHttp { return { - async list(): Promise<readonly WorkspaceEntry[]> { + async list(archived?: WorkspaceArchivedFilter): Promise<readonly WorkspaceEntry[]> { try { - const res = await fetchImpl(`${httpBase}/workspaces`); + const qs = archived !== undefined ? `?archived=${archived}` : ""; + const res = await fetchImpl(`${httpBase}/workspaces${qs}`); if (!res.ok) return []; const data = (await res.json()) as WorkspaceListResponse; return data.workspaces; @@ -86,13 +102,17 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, - async get(id): Promise<Workspace | null> { + async get(id): Promise<WorkspaceLookup> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`); - if (res.status === 404 || !res.ok) return null; - return (await res.json()) as WorkspaceResponse; - } catch { - return null; + if (res.status === 404) return { kind: "missing" }; + if (!res.ok) return { kind: "failed", error: await errText(res) }; + return { kind: "found", workspace: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { + kind: "failed", + error: err instanceof Error ? err.message : "Workspace lookup failed", + }; } }, @@ -171,6 +191,30 @@ export function createWorkspaceHttp(httpBase: string, fetchImpl: typeof fetch): } }, + async archive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "PUT", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Archive failed" }; + } + }, + + async unarchive(id): Promise<WorkspaceResult<Workspace>> { + try { + const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}/archive`, { + method: "DELETE", + }); + if (!res.ok) return { ok: false, error: await errText(res) }; + return { ok: true, value: (await res.json()) as WorkspaceResponse }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : "Unarchive failed" }; + } + }, + async delete(id): Promise<WorkspaceResult<{ closedCount: number }>> { try { const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(id)}`, { diff --git a/src/features/workspaces/adapter/tile-layout-http.ts b/src/features/workspaces/adapter/tile-layout-http.ts new file mode 100644 index 0000000..58af9d8 --- /dev/null +++ b/src/features/workspaces/adapter/tile-layout-http.ts @@ -0,0 +1,117 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; + +/** + * Home tile-layout HTTP effects — the injected edge that talks to the + * `home-tile-layout` plugin's well-known routes: + * - `GET /home/tile-layout/:mode` → `{ layout }` (a fresh orientation reads + * as the seeded empty document) + * - `PUT /home/tile-layout/:mode` body `{ layout }` → 200 `{ layout }` / + * 400 `{ error }` + * - `DELETE /home/tile-layout/:mode` → the fresh empty document (reset) + * + * Mirrors the layout adapter's pattern: `httpBase` + an injected `fetchImpl` + * (testable without the network). A 404 is NOT a "no document" signal (the + * backend never 404s a fresh orientation — it seeds): any non-2xx / network + * failure means "the routes aren't served" (an old backend) or "backend + * unreachable" — both degrade the store to its local state with ephemeral + * edits, never an error UI. + */ + +/** Outcome of a tile-layout read/write. */ +export type TileLayoutHttpResult = + | { readonly ok: true; readonly layout: TileLayoutData } + | { readonly ok: false; readonly notFound: boolean; readonly error: string }; + +export interface TileLayoutHttp { + getLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; + putLayout(mode: TileGeometryMode, layout: TileLayoutData): Promise<TileLayoutHttpResult>; + deleteLayout(mode: TileGeometryMode): Promise<TileLayoutHttpResult>; +} + +/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */ +async function errorOf(res: Response): Promise<string> { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error.length > 0) return body.error; + } catch { + // fall through + } + return `HTTP ${res.status}`; +} + +/** A malformed 200 (e.g. an SPA-fallback page) degrades like a failure — never poisons the store. */ +function malformed(): TileLayoutHttpResult { + return { ok: false, notFound: false, error: "Malformed tile-layout response" }; +} + +function route(httpBase: string, mode: TileGeometryMode): string { + return `${httpBase}/home/tile-layout/${encodeURIComponent(mode)}`; +} + +export function createTileLayoutHttp(httpBase: string, fetchImpl: typeof fetch): TileLayoutHttp { + return { + async getLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode)); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async putLayout(mode, layout): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ layout }), + }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + + async deleteLayout(mode): Promise<TileLayoutHttpResult> { + try { + const res = await fetchImpl(route(httpBase, mode), { method: "DELETE" }); + if (res.status === 404) { + return { ok: false, notFound: true, error: "Tile-layout routes are not served" }; + } + if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) }; + const body = (await res.json()) as { layout?: unknown }; + if (body === null || typeof body !== "object" || body.layout === undefined) + return malformed(); + return { ok: true, layout: body.layout as TileLayoutData }; + } catch (err) { + return { + ok: false, + notFound: false, + error: err instanceof Error ? err.message : "Tile-layout request failed", + }; + } + }, + }; +} diff --git a/src/features/workspaces/index.ts b/src/features/workspaces/index.ts index dab1dec..c7f57d5 100644 --- a/src/features/workspaces/index.ts +++ b/src/features/workspaces/index.ts @@ -1,5 +1,18 @@ export type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; export { createWorkspaceHttp } from "./adapter/http"; +export { createTileLayoutHttp, type TileLayoutHttp } from "./adapter/tile-layout-http"; +export type { CreateGate, WorkspaceLookup } from "./logic/create"; +export { + archivedCreateMessage, + checkFailedCreateMessage, + gateCreate, +} from "./logic/create"; +export type { + DragEmit, + DragEvent as DragControllerEvent, + DragPhase, +} from "./logic/drag-controller"; +export { nextDragPhase } from "./logic/drag-controller"; export type { Route } from "./logic/route"; export { DEFAULT_WORKSPACE_ID, @@ -8,10 +21,36 @@ export { WORKSPACE_SLUG_RE, workspacePath, } from "./logic/route"; +export type { + GridGeometry, + Placement, + TileGeometryMode, + TileLayoutData, + TileLayoutEntry, +} from "./logic/tile-layout"; +export { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + geometryFor, + mergeLayout, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + sortTiles, + toRenderCell, + toUniversalCell, +} from "./logic/tile-layout"; export { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./logic/view-model"; export type { WorkspaceStore } from "./store.svelte"; export { createWorkspaceStore } from "./store.svelte"; -export { default as WorkspaceCard } from "./ui/WorkspaceCard.svelte"; +export type { TileLayoutProviderState, TileLayoutStore } from "./tile-layout-store.svelte"; +export { createTileLayoutStore } from "./tile-layout-store.svelte"; export { default as WorkspacesHome } from "./ui/WorkspacesHome.svelte"; /** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ diff --git a/src/features/workspaces/logic/create.test.ts b/src/features/workspaces/logic/create.test.ts new file mode 100644 index 0000000..278bcd6 --- /dev/null +++ b/src/features/workspaces/logic/create.test.ts @@ -0,0 +1,50 @@ +import type { Workspace } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { + archivedCreateMessage, + checkFailedCreateMessage, + gateCreate, + type WorkspaceLookup, +} from "./create"; + +function workspace(overrides: Partial<Workspace> = {}): Workspace { + return { + id: "old", + title: "Old", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + ...overrides, + }; +} + +describe("gateCreate", () => { + it("blocks a slug whose workspace is archived, with the can't-create message", () => { + const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: true }) }; + const gate = gateCreate("old", lookup); + expect(gate).toEqual({ ok: false, error: archivedCreateMessage("old") }); + expect(gate.ok === false && gate.error).toContain( + 'A workspace named "old" already exists and is archived — you can\'t create it.', + ); + }); + + it("proceeds for an existing ACTIVE workspace (ensure is idempotent)", () => { + const lookup: WorkspaceLookup = { kind: "found", workspace: workspace({ archived: false }) }; + expect(gateCreate("old", lookup)).toEqual({ ok: true }); + }); + + it("proceeds for a missing slug", () => { + expect(gateCreate("fresh", { kind: "missing" })).toEqual({ ok: true }); + }); + + it("blocks when the existence probe failed (never guesses)", () => { + const gate = gateCreate("maybe", { kind: "failed", error: "db down" }); + expect(gate).toEqual({ ok: false, error: checkFailedCreateMessage("maybe", "db down") }); + expect(gate.ok === false && gate.error).toContain( + 'Couldn\'t check whether a workspace named "maybe"', + ); + }); +}); diff --git a/src/features/workspaces/logic/create.ts b/src/features/workspaces/logic/create.ts new file mode 100644 index 0000000..c9385e1 --- /dev/null +++ b/src/features/workspaces/logic/create.ts @@ -0,0 +1,64 @@ +/** + * Pure create-gate logic for the New-workspace form — zero DOM, zero effects. + * + * `PUT /workspaces/:id` (ensure) is idempotent, so creating a slug that belongs + * to an ARCHIVED workspace would silently "succeed": the backend returns the + * archived workspace, the active list (which excludes archived) never shows it, + * and the home grid would still place a tile for something invisible. The + * frontend therefore probes `GET /workspaces/:id` first and refuses the create + * when the slug exists and is archived. + */ + +import type { Workspace } from "@dispatch/wire"; + +/** + * The authoritative existence probe's outcome (`GET /workspaces/:id`). The + * adapter maps HTTP status to these kinds; `failed` is deliberately distinct + * from `missing` (404) so a broken check is never mistaken for "no such + * workspace". + */ +export type WorkspaceLookup = + | { readonly kind: "found"; readonly workspace: Workspace } + | { readonly kind: "missing" } + | { readonly kind: "failed"; readonly error: string }; + +/** Whether a create may proceed: `ok` or a user-facing error message. */ +export type CreateGate = { readonly ok: true } | { readonly ok: false; readonly error: string }; + +/** + * The user-facing message when a slug belongs to an archived workspace. It + * states plainly that the create can't happen and points at the existing + * recovery affordance (the Archived workspaces list) without adding UI. + */ +export function archivedCreateMessage(id: string): string { + return `A workspace named "${id}" already exists and is archived — you can't create it. Unarchive it from Archived workspaces instead.`; +} + +/** + * The user-facing message when the existence probe itself failed. Proceeding + * blindly could recreate the silent archived-tile bug, so the create is + * refused with a retryable message instead. + */ +export function checkFailedCreateMessage(id: string, error: string): string { + return `Couldn't check whether a workspace named "${id}" already exists (${error}). Try again.`; +} + +/** + * Decide whether a create may proceed, given the probe outcome. Pure: + * `(id, lookup) → gate`. + * + * - archived slug → blocked with the "can't create" message; + * - missing slug or an ACTIVE existing slug → proceed (`ensure` is idempotent, + * so an active slug keeps today's behavior); + * - a failed probe → blocked too (plan §3.4): never guess, because proceeding + * could ensure an archived workspace's tile. + */ +export function gateCreate(id: string, lookup: WorkspaceLookup): CreateGate { + if (lookup.kind === "failed") { + return { ok: false, error: checkFailedCreateMessage(id, lookup.error) }; + } + if (lookup.kind === "found" && lookup.workspace.archived) { + return { ok: false, error: archivedCreateMessage(id) }; + } + return { ok: true }; +} diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts new file mode 100644 index 0000000..459063a --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller"; + +const OPTS = { dragThresholdPx: 6 }; + +function down(over: Partial<Parameters<typeof nextDragPhase>[1] & { type: "pointer-down" }> = {}) { + return { + type: "pointer-down" as const, + id: "t1", + x: 10, + y: 10, + touch: false, + ...over, + }; +} + +function press(touch = false): DragPhase { + return nextDragPhase({ kind: "idle" }, down({ touch }), OPTS).phase; +} + +describe("nextDragPhase — mouse", () => { + it("a pointer-down enters the undecided pressing state", () => { + expect(press()).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("movement past the threshold starts a drag", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 20, y: 12, targetCell: { col: 4, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 20, + y: 12, + targetCell: { col: 4, row: 1 }, + touch: false, + }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold stays pressing", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 12, y: 14, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("release without moving emits a tap (open the workspace)", () => { + const transition = nextDragPhase(press(), { type: "pointer-up" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toEqual({ kind: "tap", id: "t1" }); + }); + + it("release while dragging emits a drop with the last tracked index", () => { + let transition: DragTransition = nextDragPhase( + press(), + { type: "pointer-move", x: 30, y: 30, targetCell: { col: 3, row: 1 } }, + OPTS, + ); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 31, y: 31, targetCell: { col: 6, row: 1 } }, + OPTS, + ); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: { col: 6, row: 1 } }); + expect(transition.phase).toEqual({ kind: "idle" }); + }); +}); + +describe("nextDragPhase — touch", () => { + it("a long-press lifts the tile and emits lift (arm the drag + select)", () => { + const transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + expect(transition.emit).toEqual({ kind: "lift", id: "t1" }); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 10, + targetCell: null, + touch: true, + }); + }); + + it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 10, y: 80, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold before the lift keeps pressing", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 13, y: 12, targetCell: { col: 1, row: 1 } }, + OPTS, + ); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("after the lift, movement drags (never cancels)", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 10, y: 90, targetCell: { col: 5, row: 1 } }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 90, + targetCell: { col: 5, row: 1 }, + touch: true, + }); + }); + + it("a long-press never lifts a mouse press (desktop selects via right-click)", () => { + const transition = nextDragPhase(press(false), { type: "long-press" }, OPTS); + expect(transition.emit).toBeUndefined(); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("release while lifted (no movement) drops at the lifted tile's own slot", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetCell: null }); + }); +}); + +describe("nextDragPhase — cancellations", () => { + it("cancel returns to idle from any phase without emitting", () => { + for (const phase of [ + { kind: "idle" as const }, + press(), + nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase, + ]) { + const transition = nextDragPhase(phase, { type: "cancel" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + } + }); + + it("events in idle are inert", () => { + expect(nextDragPhase({ kind: "idle" }, { type: "pointer-up" }, OPTS).phase).toEqual({ + kind: "idle", + }); + expect( + nextDragPhase( + { kind: "idle" }, + { type: "pointer-move", x: 1, y: 1, targetCell: { col: 1, row: 1 } }, + OPTS, + ).phase, + ).toEqual({ kind: "idle" }); + expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({ + kind: "idle", + }); + }); + + it("a second pointer-down restarts the gesture with the new tile", () => { + const dragging = nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetCell: { col: 2, row: 1 } }, + OPTS, + ).phase; + const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS); + expect(transition.phase).toEqual({ kind: "pressing", id: "t2", x: 10, y: 10, touch: false }); + }); +}); diff --git a/src/features/workspaces/logic/drag-controller.ts b/src/features/workspaces/logic/drag-controller.ts new file mode 100644 index 0000000..a1877aa --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.ts @@ -0,0 +1,162 @@ +/** + * Pure drag/press state machine for the home tile grid — zero DOM, zero + * timers (the component schedules the long-press timer and dispatches its + * firing as an event), zero Svelte. + * + * Pointer semantics: + * - **Mouse**: press → move past the threshold = DRAG; release without + * moving = a TAP (open the workspace). Selection is right-click. + * - **Touch**: press → (no movement past the threshold) long-press = LIFT + * (arm the drag + select the tile) → further moves drag. Moving past the + * threshold BEFORE the lift cancels the press — vertical scrolling wins, + * so the two gestures never fight. Release while lifted = DROP. + * + * Pure: `(phase, event, options) → { phase, emit }`. The component owns the + * reactive `$state` and performs the emits (navigate / select / place). + */ + +import type { Cell } from "./tile-layout"; + +/** Where the interaction currently stands. */ +export type DragPhase = + | { readonly kind: "idle" } + /** Pointer is down; not yet decided between tap / drag / long-press. */ + | { + readonly kind: "pressing"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + /** The tile is lifted and following the pointer (touch: after the long-press). */ + | { + readonly kind: "dragging"; + readonly id: string; + readonly x: number; + readonly y: number; + /** The grid CELL under the pointer (null = not over the canvas yet). */ + readonly targetCell: Cell | null; + readonly touch: boolean; + }; + +/** An action the component should perform as a result of a transition. */ +export type DragEmit = + | { readonly kind: "tap"; readonly id: string } + | { readonly kind: "lift"; readonly id: string } + | { readonly kind: "drop"; readonly id: string; readonly targetCell: Cell | null }; + +/** Events the component dispatches (DOM events + the long-press timer firing). */ +export type DragEvent = + | { + readonly type: "pointer-down"; + readonly id: string; + readonly x: number; + readonly y: number; + readonly touch: boolean; + } + | { + readonly type: "pointer-move"; + readonly x: number; + readonly y: number; + readonly targetCell: Cell | null; + } + | { readonly type: "pointer-up" } + | { readonly type: "long-press" } + /** pointercancel / blur / the tile unmounting / Escape. */ + | { readonly type: "cancel" }; + +export interface DragOptions { + /** Pointer travel (px) that turns a press into a drag (or cancels a touch press). */ + readonly dragThresholdPx: number; +} + +export interface DragTransition { + readonly phase: DragPhase; + readonly emit?: DragEmit; +} + +const IDLE: DragPhase = { kind: "idle" }; + +/** Advance the state machine. Pure: phase + event in, NEXT phase + emit out. */ +export function nextDragPhase( + phase: DragPhase, + event: DragEvent, + options: DragOptions, +): DragTransition { + switch (event.type) { + case "pointer-down": + // A press always restarts (a pointerdown while dragging is a new gesture). + return { + phase: { + kind: "pressing", + id: event.id, + x: event.x, + y: event.y, + touch: event.touch, + }, + }; + case "pointer-move": { + if (phase.kind === "pressing") { + const travelled = Math.hypot(event.x - phase.x, event.y - phase.y); + if (travelled < options.dragThresholdPx) return { phase }; + // Touch: a big movement before the long-press lift is a SCROLL, not a + // drag — abandon the press so the canvas scrolls natively. + if (phase.touch) return { phase: IDLE }; + return { + phase: { + kind: "dragging", + id: phase.id, + x: event.x, + y: event.y, + targetCell: event.targetCell, + touch: phase.touch, + }, + }; + } + if (phase.kind === "dragging") { + return { + phase: { + ...phase, + x: event.x, + y: event.y, + targetCell: event.targetCell, + }, + }; + } + return { phase }; + } + case "pointer-up": { + if (phase.kind === "pressing") { + // No drag, no lift → a tap. + return { phase: IDLE, emit: { kind: "tap", id: phase.id } }; + } + if (phase.kind === "dragging") { + return { + phase: IDLE, + emit: { kind: "drop", id: phase.id, targetCell: phase.targetCell }, + }; + } + return { phase }; + } + case "long-press": { + if (phase.kind === "pressing" && phase.touch) { + // Touch lift: arm the drag at the press point (the pointer will move + // from here) + tell the component to select the tile. + return { + phase: { + kind: "dragging", + id: phase.id, + x: phase.x, + y: phase.y, + targetCell: null, + touch: true, + }, + emit: { kind: "lift", id: phase.id }, + }; + } + return { phase }; + } + case "cancel": + return { phase: IDLE }; + } +} diff --git a/src/features/workspaces/logic/tile-layout.test.ts b/src/features/workspaces/logic/tile-layout.test.ts new file mode 100644 index 0000000..b538373 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.test.ts @@ -0,0 +1,857 @@ +import { describe, expect, it } from "vitest"; +import { + clampSpan, + displacePlace, + dragAppendLimit, + fillOrderKey, + firstEmptySlot, + type GridGeometry, + geometryFor, + mergeLayout, + type Placement, + pack, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutEntry, + toRenderCell, + toUniversalCell, + UNIVERSAL_ROWS, +} from "./tile-layout"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +describe("geometryFor", () => { + it("resolves landscape/desktop viewports to the WIDE geometry", () => { + const geometry = geometryFor(1920, 1080); + expect(geometry.mode).toBe("wide"); + expect(geometry.flowRows).toBe(4); + expect(geometry.flowCols).toBe(0); + expect(geometry.maxColSpan).toBe(4); + expect(geometry.maxRowSpan).toBe(2); + expect(geometry.unit.height).toBeGreaterThan(0); + expect(geometry.unit.width).toBeGreaterThan(0); + }); + + it("resolves portrait/mobile viewports to the TALL geometry", () => { + const geometry = geometryFor(390, 844); + expect(geometry.mode).toBe("tall"); + expect(geometry.flowCols).toBe(2); + expect(geometry.flowRows).toBe(0); + expect(geometry.maxColSpan).toBe(2); + expect(geometry.maxRowSpan).toBe(2); + }); + + it("picks the picker dimensions from the span caps (4×2 wide, 2×2 tall)", () => { + // The resize picker IS the span cap: cols × rows of small squares. + expect([WIDE.maxColSpan, WIDE.maxRowSpan]).toEqual([4, 2]); + expect([TALL.maxColSpan, TALL.maxRowSpan]).toEqual([2, 2]); + }); + + it("resolves degenerate (unmeasured) sizes to wide", () => { + expect(geometryFor(0, 0).mode).toBe("wide"); + }); + + it("derives the wide unit from the canvas height so the 4 rows fill it", () => { + // 4·unit + 3·gap + 2·margin ≈ height: an 800px canvas gets ~160px tiles, + // NOT the old fixed 120px (the "squares are so small" complaint). + const geometry = geometryFor(1440, 800); + const filled = 4 * geometry.unit.height + 3 * geometry.unit.gap + 2 * geometry.margin; + expect(geometry.unit.height).toBeGreaterThanOrEqual(140); + expect(Math.abs(filled - 800)).toBeLessThanOrEqual(8); + expect(geometry.unit.width).toBe(geometry.unit.height); + }); + + it("clamps the wide unit to its band on very short and very tall canvases", () => { + const short = geometryFor(1200, 300); + const huge = geometryFor(5000, 4000); // wide (width ≥ height) with a huge height + expect(short.unit.height).toBe(112); + expect(huge.unit.height).toBe(184); + // The margin never collapses (the "no margins" complaint) nor dominates. + expect(short.margin).toBeGreaterThanOrEqual(24); + expect(huge.margin).toBeLessThanOrEqual(64); + }); + + it("always carries a positive margin (the tile field never hugs the edge)", () => { + const sizes: [number, number][] = [ + [1920, 1080], + [390, 844], + [0, 0], + [1200, 300], + ]; + for (const [w, h] of sizes) { + expect(geometryFor(w, h).margin).toBeGreaterThan(0); + } + }); + + it("keeps the tall unit fixed with a gap (its columns are fluid)", () => { + expect(TALL.unit.width).toBe(0); + expect(TALL.unit.height).toBeGreaterThanOrEqual(120); + expect(TALL.unit.gap).toBeGreaterThan(8); + }); +}); + +describe("clampSpan", () => { + it("clamps spans to the geometry caps", () => { + expect(clampSpan({ colSpan: 4, rowSpan: 2 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 9, rowSpan: 9 }, WIDE)).toEqual({ colSpan: 4, rowSpan: 2 }); + expect(clampSpan({ colSpan: 3, rowSpan: 1 }, TALL)).toEqual({ colSpan: 2, rowSpan: 1 }); + }); + + it("coerces garbage spans to 1×1", () => { + expect(clampSpan({ colSpan: 0, rowSpan: -2 }, WIDE)).toEqual({ colSpan: 1, rowSpan: 1 }); + expect(clampSpan({ colSpan: Number.NaN, rowSpan: 1.9 }, WIDE)).toEqual({ + colSpan: 1, + rowSpan: 1, + }); + }); +}); + +describe("pack", () => { + it("places tiles in order at the first fitting slot (wide, column-wise flow)", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)], WIDE); + expect(placements).toEqual([ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("flows to the next column when the current one is full (wide)", () => { + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3), tile("e", 4)], + WIDE, + ); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [1, 2], + [1, 3], + [1, 4], + [2, 1], + ]); + }); + + it("respects the persisted order over ids", () => { + const placements = pack([tile("z", 0), tile("a", 1)], WIDE); + expect(placements.map((p) => p.id)).toEqual(["z", "a"]); + }); + + it("places a double-height tile at the first slot with room (wide)", () => { + const placements = pack([tile("a", 0, 1, 2), tile("b", 1), tile("c", 2)], WIDE); + expect(placements.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["a", 1, 1, 2], + ["b", 1, 3, 1], + ["c", 1, 4, 1], + ]); + }); + + it("a tile too tall for the remaining rows moves to the next column (wide)", () => { + // a,b,c fill rows 1-3; a 2-row tile does not fit the single remaining row → col 2. + const placements = pack( + [tile("a", 0), tile("b", 1), tile("c", 2), tile("tall", 3, 1, 2)], + WIDE, + ); + expect(placements[3]).toEqual({ id: "tall", col: 2, row: 1, colSpan: 1, rowSpan: 2 }); + }); + + it("flows row-wise across two fixed columns in tall mode", () => { + const placements = pack([tile("a", 0), tile("b", 1), tile("c", 2)], TALL); + expect(placements.map((p) => [p.col, p.row])).toEqual([ + [1, 1], + [2, 1], + [1, 2], + ]); + }); + + it("clamps spans per geometry when packing (persisted spans stay raw)", () => { + const tiles = [tile("wide", 0, 3, 1), tile("b", 1, 4, 2)]; + const placements = pack(tiles, TALL); + expect(placements).toEqual([ + { id: "wide", col: 1, row: 1, colSpan: 2, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 2, rowSpan: 2 }, + ]); + // The input is untouched. + expect(tiles[0]).toEqual({ id: "wide", order: 0, colSpan: 3, rowSpan: 1 }); + }); + + it("never overlaps tiles", () => { + const tiles = [ + tile("a", 0, 2, 2), + tile("b", 1, 2, 2), + tile("c", 2, 4, 1), + tile("d", 3), + tile("e", 4, 1, 2), + tile("f", 5), + ]; + const placements = pack(tiles, WIDE); + const seen = new Set<string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + const key = `${r}:${c}`; + expect(seen.has(key)).toBe(false); + seen.add(key); + } + } + } + }); +}); + +describe("fillOrderKey", () => { + it("ranks wide slots column-wise (top-to-bottom, then next column)", () => { + expect(fillOrderKey(1, 1, WIDE)).toBeLessThan(fillOrderKey(1, 2, WIDE)); + expect(fillOrderKey(1, 4, WIDE)).toBeLessThan(fillOrderKey(2, 1, WIDE)); + }); + + it("ranks tall slots row-wise (left-to-right, then next row)", () => { + expect(fillOrderKey(1, 1, TALL)).toBeLessThan(fillOrderKey(2, 1, TALL)); + expect(fillOrderKey(2, 1, TALL)).toBeLessThan(fillOrderKey(1, 2, TALL)); + }); +}); + +describe("firstEmptySlot", () => { + it("finds the first fitting empty slot in fill order", () => { + // Wide, rows 1-2 of col 1 occupied by a double-height tile → first empty is (col1,row3). + const tiles = [tile("a", 0, 1, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 3 }); + }); + + it("skips slots too small for the requested footprint", () => { + // (col1,row4) is the first empty 1×1 slot after three singles, but a + // 2-row tile needs rows 4-5 of a 4-row grid — impossible → next column. + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 4 }); + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 2 }, WIDE)).toEqual({ col: 2, row: 1 }); + }); + + it("returns the origin for an empty layout", () => { + expect(firstEmptySlot([], { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 1 }); + }); + + it("lands in a brand-new column/row beyond the occupied extent", () => { + // Two 4×2 tiles fill columns 1-4 across all 4 rows → the next slot is col 5. + const tiles = [tile("a", 0, 4, 2), tile("b", 1, 4, 2)]; + expect(firstEmptySlot(tiles, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 5, row: 1 }); + }); +}); + +describe("pinAtFirstEmpty", () => { + it("pins a new tile at the first empty slot (holes count)", () => { + // A double-height tile fills rows 1-2, a single fills row 3 → the first + // empty slot is (col1,row4); the new tile PINS exactly there. + const tiles = [tile("big", 0, 1, 2), tile("c", 1)]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + const placements = pack(next, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["big", 1, 1], + ["c", 1, 3], + ["new", 1, 4], + ]); + }); + + it("pins into a GAP between pinned tiles", () => { + // big is pinned out of the way; the flow left row 3 free for a 1×1. + const tiles = [ + { ...tile("big", 0, 1, 2), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + const next = pinAtFirstEmpty(tiles, "new", WIDE); + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 3 }); + }); + + it("re-pins an already-present tile instead of duplicating (refresh-race idempotency)", () => { + const tiles = [tile("a", 0), tile("x", 1), tile("b", 2, 1, 2)]; + const first = pinAtFirstEmpty(tiles, "x", WIDE); + const second = pinAtFirstEmpty(first, "x", WIDE); + expect(second).toEqual(first); + expect(second.filter((t) => t.id === "x")).toHaveLength(1); + }); + + it("appends (pins beyond the current extent) when the grid is full", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2), tile("d", 3)]; + const next = pinAtFirstEmpty(tiles, "e", WIDE); + expect(next.find((t) => t.id === "e")).toMatchObject({ col: 2, row: 1 }); + }); + + it("preserves the spans of a re-pinned existing tile", () => { + const tiles = [tile("x", 0, 2, 2), tile("a", 1), tile("b", 2), tile("c", 3)]; + const next = pinAtFirstEmpty(tiles, "x", WIDE); + expect(next.find((t) => t.id === "x")).toMatchObject({ colSpan: 2, rowSpan: 2 }); + }); + + it("saves the pin in the universal frame (tall)", () => { + const tiles = [{ ...tile("a", 0), col: 1, row: 1 }]; + const next = pinAtFirstEmpty(tiles, "new", TALL); + // a renders at tall (1,1); the first empty slot is tall (2,1) → universal + // (col 1, row 2). + expect(next.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 2 }); + }); +}); + +describe("resizeTile", () => { + it("sets the persisted spans of one tile", () => { + const next = resizeTile([tile("a", 0), tile("b", 1)], "b", 4, 2); + expect(next.find((t) => t.id === "b")).toMatchObject({ colSpan: 4, rowSpan: 2 }); + expect(next.find((t) => t.id === "a")).toMatchObject({ colSpan: 1, rowSpan: 1 }); + }); + + it("sanitizes garbage spans to 1 and preserves order", () => { + const next = resizeTile([tile("a", 5, 2, 2)], "a", Number.NaN, -3); + expect(next).toEqual([{ id: "a", order: 5, colSpan: 1, rowSpan: 1 }]); + }); + + it("ignores unknown ids", () => { + const tiles = [tile("a", 0)]; + expect(resizeTile(tiles, "nope", 2, 2)).toEqual(tiles); + }); + + it("does NOT clamp to the current geometry (render-time concern)", () => { + // A 4-wide desktop tile resized while wide stays 4-wide in the document; + // tall rendering clamps its VIEW. + const next = resizeTile([tile("a", 0)], "a", 4, 2); + expect(next[0]).toMatchObject({ colSpan: 4, rowSpan: 2 }); + }); +}); + +describe("displacePlace", () => { + /** Render placements as the column-1 stream (cells 1..n, `_` = empty, + * one character per tile — the id's first char). */ + const stream = (placements: Placement[], geometry: GridGeometry): string => { + const cells = new Map<number, string>(); + for (const p of placements) { + for (let r = p.row; r < p.row + p.rowSpan; r++) { + for (let c = p.col; c < p.col + p.colSpan; c++) { + cells.set(fillOrderKey(c, r, geometry), p.id.charAt(0)); + } + } + } + const maxKey = Math.max(0, ...cells.keys()); + const out: string[] = []; + for (let key = 0; key <= maxKey; key++) { + out.push(cells.get(key) ?? "_"); + } + return out.join(""); + }; + + it("USER EXAMPLE 1: X_X + Y placed in slot 1 → YXX (the gap absorbs the shift)", () => { + // X at cells 1,3 (wide: (1,1) and (1,3)); Y sits at cell 5 (dragged in + // from outside the shown range — its origin is beyond the gap). + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + // Y@1; X (was@1) pushed into the gap at 2; X2 (was@3) stays. The array + // keeps the INPUT render order (only coordinates change). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 2], + ["X2", 1, 3], + ["Y", 1, 1], + ]); + }); + + it("USER EXAMPLE 2: _XZ_ + Y placed in slot 2 → _YXZ (leading gap preserved)", () => { + // X@(1,2), Z@(1,3); Y dragged in from cell 5. + const placements: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 2 }, WIDE); + // Same coordinates, input render order: X, Z, then the dragged Y. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["X", 1, 3], + ["Z", 1, 4], + ["Y", 1, 2], + ]); + }); + + it("dragging a tile OUT opens a gap at its old anchor (nothing slides back)", () => { + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + ]; + // Drag a (cell 1) to cell 3 — b stays at 2; a lands at 3. + const next = displacePlace(placements, "a", { col: 1, row: 3 }, WIDE); + // The array keeps the input render order (a first) — only a's coords move. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 2], + ]); + }); + + it("internal move rightward over a full run pushes the tail forward", () => { + // a,b,c in cells 1-3; drag a to cell 2: b→3 pushes c→4. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 2], + ["b", 1, 3], + ["c", 1, 4], + ]); + }); + + it("an unknown dragged id changes nothing (a copy of the input)", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + const next = displacePlace(placements, "nope", { col: 1, row: 2 }, WIDE); + expect(next).toEqual(placements); + }); + + it("a spanned displaced tile keeps its spans and re-anchors forward as a rect", () => { + // big (2-tall) at rows 1-2; tail (2-tall) at rows 3-4. Drag big to (1,2): + // its own old body is free-ish but (1,2)+(1,3) collides with tail's + // (1,3)+(1,4) → tail re-anchors FORWARD: no 2-tall slot fits in column 1 + // anymore → column 2. + const placements: Placement[] = [ + { id: "big", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "tail", col: 1, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + const next = displacePlace(placements, "big", { col: 1, row: 2 }, WIDE); + expect(next.map((p) => [p.id, p.col, p.row, p.rowSpan])).toEqual([ + ["big", 1, 2, 2], + ["tail", 2, 1, 2], + ]); + }); + + it("displacing into the NEXT COLUMN when the column is full", () => { + // 4 tiles fill column 1; drag the first to the LAST cell of column 1. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "c", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "d", col: 1, row: 4, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 1, row: 4 }, WIDE); + // d is pushed past the column's end → column 2 row 1. The array keeps the + // INPUT render order (a,b,c,d) — only coordinates change. + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 4], + ["b", 1, 2], + ["c", 1, 3], + ["d", 2, 1], + ]); + }); + + it("works in TALL mode along the row-major stream", () => { + // Tall: 2 columns, row-major. a@(1,1), b@(2,1); drag a to cell (2,1). + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "a", { col: 2, row: 1 }, TALL); + // b pushed forward past the row's end → next row. Stream order: a(1) b(2). + expect(next.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 2, 1], + ["b", 1, 2], + ]); + }); + + it("the stream helper renders the documented examples (sanity)", () => { + const placements: Placement[] = [ + { id: "X", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "X2", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next = displacePlace(placements, "Y", { col: 1, row: 1 }, WIDE); + expect(stream(next, WIDE)).toBe("YXX"); + const placements2: Placement[] = [ + { id: "X", col: 1, row: 2, colSpan: 1, rowSpan: 1 }, + { id: "Z", col: 1, row: 3, colSpan: 1, rowSpan: 1 }, + { id: "Y", col: 2, row: 1, colSpan: 1, rowSpan: 1 }, + ]; + const next2 = displacePlace(placements2, "Y", { col: 1, row: 2 }, WIDE); + expect(stream(next2, WIDE)).toBe("_YXZ"); + }); +}); + +describe("placeAtCell", () => { + it("pins every tile at its displaced anchor in the universal frame", () => { + // Wide: a@(1,1), b@(1,2); drag a to (2,1) — a pins there, b KEEPS its + // cell (the gap at (1,1) opens and stays). + const tiles = [tile("a", 0), tile("b", 1)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, WIDE); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(next.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + // And the pinned layout packs exactly as placed (b first — stream order). + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 2], + ["a", 2, 1], + ]); + }); + + it("keeps persisted spans raw (never clamped) through the commit", () => { + // A 4-wide desktop tile dropped in TALL commits the tall cell but keeps + // its raw 4-wide span. + const tiles = [{ ...tile("big", 0, 4, 1) }, tile("b", 1)]; + const next = placeAtCell(tiles, "big", { col: 1, row: 3 }, TALL); + expect(next.find((t) => t.id === "big")).toMatchObject({ + colSpan: 4, + rowSpan: 1, + col: 1, + row: 5, + }); + }); + + it("renumbers orders densely in stream order", () => { + const tiles = [tile("a", 0), tile("b", 5), tile("c", 9)]; + const next = placeAtCell(tiles, "c", { col: 1, row: 1 }, WIDE); + expect(next.every((t, i) => t.order === i)).toBe(true); + }); + + it("maps tall commits to the universal frame", () => { + // Tall (col 2, row 1) → universal (col 1, row 2). + const tiles = [tile("a", 0)]; + const next = placeAtCell(tiles, "a", { col: 2, row: 1 }, TALL); + expect(next.find((t) => t.id === "a")).toMatchObject({ col: 1, row: 2 }); + }); + + it("an unknown id changes nothing (no-op)", () => { + const tiles = [tile("a", 0)]; + expect(placeAtCell(tiles, "nope", { col: 1, row: 1 }, WIDE)).toEqual(tiles); + }); +}); + +describe("dragAppendLimit", () => { + it("caps a wide drag at 3 columns past the right-most content edge (span-aware)", () => { + // b spans cols 3-4 → the right-most occupied column is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }, + { id: "b", col: 3, row: 1, colSpan: 2, rowSpan: 1 }, + ]; + expect(dragAppendLimit(placements, WIDE)).toBe(7); + }); + + it("caps a tall drag at 3 rows past the bottom-most content edge (span-aware)", () => { + // b spans rows 3-4 → the bottom-most occupied row is 4; +3 = 7. + const placements: Placement[] = [ + { id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 2 }, + { id: "b", col: 2, row: 3, colSpan: 1, rowSpan: 2 }, + ]; + expect(dragAppendLimit(placements, TALL)).toBe(7); + }); + + it("an empty grid still allows the first tiles within the first 3 columns", () => { + expect(dragAppendLimit([], WIDE)).toBe(3); + expect(dragAppendLimit([], TALL)).toBe(3); + }); + + it("honors a custom extra", () => { + const placements: Placement[] = [{ id: "a", col: 1, row: 1, colSpan: 1, rowSpan: 1 }]; + expect(dragAppendLimit(placements, WIDE, 5)).toBe(6); + }); +}); + +describe("sortByRecentActivity", () => { + it("re-applies the recent-activity order (descending)", () => { + const tiles = [tile("a", 0), tile("b", 1), tile("c", 2)]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ["c", 200], + ]), + ); + expect(next.map((t) => t.id)).toEqual(["b", "c", "a"]); + }); + + it("treats unknown ids as least-recent and renumbers", () => { + const tiles = [tile("a", 0), tile("b", 1)]; + // `a` has recent activity (50); `b` is unknown → least-recent → last. + const next = sortByRecentActivity(tiles, new Map([["a", 50]])); + expect(next.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ]); + }); + + it("CLEARs every pin (an explicit arrangement reset — gaps gone)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 2, row: 3 }, + ]; + const next = sortByRecentActivity( + tiles, + new Map([ + ["a", 100], + ["b", 300], + ]), + ); + expect(next.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + // And the now-unpinned layout packs compactly. + expect(pack(next, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["b", 1, 1], + ["a", 1, 2], + ]); + }); +}); + +describe("mergeLayout", () => { + it("starts workspaces unknown to the layout (appends after the max order)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0)] }, ["a", "b", "c"]); + expect(merged.map((t) => [t.id, t.order])).toEqual([ + ["a", 0], + ["b", 1], + ["c", 2], + ]); + }); + + it("appends unknowns in the live list's order", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("z", 0)] }, ["z", "y", "x"]); + expect(merged.map((t) => t.id)).toEqual(["z", "y", "x"]); + }); + + it("drops entries for deleted workspaces", () => { + const merged = mergeLayout( + { version: 1, tiles: [tile("a", 0), tile("gone", 1), tile("b", 2)] }, + ["a", "b"], + ); + expect(merged.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("keeps manual order (not the list order) for known workspaces", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("c", 0), tile("a", 1)] }, ["a", "c"]); + expect(merged.map((t) => t.id)).toEqual(["c", "a"]); + }); + + it("clamps spans in the view when a geometry is given (persisted data unchanged)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"], TALL); + expect(merged[0]).toMatchObject({ colSpan: 2, rowSpan: 1 }); + expect(mergeLayout({ version: 1, tiles: [tile("a", 0, 3, 1)] }, ["a"])).toEqual([ + { id: "a", order: 0, colSpan: 3, rowSpan: 1 }, + ]); + }); + + it("deduplicates corrupted duplicate ids (first by order wins)", () => { + const merged = mergeLayout({ version: 1, tiles: [tile("a", 1, 2, 2), tile("a", 0)] }, [ + "a", + "b", + ]); + expect(merged).toEqual([ + { id: "a", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "b", order: 1, colSpan: 1, rowSpan: 1 }, + ]); + }); + + it("handles a null/undefined persisted document", () => { + expect(mergeLayout(null, ["a"])).toEqual([{ id: "a", colSpan: 1, rowSpan: 1, order: 1 }]); + expect(mergeLayout(undefined, [])).toEqual([]); + }); +}); + +describe("sanitizeLayout", () => { + it("accepts a valid v2 document (anchors kept)", () => { + expect( + sanitizeLayout({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0, col: 3, row: 2 }], + }); + }); + + it("MIGRATES a v1 document to v2 in memory (all tiles unpinned)", () => { + expect( + sanitizeLayout({ version: 1, tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }] }), + ).toEqual({ + version: 2, + tiles: [{ id: "a", colSpan: 2, rowSpan: 1, order: 0 }], + }); + }); + + it("rejects foreign versions and malformed documents", () => { + expect(sanitizeLayout(null)).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout("nope")).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 3, tiles: [] })).toEqual({ version: 2, tiles: [] }); + expect(sanitizeLayout({ version: 1, tiles: "x" })).toEqual({ version: 2, tiles: [] }); + }); + + it("coerces malformed entries and drops anonymous/duplicate ones", () => { + const data = sanitizeLayout({ + version: 1, + tiles: [ + { id: "a", colSpan: "x", rowSpan: 0, order: "y" }, + null, + { colSpan: 2 }, + { id: "a", order: 9 }, + { id: "b", colSpan: 3.9, order: 2.5 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "a", colSpan: 1, rowSpan: 1, order: 0 }, + { id: "b", colSpan: 3, rowSpan: 1, order: 2 }, + ]); + }); + + it("drops garbage anchors (non-positive / fractional / half a pair)", () => { + const data = sanitizeLayout({ + version: 2, + tiles: [ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2.5, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2, col: 0, row: 1 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3, col: 2 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4, col: -1, row: -1 }, + ], + }); + expect(data.tiles).toEqual([ + { id: "ok", colSpan: 1, rowSpan: 1, order: 0, col: 2, row: 3 }, + { id: "frac", colSpan: 1, rowSpan: 1, order: 1, col: 2, row: 3 }, + { id: "zero", colSpan: 1, rowSpan: 1, order: 2 }, + { id: "lone", colSpan: 1, rowSpan: 1, order: 3 }, + { id: "neg", colSpan: 1, rowSpan: 1, order: 4 }, + ]); + }); +}); + +describe("geometry-agnostic persistence", () => { + it("the SAME persisted data renders in either geometry (unpinned flow re-derives)", () => { + const tiles: TileLayoutEntry[] = [ + tile("a", 0, 4, 2), + tile("b", 1), + tile("c", 2), + tile("d", 3, 2, 2), + tile("e", 4), + ]; + const widePlacements = pack(tiles, WIDE); + const tallPlacements = pack(tiles, TALL); + // Wide flows column-wise across 4 rows; tall flows row-wise across 2 columns. + expect(widePlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 4, rowSpan: 2 }); + expect(widePlacements.at(-1)).toMatchObject({ id: "e" }); + expect(tallPlacements[0]).toMatchObject({ id: "a", col: 1, row: 1, colSpan: 2, rowSpan: 2 }); + // An unpinned entry carries NO anchor — only id/spans/order. + expect(Object.keys(tiles[0] ?? {}).sort()).toEqual(["colSpan", "id", "order", "rowSpan"]); + }); +}); + +describe("pinned pack (v2)", () => { + it("pinned tiles land at their anchors; unpinned flow into the remaining free cells", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 3 }, + { ...tile("b", 1), col: 1, row: 1 }, + tile("c", 2), // unpinned → first free = (1,2) + tile("d", 3), // unpinned → next free = (1,4) + ]; + // pack returns ENTRY order (a, b, c, d); positions are what matters. + const placements = pack(tiles, WIDE); + expect(placements.map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 3], + ["b", 1, 1], + ["c", 1, 2], + ["d", 1, 4], + ]); + }); + + it("keeps the GAP between two pins (flow does not compact it away)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("z", 1), col: 1, row: 4 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.row])).toEqual([ + ["a", 1], + ["z", 4], + ]); + }); + + it("a pin whose span no longer fits its anchor reads as UNPINNED (flows)", () => { + // A 2-tall tile pinned at row 4 cannot fit the 4-row cross-axis at that + // anchor → it flows to the first fitting slot instead: (1,2). + const tiles: TileLayoutEntry[] = [ + { ...tile("big", 0, 1, 2), col: 1, row: 4 }, + { ...tile("a", 1), col: 1, row: 1 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "big")).toMatchObject({ col: 1, row: 2 }); + }); + + it("overlapping pins resolve first-wins by entry order (the loser flows)", () => { + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 2 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + const placements = pack(tiles, WIDE); + expect(placements.find((p) => p.id === "a")).toMatchObject({ col: 1, row: 2 }); + expect(placements.find((p) => p.id === "b")).toMatchObject({ col: 1, row: 1 }); + }); + + it("a wide pin at col > 1 reads as UNPINNED in tall (documented lossy case)", () => { + const tiles: TileLayoutEntry[] = [{ ...tile("a", 0), col: 3, row: 1 }]; + expect(pack(tiles, TALL).find((p) => p.id === "a")).toMatchObject({ col: 1, row: 1 }); + }); + + it("tall pins map through the universal frame in both directions", () => { + // Universal column 1 folds onto tall's 2 columns in reading order: + // u1→(1,1) u2→(2,1) u3→(1,2) u4→(2,2). + expect(toRenderCell({ col: 1, row: 1 }, TALL)).toEqual({ col: 1, row: 1 }); + expect(toRenderCell({ col: 1, row: 2 }, TALL)).toEqual({ col: 2, row: 1 }); + expect(toRenderCell({ col: 1, row: 3 }, TALL)).toEqual({ col: 1, row: 2 }); + expect(toRenderCell({ col: 1, row: 4 }, TALL)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, TALL)).toEqual({ col: 1, row: 4 }); + // Wide is the identity. + expect(toRenderCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(toUniversalCell({ col: 2, row: 2 }, WIDE)).toEqual({ col: 2, row: 2 }); + expect(UNIVERSAL_ROWS).toBe(4); + }); + + it("a layout dragged in TALL renders its pins when back in WIDE", () => { + // Committed in tall: a at tall (1,1) → universal (1,1); b at tall (2,1) → + // universal (1,2). Back in wide they render exactly there (col 1). + const tiles: TileLayoutEntry[] = [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("b", 1), col: 1, row: 2 }, + ]; + expect(pack(tiles, WIDE).map((p) => [p.id, p.col, p.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 2], + ]); + }); + + it("deleting a workspace leaves its cells EMPTY (mergeLayout keeps other pins)", () => { + const persisted = { + version: 2 as const, + tiles: [ + { ...tile("a", 0), col: 1, row: 1 }, + { ...tile("gone", 1), col: 1, row: 2 }, + { ...tile("b", 2), col: 1, row: 3 }, + ] as TileLayoutEntry[], + }; + const merged = mergeLayout(persisted, ["a", "b"]); + expect(merged.map((t) => [t.id, t.col, t.row])).toEqual([ + ["a", 1, 1], + ["b", 1, 3], + ]); + // The gap at (1,2) stays: a new workspace flows/pins INTO it. + expect(firstEmptySlot(merged, { colSpan: 1, rowSpan: 1 }, WIDE)).toEqual({ col: 1, row: 2 }); + }); +}); + +describe("type surface", () => { + it("exposes the geometry fields the picker and grid need", () => { + // A 500px-tall canvas: the raw unit (100) clamps UP to the 112px minimum, + // so the gap is 12px and the margin 37px (never the old fixed 8px gap). + const geometry: GridGeometry = geometryFor(1000, 500); + expect([ + geometry.mode, + geometry.flowRows, + geometry.flowCols, + geometry.unit.gap, + geometry.margin, + ]).toEqual(["wide", 4, 0, 12, 37]); + }); +}); diff --git a/src/features/workspaces/logic/tile-layout.ts b/src/features/workspaces/logic/tile-layout.ts new file mode 100644 index 0000000..6f7c281 --- /dev/null +++ b/src/features/workspaces/logic/tile-layout.ts @@ -0,0 +1,709 @@ +/** + * Pure tile-layout logic for the Win8-Start-style workspace home grid — zero + * DOM, zero Svelte, zero effects. + * + * The PERSISTED data (v2): `{ version, tiles: [{ id, colSpan, rowSpan, order, + * col?, row? }] }` — each tile's spans, arrival order, and OPTIONAL pinned + * anchor. Anchors live in a UNIVERSAL 4-row frame (wide's frame; tall folds + * its 2 columns onto frame column 1), so the same document renders in either + * geometry with no orientation conversion step: an anchor that doesn't map + * into the current geometry (or whose clamped span can't fit there) reads as + * UNPINNED, and unpinned tiles flow into the first free cells — holes between + * pins included. Empty cells are real: nothing ever slides backward, so gaps + * only close when a drop's displacement pushes a run of filled cells through + * them (see {@link displacePlace}). + * + * Terminology (distinct from the layout feature's nested split-tree "tile"): + * a HOME TILE is one workspace's cell in the flat start-screen grid. + */ + +/** The grid geometry mode: landscape/desktop ("wide") or portrait/mobile ("tall"). */ +export type TileGeometryMode = "wide" | "tall"; + +/** The px size of one tile unit (one grid cell) + the grid gap. */ +export interface TileUnitSize { + /** + * The unit's px width. `0` means "fluid" (the tall grid sizes its fixed + * columns in `1fr` so two tiles fill a phone's width). + */ + readonly width: number; + readonly height: number; + readonly gap: number; +} + +/** + * The full grid geometry a viewport resolves to. `flowRows`/`flowCols` name + * the FIXED track counts across the fill direction's cross axis: + * wide mode fixes ROWS (tiles flow column-wise, columns grow unbounded); + * tall mode fixes COLS (tiles flow row-wise, rows grow unbounded). `0` = + * unbounded. The picker (resize) dimensions equal the span caps. + */ +export interface GridGeometry { + readonly mode: TileGeometryMode; + /** Fixed row tracks (wide: 4; tall: 0 = rows grow unbounded). */ + readonly flowRows: number; + /** Fixed column tracks (tall: 2; wide: 0 = columns grow unbounded). */ + readonly flowCols: number; + /** Maximum horizontal span a tile may occupy in this geometry. */ + readonly maxColSpan: number; + /** Maximum vertical span a tile may occupy in this geometry. */ + readonly maxRowSpan: number; + readonly unit: TileUnitSize; + /** + * The breathing room around the whole tile field — the grid never renders + * flush against the canvas edge. In wide mode the scroller pads all four + * sides by this amount (the pan arrow/scroll indicator offset inward by it + * too); in tall mode it insets the two fluid columns horizontally. + */ + readonly margin: number; +} + +/** One persisted tile: which workspace it pins, how big it wants to be, its manual order, and its anchor. */ +export interface TileLayoutEntry { + /** The workspace id (the slug). */ + readonly id: string; + readonly colSpan: number; + readonly rowSpan: number; + /** Lower sorts earlier. Arrival order for unpinned flow + first-wins tie-breaks. */ + readonly order: number; + /** + * The pinned anchor cell (1-based) in the UNIVERSAL 4-row frame, or absent = + * unpinned (the tile flows into the first free cells at render time). Tall + * mode normalizes its 2-column grid into this frame: render { c, r } ↔ + * universal { col: 1, row: 2(r−1)+c }. A pin outside the current geometry's + * fixed cross-axis reads as UNPINNED (render-time clamp — like spans). + */ + readonly col?: number; + readonly row?: number; +} + +/** The persisted document (localStorage via the injected `LocalStore` adapter). */ +export interface TileLayoutData { + readonly version: 1 | 2; + readonly tiles: readonly TileLayoutEntry[]; +} + +/** A tile's resolved footprint on the grid (1-based grid coordinates). */ +export interface Placement { + readonly id: string; + readonly col: number; + readonly row: number; + readonly colSpan: number; + readonly rowSpan: number; +} + +/** A requested span (pre-clamping). */ +export interface Span { + readonly colSpan: number; + readonly rowSpan: number; +} + +/** The viewport assumed before the canvas has been measured (a landscape desktop). */ +export const DEFAULT_VIEWPORT = { width: 1280, height: 800 } as const; + +/** + * The wide unit's clamp band (px). Tiles FILL the canvas' height across the 4 + * fixed rows (the Start-screen feel), but never shrink below a legible small + * tile nor grow past a comfortable large one. + */ +const WIDE_UNIT_MIN = 112; +const WIDE_UNIT_MAX = 184; + +/** The wide canvas' outer margin band (px) — proportional to the unit size. */ +const WIDE_MARGIN_MIN = 24; +const WIDE_MARGIN_MAX = 64; + +/** The tall unit: the two fluid columns size in `1fr` (width `0` = fluid). */ +const TALL_UNIT: TileUnitSize = { width: 0, height: 148, gap: 12 }; +const TALL_MARGIN = 16; + +/** Round to whole pixels (fractional track sizes blur tile edges). */ +function px(value: number): number { + return Math.round(value); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +/** + * Resolve the grid geometry from the tile canvas' size. Landscape/desktop → + * WIDE (4 fixed rows, column-wise flow, 4×2 span cap); portrait/mobile → TALL + * (2 fixed columns, row-wise flow, 2×2 span cap). Degenerate sizes (not yet + * measured) resolve to wide. + * + * The wide unit is DERIVED from the canvas height so the 4 rows exactly fill + * it (unit + margin + gap all proportional, clamped to their bands); the tall + * unit is fixed (its columns are fluid instead). Pure: numbers in, geometry + * out. + */ +export function geometryFor(width: number, height: number): GridGeometry { + const tall = width > 0 && height > 0 && height > width; + if (tall) { + return { + mode: "tall", + flowRows: 0, + flowCols: 2, + maxColSpan: 2, + maxRowSpan: 2, + unit: TALL_UNIT, + margin: TALL_MARGIN, + }; + } + // Solve 4·unit + 3·gap + 2·margin = height with gap = unit/9 and margin = + // unit/3: 4u + u/3 + 2u/3 = 5u → u = height/5. Unmeasured (height ≤ 0) → + // the band's middle, not a tiny tile. + const raw = height > 0 ? height / 5 : (WIDE_UNIT_MIN + WIDE_UNIT_MAX) / 2; + const unit = px(clampNumber(raw, WIDE_UNIT_MIN, WIDE_UNIT_MAX)); + const gap = Math.max(10, px(unit / 9)); + const margin = px(clampNumber(unit / 3, WIDE_MARGIN_MIN, WIDE_MARGIN_MAX)); + return { + mode: "wide", + flowRows: 4, + flowCols: 0, + maxColSpan: 4, + maxRowSpan: 2, + unit: { width: unit, height: unit, gap }, + margin, + }; +} + +/** Sort entries by their persisted order (stable — ties keep their relative order). */ +export function sortTiles<T extends TileLayoutEntry>(tiles: readonly T[]): T[] { + return [...tiles].sort((a, b) => a.order - b.order); +} + +/** Clamp a requested span to a geometry's caps, coercing garbage (non-positive/NaN) to 1. */ +export function clampSpan<T extends Span>(span: T, geometry: GridGeometry): Span { + return { + colSpan: clampUnit(span.colSpan, geometry.maxColSpan), + rowSpan: clampUnit(span.rowSpan, geometry.maxRowSpan), + }; +} + +function clampUnit(value: number, max: number): number { + if (!Number.isFinite(value)) return 1; + const int = Math.floor(value); + if (int < 1) return 1; + return Math.min(int, max); +} + +/** + * A position's rank in the geometry's FILL ORDER — the order slots are visited + * when packing: wide fills column-wise (top-to-bottom, then the next column); + * tall fills row-wise (left-to-right, then the next row). Pure; only used for + * comparisons within one geometry. + */ +export function fillOrderKey(col: number, row: number, geometry: GridGeometry): number { + if (geometry.mode === "wide") return (col - 1) * geometry.flowRows + (row - 1); + return (row - 1) * geometry.flowCols + (col - 1); +} + +/** + * The universal frame's fixed row count (the wide grid's). Tall render cells + * map onto column 1 of this frame; wide is the identity. + */ +export const UNIVERSAL_ROWS = 4; + +/** A tile's anchor cell (1-based). */ +export interface Cell { + readonly col: number; + readonly row: number; +} + +/** + * Universal-frame anchor → render-geometry cell. Wide: identity. Tall: column + * 1 of the universal frame folds onto the 2 render columns (universal row u → + * render { col: (u−1)%2 + 1, row: ⌈u/2⌉ }); any universal col > 1 cannot be + * represented in tall → `null` (the tile reads as UNPINNED in tall). + * Pure. + */ +export function toRenderCell(anchor: Cell, geometry: GridGeometry): Cell | null { + if (geometry.mode === "wide") return anchor; + if (anchor.col !== 1) return null; + return { col: ((anchor.row - 1) % 2) + 1, row: Math.floor((anchor.row - 1) / 2) + 1 }; +} + +/** Render-geometry cell → universal-frame anchor (the commit direction). Pure. */ +export function toUniversalCell(cell: Cell, geometry: GridGeometry): Cell { + if (geometry.mode === "wide") return cell; + return { col: 1, row: 2 * (cell.row - 1) + cell.col }; +} + +function cellsFree( + occupied: ReadonlySet<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): boolean { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + if (occupied.has(`${r}:${c}`)) return false; + } + } + return true; +} + +function occupy( + occupied: Set<string>, + col: number, + row: number, + colSpan: number, + rowSpan: number, +): void { + for (let r = row; r < row + rowSpan; r++) { + for (let c = col; c < col + colSpan; c++) { + occupied.add(`${r}:${c}`); + } + } +} + +function maxOccupied(occupied: ReadonlySet<string>, axis: "col" | "row"): number { + let max = 0; + for (const cell of occupied) { + const value = Number.parseInt( + axis === "row" ? (cell.split(":")[0] ?? "") : (cell.split(":")[1] ?? ""), + 10, + ); + if (Number.isFinite(value) && value > max) max = value; + } + return max; +} + +/** + * The first slot (in fill order) where a span-sized footprint fits without + * overlapping or exceeding the geometry's fixed cross-axis. Beyond the + * occupied region every cell is free, so the scan is bounded by the occupied + * extent — the result always exists (the flow is unbounded along its main + * axis). Pure: occupancy + span + geometry in, slot out. + */ +function firstFit( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col"); + for (let col = 1; col <= maxCol; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 1, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row"); + for (let row = 1; row <= maxRow; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 1 }; +} + +/** + * THE entries → coordinates transform, v2. Two passes, one scan each: + * + * 1. **Pinned pass** (in entry order — first-pinned-wins on overlap, strictly + * one-ply: a displaced pin never bumps another pin): a pinned tile lands at + * its anchor (universal frame → render cell) when the anchor maps into this + * geometry, its clamped footprint fits the fixed cross-axis there, and no + * already-placed tile overlaps. Otherwise the tile reads as UNPINNED. + * 2. **Flow pass**: unpinned tiles (never pinned, out-of-range, clashing) take + * the first fitting FREE slot in fill order — holes between pins count. + * + * Requested spans clamp to the geometry (render-time only; the persisted spans + * stay raw). Pure: entries + geometry in, a NEW placements array out (entry + * order — the render order). + */ +export function pack(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Placement[] { + const occupied = new Set<string>(); + const placements: (Placement | undefined)[] = []; + const flowing: { index: number; tile: TileLayoutEntry }[] = []; + const ordered = sortTiles(tiles); + for (let index = 0; index < ordered.length; index++) { + const tile = ordered[index]; + if (!tile) continue; + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const anchor = pinnedAnchor(tile, geometry); + if ( + anchor !== null && + fitsCrossAxis(anchor, colSpan, rowSpan, geometry) && + cellsFree(occupied, anchor.col, anchor.row, colSpan, rowSpan) + ) { + occupy(occupied, anchor.col, anchor.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: anchor.col, row: anchor.row, colSpan, rowSpan }; + } else { + flowing.push({ index, tile }); + } + } + for (const { index, tile } of flowing) { + const { colSpan, rowSpan } = clampSpan(tile, geometry); + const slot = firstFit(occupied, { colSpan, rowSpan }, geometry); + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placements[index] = { id: tile.id, col: slot.col, row: slot.row, colSpan, rowSpan }; + } + return placements.filter((placement) => placement !== undefined); +} + +/** + * A pinned tile's render-geometry anchor, or `null` when the tile is unpinned + * (no anchor) or its anchor cannot be represented in this geometry (a wide + * column > 1 read in tall). Pure. + */ +function pinnedAnchor(tile: TileLayoutEntry, geometry: GridGeometry): Cell | null { + if (tile.col === undefined || tile.row === undefined) return null; + return toRenderCell({ col: tile.col, row: tile.row }, geometry); +} + +/** The footprint fits the geometry's FIXED cross-axis at this anchor. */ +function fitsCrossAxis( + anchor: Cell, + colSpan: number, + rowSpan: number, + geometry: GridGeometry, +): boolean { + if (geometry.mode === "wide") return anchor.row + rowSpan - 1 <= geometry.flowRows; + return anchor.col + colSpan - 1 <= geometry.flowCols; +} + +/** + * The first fitting slot at or after a minimum stream key (the displacement + * re-anchor scan — pushed tiles only move FORWARD). Pure. + */ +function firstFitFrom( + occupied: ReadonlySet<string>, + span: Span, + geometry: GridGeometry, + minKey: number, +): Cell { + const { colSpan, rowSpan } = clampSpan(span, geometry); + if (geometry.mode === "wide") { + const maxCol = maxOccupied(occupied, "col") + 1; + for (let col = 1; col <= maxCol + 1; col++) { + for (let row = 1; row <= geometry.flowRows - rowSpan + 1; row++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: maxCol + 2, row: 1 }; + } + const maxRow = maxOccupied(occupied, "row") + 1; + for (let row = 1; row <= maxRow + 1; row++) { + for (let col = 1; col <= geometry.flowCols - colSpan + 1; col++) { + if (fillOrderKey(col, row, geometry) < minKey) continue; + if (cellsFree(occupied, col, row, colSpan, rowSpan)) return { col, row }; + } + } + return { col: 1, row: maxRow + 2 }; +} + +/** + * THE displacement op — drop `draggedId` at `cell` over the CURRENT RENDER + * placements (the squares exactly as the user sees them). Semantics (user- + * specified): the dragged tile leaves its slot (the gap OPENS — nothing slides + * backward, ever), anchors exactly at `cell`, and the contiguous run of filled + * cells pushes FORWARD in fill order until the first empty cell absorbs it: + * + * `X _ X` + Y at slot 1 → `Y X X` + * `_ X Z _` + Y at slot 2 → `_ Y X Z` + * + * Tiles keep their positions unless their footprint overlaps an already-placed + * one; the displaced re-anchor at the next fitting slot forward (spans ride + * along; a span that can't fit the fixed cross-axis anywhere forward wraps to + * the first fitting slot — it must land somewhere). Pure: placements + drop + * in, a NEW placements array out — in the INPUT array order (only coordinates + * change). A keyed `{#each}` over the result therefore never moves DOM nodes; + * moving a captured element drops its pointer capture — the "stuck tile" bug. + */ +export function displacePlace( + placements: readonly Placement[], + draggedId: string, + cell: Cell, + geometry: GridGeometry, +): Placement[] { + const dragged = placements.find((placement) => placement.id === draggedId); + if (!dragged) return [...placements]; + const occupied = new Set<string>(); + occupy(occupied, cell.col, cell.row, dragged.colSpan, dragged.rowSpan); + const placed: Placement[] = [{ ...dragged, col: cell.col, row: cell.row }]; + const others = placements + .filter((placement) => placement.id !== draggedId) + .map((placement) => ({ + placement, + key: fillOrderKey(placement.col, placement.row, geometry), + })) + .sort((a, b) => a.key - b.key); + for (const { placement, key } of others) { + const { colSpan, rowSpan } = placement; + if (cellsFree(occupied, placement.col, placement.row, colSpan, rowSpan)) { + occupy(occupied, placement.col, placement.row, colSpan, rowSpan); + placed.push(placement); + continue; + } + let slot = firstFitFrom(occupied, placement, geometry, key); + if (!fitsCrossAxis(slot, colSpan, rowSpan, geometry)) { + slot = firstFit(occupied, placement, geometry); + } + occupy(occupied, slot.col, slot.row, colSpan, rowSpan); + placed.push({ ...placement, col: slot.col, row: slot.row }); + } + // Return in the INPUT render order (coordinates change only): the live drag + // preview feeds a keyed `{#each}`, and a reordered array moves the DOM nodes + // — a moved captured element silently loses pointer capture (the stuck-tile + // bug). The commit path re-sorts to stream order itself (`placeAtCell`). + const byId = new Map(placed.map((placement) => [placement.id, placement])); + return placements.map((placement) => byId.get(placement.id) ?? placement); +} + +/** + * The commit path: pack the persisted entries, displace at `cell`, and pin + * EVERY tile back to the entries at its resulting anchor (universal frame — + * the persisted spans stay raw, only anchors + order change; order renumbers + * in stream order). This is the drag-drop write. Pure: entries in, NEW + * entries out. + */ +export function placeAtCell( + tiles: readonly TileLayoutEntry[], + id: string, + cell: Cell, + geometry: GridGeometry, +): TileLayoutEntry[] { + // An unknown dragged id changes nothing (order-sorted copy — the op's + // canonical no-op shape, like `moveTile` before it). + if (!tiles.some((tile) => tile.id === id)) return sortTiles(tiles); + // displacePlace preserves the input RENDER order for the live preview; the + // commit pins + renumbers in STREAM order, so re-sort before pinning. + const displaced = displacePlace(pack(tiles, geometry), id, cell, geometry) + .slice() + .sort((a, b) => fillOrderKey(a.col, a.row, geometry) - fillOrderKey(b.col, b.row, geometry)); + const result: TileLayoutEntry[] = []; + for (const placement of displaced) { + const entry = tiles.find((tile) => tile.id === placement.id); + if (!entry) continue; + const anchor = toUniversalCell(placement, geometry); + result.push({ ...entry, col: anchor.col, row: anchor.row }); + } + return result.map((entry, index) => (entry.order === index ? entry : { ...entry, order: index })); +} + +/** + * The drag target's clamp on the geometry's UNBOUNDED axis — a drop may aim + * at most `extra` columns (wide) / rows (tall) past the current content edge, + * so a drag can never create an endlessly growing canvas ("hard to place if + * it just grows"): wide bound = the right-most occupied column (span-aware) + + * `extra`; tall = the bottom-most occupied row + `extra`. Empty content → the + * first tiles may land within the first `extra` columns. The bound is measured + * on the COMMITTED placements — a drag always compares against the layout it + * started from, never the live preview, so the limit cannot ratchet mid-drag. + * Pure: placements + geometry in, the max allowed column (wide) / row (tall) + * out. + */ +export function dragAppendLimit( + placements: readonly Placement[], + geometry: GridGeometry, + extra = 3, +): number { + let extent = 0; + for (const placement of placements) { + extent = Math.max( + extent, + geometry.mode === "wide" + ? placement.col + placement.colSpan - 1 + : placement.row + placement.rowSpan - 1, + ); + } + return extent + extra; +} + +/** + * The first footprint-fitting EMPTY slot in fill order — where a NEW tile of + * the given span would go (the header's create flow). Pure: entries + span + + * geometry in, a slot placement out (always exists; may be a new column/row + * beyond the current extent). + */ +export function firstEmptySlot( + tiles: readonly TileLayoutEntry[], + span: Span, + geometry: GridGeometry, +): { col: number; row: number } { + return firstFit(packOccupancy(tiles, geometry), span, geometry); +} + +/** Build the occupancy set of the tiles' packed positions. */ +function packOccupancy(tiles: readonly TileLayoutEntry[], geometry: GridGeometry): Set<string> { + const occupied = new Set<string>(); + for (const placement of pack(tiles, geometry)) { + occupy(occupied, placement.col, placement.row, placement.colSpan, placement.rowSpan); + } + return occupied; +} + +/** Re-assign orders 0..n-1 by array position (the canonical order encoding after structural edits). */ +function renumber(tiles: readonly TileLayoutEntry[]): TileLayoutEntry[] { + return tiles.map((tile, index) => (tile.order === index ? tile : { ...tile, order: index })); +} + +/** + * Pin a tile at the first EMPTY slot in fill order — the header-create + * placement (holes between pins count). Idempotent: an id already in the + * layout is RE-PINNED there (spans preserved), not duplicated — this closes + * the race where the list-refresh sync appended the id first. Unknown ids + * append as a new 1×1 pinned entry. Pure: entries in, a NEW entries array out + * (order preserved — anchors, not order, place tiles now). + */ +export function pinAtFirstEmpty( + tiles: readonly TileLayoutEntry[], + id: string, + geometry: GridGeometry, +): TileLayoutEntry[] { + const without = sortTiles(tiles).filter((tile) => tile.id !== id); + const slot = firstEmptySlot(without, { colSpan: 1, rowSpan: 1 }, geometry); + const anchor = toUniversalCell(slot, geometry); + const existing = tiles.find((tile) => tile.id === id); + const entry: TileLayoutEntry = existing + ? { ...existing, col: anchor.col, row: anchor.row } + : { + id, + colSpan: 1, + rowSpan: 1, + order: without.reduce((max, tile) => Math.max(max, tile.order + 1), 0), + col: anchor.col, + row: anchor.row, + }; + return [...without, entry]; +} + +/** + * Resize one tile: set its persisted spans (sanitized to positive integers — + * NOT clamped to the current geometry; clamping is a render-time concern so a + * wide-sized tile survives an orientation switch). Unknown ids are ignored. + * Pure: entries in, a NEW entries array out. + */ +export function resizeTile( + tiles: readonly TileLayoutEntry[], + id: string, + colSpan: number, + rowSpan: number, +): TileLayoutEntry[] { + const sanitized: Span = { + colSpan: clampUnit(colSpan, Number.MAX_SAFE_INTEGER), + rowSpan: clampUnit(rowSpan, Number.MAX_SAFE_INTEGER), + }; + return sortTiles(tiles).map((tile) => + tile.id === id ? { ...tile, colSpan: sanitized.colSpan, rowSpan: sanitized.rowSpan } : tile, + ); +} + +/** + * Re-apply the recent-activity order (the header overflow "Sort" action) AND + * clear every pin — an explicit ARRANGEMENT RESET: tiles sort by their + * workspace's `lastActivityAt` descending (unknown ids last, ties stable), + * orders renumber, and the now-unpinned tiles first-fit with no gaps. Pure. + */ +export function sortByRecentActivity( + tiles: readonly TileLayoutEntry[], + lastActivityById: ReadonlyMap<string, number>, +): TileLayoutEntry[] { + const sorted = sortTiles(tiles).sort( + (a, b) => (lastActivityById.get(b.id) ?? 0) - (lastActivityById.get(a.id) ?? 0), + ); + return renumber( + sorted.map((tile) => { + const { col: _col, row: _row, ...unpinned } = tile; + return unpinned; + }), + ); +} + +/** + * Merge the persisted layout with the live workspace list: workspaces unknown + * to the layout (e.g. auto-created via deep-link) APPEND at the end (default + * 1×1, order after the current max); entries for deleted workspaces DROP; + * duplicate ids dedup (first wins); the result sorts by order. When a + * geometry is given, the returned VIEW clamps spans to it (the persisted + * document keeps the raw spans — clamping is transient per geometry). Pure: + * both inputs in, a NEW entries array out. + */ +export function mergeLayout( + persisted: TileLayoutData | null | undefined, + workspaceIds: readonly string[], + geometry?: GridGeometry, +): TileLayoutEntry[] { + const known = new Set(workspaceIds); + const kept: TileLayoutEntry[] = []; + const seen = new Set<string>(); + let maxOrder = 0; + for (const tile of sortTiles(persisted?.tiles ?? [])) { + if (!known.has(tile.id) || seen.has(tile.id)) continue; + seen.add(tile.id); + kept.push(tile); + if (tile.order > maxOrder) maxOrder = tile.order; + } + const appended: TileLayoutEntry[] = []; + for (const id of workspaceIds) { + if (seen.has(id)) continue; + seen.add(id); + maxOrder += 1; + appended.push({ id, colSpan: 1, rowSpan: 1, order: maxOrder }); + } + const merged = [...kept, ...appended]; + if (!geometry) return merged; + return merged.map((tile) => ({ ...tile, ...clampSpan(tile, geometry) })); +} + +/** + * Coerce an unknown parsed-JSON value into layout data (the `LocalStore` load + * is untyped at the boundary). v1 documents (order + spans only) MIGRATE to + * v2 in memory: their tiles become unpinned, which packs identically to v1's + * first-fit — the layout only starts carrying anchors after the user's first + * drop. v2 entries keep their anchors; malformed entries coerce (missing + * spans → 1, garbage anchors → unpinned, garbage entries → dropped). Anything + * else yields the empty layout. Pure: unknown in, v2 data out. + */ +export function sanitizeLayout(raw: unknown): TileLayoutData { + if (typeof raw !== "object" || raw === null) return EMPTY_LAYOUT; + const version = (raw as { version?: unknown }).version; + if (version !== 1 && version !== 2) return EMPTY_LAYOUT; + const rawTiles = (raw as { tiles?: unknown }).tiles; + if (!Array.isArray(rawTiles)) return EMPTY_LAYOUT; + const tiles: TileLayoutEntry[] = []; + const seen = new Set<string>(); + for (let index = 0; index < rawTiles.length; index++) { + const rawTile = rawTiles[index]; + if (typeof rawTile !== "object" || rawTile === null) continue; + const id = (rawTile as { id?: unknown }).id; + if (typeof id !== "string" || id === "" || seen.has(id)) continue; + seen.add(id); + const col = coerceAnchor((rawTile as { col?: unknown }).col); + const row = coerceAnchor((rawTile as { row?: unknown }).row); + tiles.push({ + id, + colSpan: coerceSpan((rawTile as { colSpan?: unknown }).colSpan), + rowSpan: coerceSpan((rawTile as { rowSpan?: unknown }).rowSpan), + order: coerceOrder((rawTile as { order?: unknown }).order, index), + // An anchor is only meaningful as a PAIR — a lone coordinate drops both. + ...(version === 2 && col !== undefined && row !== undefined ? { col, row } : {}), + }); + } + return { version: 2, tiles }; +} + +const EMPTY_LAYOUT: TileLayoutData = { version: 2, tiles: [] }; + +function coerceSpan(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return 1; + return Math.floor(value); +} + +function coerceOrder(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.floor(value); +} + +/** A valid anchor coordinate (1-based positive integer) or undefined. */ +function coerceAnchor(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value) || value < 1) return undefined; + return Math.floor(value); +} diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 44d2f31..31366ba 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,39 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; +import { + applyArchived, + applyStarred, + pageTitle, + relativeTime, + sortWorkspaces, + tileTone, +} from "./view-model"; + +describe("tileTone", () => { + const TONES = ["primary", "secondary", "accent", "info", "success", "warning"]; + + it("is deterministic — the same id always gets the same tone", () => { + expect(tileTone("my-workspace")).toBe(tileTone("my-workspace")); + }); + + it("only returns known tones", () => { + for (const id of ["default", "a", "my-workspace", "x-1", "project-phoenix", "zzz"]) { + expect(TONES).toContain(tileTone(id)); + } + }); + + it("spreads distinct ids across more than one tone", () => { + const tones = new Set( + ["default", "alpha", "beta", "gamma", "delta", "epsilon", "zeta", "eta"].map(tileTone), + ); + expect(tones.size).toBeGreaterThan(1); + }); + + it("is case-sensitive (ids are slugs, but the hash makes no normalization promise)", () => { + // Not a correctness requirement — just documents the hash reads raw chars. + expect(typeof tileTone("ABC")).toBe("string"); + }); +}); describe("relativeTime", () => { const now = 1_000_000_000_000; // 2001-09-09 @@ -23,10 +56,15 @@ describe("relativeTime", () => { expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); }); - it("is a short date beyond a week", () => { - // 7+ days ago: just check it is a MM/DD string. - const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); - expect(s).toMatch(/^\d{2}\/\d{2}$/); + it("keeps days past a week (no short date) up to a month", () => { + expect(relativeTime(now - 10 * 24 * 60 * 60_000, now)).toBe("10d"); + expect(relativeTime(now - 29 * 24 * 60 * 60_000, now)).toBe("29d"); + }); + + it("is months past a month", () => { + expect(relativeTime(now - 30 * 24 * 60 * 60_000, now)).toBe("1mo"); + expect(relativeTime(now - 75 * 24 * 60 * 60_000, now)).toBe("2mo"); + expect(relativeTime(now - 365 * 24 * 60 * 60_000, now)).toBe("12mo"); }); }); @@ -38,6 +76,7 @@ describe("pageTitle", () => { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -75,6 +114,7 @@ describe("sortWorkspaces", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt, conversationCount: 0, @@ -133,6 +173,7 @@ describe("applyStarred", () => { defaultCwd: null, defaultComputerId: null, starred, + archived: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -177,3 +218,50 @@ describe("applyStarred", () => { expect(reverted[0]?.starred).toBe(false); }); }); + +describe("applyArchived", () => { + const entry = (id: string, archived: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's archived flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyArchived(list, "b", true); + expect(next.map((w) => [w.id, w.archived])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyArchived(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.archived).toBe(false); + expect(next[0]?.archived).toBe(true); + }); + + it("leaves other entries referentially unchanged (only the target is replaced)", () => { + const a = entry("a", false); + const b = entry("b", false); + const next = applyArchived([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("can revert by re-applying the previous value (unarchive)", () => { + const list = [entry("a", false)]; + const optimistic = applyArchived(list, "a", true); + expect(optimistic[0]?.archived).toBe(true); + const reverted = applyArchived(optimistic, "a", false); + expect(reverted[0]?.archived).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index b994b7c..cdf99c8 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -6,6 +6,40 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import type { Route } from "./route"; /** + * A home tile's color tone. Windows 8's Start screen gave every tile its own + * saturated color; here the tile's workspace id DETERMINISTICALLY picks one of + * the theme's six semantic tones, so the same workspace keeps its color across + * sessions and themes (the tone name maps to a DaisyUI color, which adapts to + * the active theme). Starred stays a separate visual concern (a badge), NOT a + * tone — any tile may be starred regardless of its color. + */ +export type TileTone = "primary" | "secondary" | "accent" | "info" | "success" | "warning"; + +/** The tones a tile may take, in hash order. */ +const TILE_TONES: readonly TileTone[] = [ + "primary", + "secondary", + "accent", + "info", + "success", + "warning", +]; + +/** + * Map a workspace id to its tile tone. Deterministic (FNV-1a hash of the id) + * and uniformly distributed across the six tones — pure: id in, tone out. + */ +export function tileTone(id: string): TileTone { + let hash = 0x811c9dc5; + for (let index = 0; index < id.length; index++) { + hash ^= id.charCodeAt(index); + // FNV prime (16777619), kept in 32-bit unsigned range. + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return TILE_TONES[hash % TILE_TONES.length] ?? "primary"; +} + +/** * The browser tab / page (`document.title`) text for a route. The home route * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}", * using the workspace's display title and falling back to the URL slug (`id`) @@ -50,6 +84,20 @@ export function applyStarred<T extends WorkspaceEntry>( } /** + * Return a NEW list with the one workspace's `archived` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * archive/unarchive transformation shared by the apply + the error revert. A + * missing `id` leaves the list unchanged; a subsequent refresh reconciles. + */ +export function applyArchived<T extends WorkspaceEntry>( + workspaces: readonly T[], + id: string, + archived: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, archived } : w)); +} + +/** * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h", * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps * (a workspace just created) read as "now". @@ -62,10 +110,8 @@ export function relativeTime(then: number, now: number): string { const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h`; const days = Math.floor(hours / 24); - if (days < 7) return `${days}d`; - // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. - const d = new Date(then); - const month = String(d.getUTCMonth() + 1).padStart(2, "0"); - const day = String(d.getUTCDate()).padStart(2, "0"); - return `${month}/${day}`; + if (days < 30) return `${days}d`; + // Past a month: months (a month ≈ 30 days). No short date — the label + // stays relative forever. + return `${Math.floor(days / 30)}mo`; } diff --git a/src/features/workspaces/store.svelte.ts b/src/features/workspaces/store.svelte.ts index 22d73b5..b9b646b 100644 --- a/src/features/workspaces/store.svelte.ts +++ b/src/features/workspaces/store.svelte.ts @@ -1,7 +1,8 @@ import type { EnsureWorkspaceRequest } from "@dispatch/transport-contract"; import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import type { WorkspaceHttp, WorkspaceResult } from "./adapter/http"; -import { applyStarred, sortWorkspaces } from "./logic/view-model"; +import { gateCreate } from "./logic/create"; +import { applyArchived, applyStarred, sortWorkspaces } from "./logic/view-model"; /** * Workspace store — a thin reactive wrapper over the pure HTTP edge. Owns the @@ -16,12 +17,25 @@ export interface WorkspaceStore { * backend's `lastActivityAt`-desc order; this getter re-sorts reactively. */ readonly list: readonly WorkspaceEntry[]; + /** Archived workspaces, for the archived view. Loaded via `refreshArchived()`. */ + readonly archivedList: readonly WorkspaceEntry[]; readonly loading: boolean; + readonly archivedLoading: boolean; readonly error: string | null; /** Refresh the list from the backend. */ refresh(): Promise<void>; + /** Refresh the archived list (`GET /workspaces?archived=true`). */ + refreshArchived(): Promise<void>; /** `PUT /workspaces/:id` (create-on-miss). Returns the workspace or an error. */ ensure(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; + /** + * Create a workspace via the New-workspace flow. Probes `GET /workspaces/:id` + * first and refuses a slug that belongs to an ARCHIVED workspace (the + * idempotent `ensure` would otherwise silently resurrect an invisible tile). + * A failed probe is refused too (see `gateCreate`). On success it falls + * through to `ensure`, unchanged. + */ + create(id: string, body?: EnsureWorkspaceRequest): Promise<WorkspaceResult<Workspace>>; /** Rename a workspace (display only; id unchanged). */ rename(id: string, title: string): Promise<WorkspaceResult<Workspace>>; /** Set/clear a workspace's default cwd. */ @@ -34,25 +48,41 @@ export interface WorkspaceStore { * reverts to the prior value. No full refresh on success (avoids flicker). */ setStarred(id: string, starred: boolean): Promise<WorkspaceResult<Workspace>>; + /** + * Archive or unarchive a workspace. Optimistic: the local `archived` flag + * flips immediately (archiving drops the tile from the home grid); on error + * it reverts to the prior value. A successful archive/unarchive is followed + * by a `refresh()` so the active list reflects the backend (the archived + * workspace leaves/enters the default-filtered list). + */ + setArchived(id: string, archived: boolean): Promise<WorkspaceResult<Workspace>>; /** Delete a workspace (closes its conversations, reassigns to "default"). */ remove(id: string): Promise<WorkspaceResult<{ closedCount: number }>>; } export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { let list = $state<readonly WorkspaceEntry[]>([]); + let archivedList = $state<readonly WorkspaceEntry[]>([]); // Sorted view (starred first, then most-recent) — derived so it recomputes // only when the backing list changes, not on every read. let sorted = $derived(sortWorkspaces(list)); let loading = $state(false); + let archivedLoading = $state(false); let error = $state<string | null>(null); return { get list(): readonly WorkspaceEntry[] { return sorted; }, + get archivedList(): readonly WorkspaceEntry[] { + return archivedList; + }, get loading(): boolean { return loading; }, + get archivedLoading(): boolean { + return archivedLoading; + }, get error(): string | null { return error; }, @@ -69,6 +99,24 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { } }, + async refreshArchived(): Promise<void> { + archivedLoading = true; + error = null; + try { + archivedList = await http.list("true"); + } catch (err) { + error = err instanceof Error ? err.message : "Failed to load archived workspaces"; + } finally { + archivedLoading = false; + } + }, + + async create(id, body): Promise<WorkspaceResult<Workspace>> { + const gate = gateCreate(id, await http.get(id)); + if (!gate.ok) return { ok: false, error: gate.error }; + return this.ensure(id, body); + }, + async ensure(id, body): Promise<WorkspaceResult<Workspace>> { const result = await http.ensure(id, body); if (result.ok) void this.refresh(); @@ -105,6 +153,22 @@ export function createWorkspaceStore(http: WorkspaceHttp): WorkspaceStore { return result; }, + async setArchived(id, archived): Promise<WorkspaceResult<Workspace>> { + const prev = list.find((w) => w.id === id)?.archived ?? false; + // Optimistic: flip immediately so the tile leaves/enters the home grid. + list = applyArchived(list, id, archived); + const result = archived ? await http.archive(id) : await http.unarchive(id); + if (!result.ok) { + // Revert the optimistic flip on failure. + list = applyArchived(list, id, prev); + return result; + } + // Reconcile with the backend's default (active-only) filter so the + // archived tile is dropped / the unarchived tile is restored. + void this.refresh(); + return result; + }, + async remove(id): Promise<WorkspaceResult<{ closedCount: number }>> { const result = await http.delete(id); if (result.ok) void this.refresh(); diff --git a/src/features/workspaces/store.test.ts b/src/features/workspaces/store.test.ts index 4caac9f..faec5bf 100644 --- a/src/features/workspaces/store.test.ts +++ b/src/features/workspaces/store.test.ts @@ -1,6 +1,7 @@ import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it, vi } from "vitest"; import type { WorkspaceResult } from "./adapter/http"; +import { archivedCreateMessage, type WorkspaceLookup } from "./logic/create"; import { createWorkspaceStore } from "./store.svelte"; function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { @@ -10,6 +11,7 @@ function entry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { defaultCwd: null, defaultComputerId: null, starred: false, + archived: false, createdAt: 1, lastActivityAt: 2, conversationCount: 0, @@ -22,11 +24,25 @@ function fakeHttp(opts: { list?: readonly WorkspaceEntry[]; star?: (id: string) => Promise<WorkspaceResult<Workspace>>; unstar?: (id: string) => Promise<WorkspaceResult<Workspace>>; + archive?: (id: string) => Promise<WorkspaceResult<Workspace>>; + unarchive?: (id: string) => Promise<WorkspaceResult<Workspace>>; + get?: (id: string) => Promise<WorkspaceLookup>; + ensure?: (id: string) => Promise<WorkspaceResult<Workspace>>; }) { return { - list: vi.fn(async (): Promise<readonly WorkspaceEntry[]> => opts.list ?? []), - ensure: vi.fn(), - get: vi.fn(), + list: vi.fn( + async (_archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + opts.list ?? [], + ), + ensure: + opts.ensure ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id }), + }), + ), + get: vi.fn(opts.get ?? (async (): Promise<WorkspaceLookup> => ({ kind: "missing" }))), setTitle: vi.fn(), setDefaultCwd: vi.fn(), setDefaultComputer: vi.fn(), @@ -46,6 +62,22 @@ function fakeHttp(opts: { value: entry({ id, starred: false }), }), ), + archive: + opts.archive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: true }), + }), + ), + unarchive: + opts.unarchive ?? + vi.fn( + async (id: string): Promise<WorkspaceResult<Workspace>> => ({ + ok: true, + value: entry({ id, archived: false }), + }), + ), delete: vi.fn(), }; } @@ -143,3 +175,131 @@ describe("createWorkspaceStore — setStarred", () => { expect(store.list).toHaveLength(0); }); }); + +describe("createWorkspaceStore — setArchived", () => { + it("optimistically flips archived to true before the request resolves", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + let observedDuringCall = false; + http.archive = vi.fn(async (_id: string): Promise<WorkspaceResult<Workspace>> => { + // While the request is in flight, the store already shows the new state. + observedDuringCall = store.list[0]?.archived === true; + return { ok: true, value: entry({ id: "a", archived: true }) }; + }); + + await store.setArchived("a", true); + + expect(observedDuringCall).toBe(true); + expect(http.archive).toHaveBeenCalledWith("a"); + }); + + it("calls unarchive (DELETE) when archiving false", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: true })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + await store.setArchived("a", false); + + expect(http.unarchive).toHaveBeenCalledWith("a"); + expect(http.archive).not.toHaveBeenCalled(); + }); + + it("reverts the optimistic flip on error", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + http.archive = vi.fn( + async (): Promise<WorkspaceResult<Workspace>> => ({ ok: false, error: "boom" }), + ); + + const result = await store.setArchived("a", true); + + expect(result).toEqual({ ok: false, error: "boom" }); + expect(store.list[0]?.archived).toBe(false); + }); + + it("refreshes the active list after a successful archive", async () => { + const http = fakeHttp({ list: [entry({ id: "a", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + const listCallsBefore = http.list.mock.calls.length; + + await store.setArchived("a", true); + + // setArchived triggers a refresh() to reconcile the active-only list. + expect(http.list.mock.calls.length).toBeGreaterThan(listCallsBefore); + }); +}); + +describe("createWorkspaceStore — create (archived-slug gate)", () => { + it("refuses an archived slug with the can't-create message and never calls ensure", async () => { + const http = fakeHttp({ + get: async () => ({ kind: "found", workspace: entry({ id: "old", archived: true }) }), + }); + const store = createWorkspaceStore(http); + + const result = await store.create("old"); + + expect(result).toEqual({ ok: false, error: archivedCreateMessage("old") }); + expect(http.ensure).not.toHaveBeenCalled(); + }); + + it("proceeds for an existing non-archived slug (ensure is called as before)", async () => { + const http = fakeHttp({ + get: async () => ({ kind: "found", workspace: entry({ id: "taken", archived: false }) }), + }); + const store = createWorkspaceStore(http); + + const result = await store.create("taken"); + + expect(result.ok).toBe(true); + expect(http.ensure).toHaveBeenCalledTimes(1); + }); + + it("proceeds for a brand-new slug (404 → missing)", async () => { + const http = fakeHttp({ get: async () => ({ kind: "missing" }) }); + const store = createWorkspaceStore(http); + + const result = await store.create("fresh"); + + expect(result.ok).toBe(true); + expect(http.ensure).toHaveBeenCalledTimes(1); + }); + + it("refuses when the existence check itself failed, without calling ensure (§3.4)", async () => { + const http = fakeHttp({ get: async () => ({ kind: "failed", error: "db down" }) }); + const store = createWorkspaceStore(http); + + const result = await store.create("maybe"); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("Couldn't check whether"); + expect(http.ensure).not.toHaveBeenCalled(); + }); +}); + +describe("createWorkspaceStore — refreshArchived", () => { + it("loads the archived list via list('true') without touching the main list", async () => { + const http = fakeHttp({ list: [entry({ id: "active", archived: false })] }); + const store = createWorkspaceStore(http); + await store.refresh(); + + // Point the archived call at a different payload. + http.list = vi.fn( + async (archived?: "true" | "false" | "all"): Promise<readonly WorkspaceEntry[]> => + archived === "true" + ? [entry({ id: "done", archived: true })] + : [entry({ id: "active", archived: false })], + ); + + await store.refreshArchived(); + + expect(http.list).toHaveBeenCalledWith("true"); + expect(store.archivedList.map((w) => w.id)).toEqual(["done"]); + // The main (active) list is untouched. + expect(store.list.map((w) => w.id)).toEqual(["active"]); + }); +}); diff --git a/src/features/workspaces/tile-layout-store.svelte.ts b/src/features/workspaces/tile-layout-store.svelte.ts new file mode 100644 index 0000000..98a5c33 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.svelte.ts @@ -0,0 +1,308 @@ +import type { + TileLayoutData as ContractTileLayoutData, + TileGeometryMode, +} from "@dispatch/tile-layout-contract"; +import { createLocalStore } from "../../adapters/local-storage"; +import type { TileLayoutHttp } from "./adapter/tile-layout-http"; +import { + type Cell, + type GridGeometry, + mergeLayout, + pinAtFirstEmpty, + placeAtCell, + resizeTile, + sanitizeLayout, + sortByRecentActivity, + type TileLayoutData, + type TileLayoutEntry, +} from "./logic/tile-layout"; + +/** + * Tile-layout store — the synced arrangement of the home grid, ONE shared + * document PER device orientation (`wide` = landscape/desktop, `tall` = + * portrait/mobile), persisted through the INJECTED `TileLayoutHttp` edge + * (the `home-tile-layout` plugin's well-known routes). Mirrors the layout + * feature's store semantics: + * + * - **Orientation selection** — `setMode(mode)` (the home page's effect on + * the resolved grid geometry) is the boot + orientation-change trigger: + * an orientation CHANGE seeds from its cached server layout and probes that + * orientation's document fresh. Same-mode calls are a no-op. The store + * probes its INITIAL mode on creation (the boot probe). + * - **Server-backed** — a successful probe applies the server document; + * edits apply locally IMMEDIATELY, then PUT the whole document (decision 7 + * — no patch protocol) lightly debounced; a failed PUT on a present + * backend rolls back to the last server document with a quiet console + * warning (never an error UI). + * - **WS push** — `onServerTileLayout(mode, data)` applies a `tile-layout` + * broadcast, ignoring the OTHER orientation (the server fans out to all + * clients; the client filters by mode). + * - **Quiet degrade** — any probe/write failure (backend unreachable, routes + * not served) keeps the local state with EPHEMERAL edits (in-memory only — + * only server-confirmed layouts are cached); logged once, no error UI. + * - **Sync gating** — `sync(ids)` no-ops while the first probe for an + * orientation is in flight (`providerState === "unknown"`): merging the + * live workspace list into an unprobed empty document would PUT a wiped + * arrangement. The caller's effect re-runs when the state settles. + * + * No ambient state: one instance per home page, created by the page entry + * (or injected in tests). + */ + +/** Whether the backend is serving the active orientation's document. */ +export type TileLayoutProviderState = "unknown" | "present" | "absent"; + +export interface TileLayoutStore { + /** The persisted entries of the ACTIVE orientation, order-sorted. */ + readonly entries: readonly TileLayoutEntry[]; + /** The active orientation (the home page's resolved grid geometry mode). */ + readonly mode: TileGeometryMode; + /** The backend's serving state for the ACTIVE orientation. */ + readonly providerState: TileLayoutProviderState; + /** + * Select the active orientation and probe its document. Same-mode calls + * are a no-op (the boot probe fires on creation for the initial mode); + * an orientation CHANGE seeds from its cache + probes fresh. + */ + setMode(mode: TileGeometryMode): void; + /** Re-probe the ACTIVE orientation's document (reconnect catch-up). */ + probe(): Promise<void>; + /** Server `tile-layout` broadcast. Other orientations are ignored. */ + onServerTileLayout(mode: TileGeometryMode, data: ContractTileLayoutData): void; + /** + * Merge the live workspace ids into the layout: workspaces unknown to the + * layout append (unpinned — they flow to the first free cells), entries for + * deleted workspaces drop (their cells become gaps). A no-op (no write) + * when the merge changes nothing — so an effect that tracks both the + * workspace list and this store settles instead of looping. + */ + sync(workspaceIds: readonly string[]): void; + /** Resize a tile (persisted spans; render-time clamping is separate). */ + resize(id: string, colSpan: number, rowSpan: number): void; + /** + * Drop a tile at a specific CELL (the drag commit): the contiguous filled + * run pushes forward from that cell (gaps absorb it), every resulting + * position pins. See `placeAtCell`. + */ + placeAtCell(id: string, cell: Cell, geometry: GridGeometry): void; + /** + * Pin (or re-pin) the tile at the FIRST EMPTY slot — the header-create + * flow. Idempotent (see `pinAtFirstEmpty`). + */ + placeFirstEmpty(id: string, geometry: GridGeometry): void; + /** Re-apply the recent-activity order AND clear all pins (the reset). */ + sortByRecentActivity(lastActivityById: ReadonlyMap<string, number>): void; +} + +export interface TileLayoutStoreOptions { + /** The injected HTTP edge (the `home-tile-layout` plugin's routes). */ + readonly http: TileLayoutHttp; + /** Initial viewport orientation (default wide). */ + readonly initialMode?: TileGeometryMode; + /** Injected localStorage for server-layout snapshots; absent → no cache. */ + readonly storage?: Storage | undefined; + /** Edit-PUT debounce window in ms (default 300; tests shrink it). */ + readonly debounceMs?: number; + /** The quiet-failure hook (default `console.warn` — never an error UI). */ + readonly warn?: (message: string) => void; +} + +const DEFAULT_DEBOUNCE_MS = 300; + +export function createTileLayoutStore(opts: TileLayoutStoreOptions): TileLayoutStore { + const http = opts.http; + const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const warn = opts.warn ?? ((message: string) => console.warn(message)); + + const cache = { + wide: createLocalStore<TileLayoutData>("dispatch.tile-layout.wide", { storage: opts.storage }), + tall: createLocalStore<TileLayoutData>("dispatch.tile-layout.tall", { storage: opts.storage }), + }; + const initialMode = opts.initialMode ?? "wide"; + let mode = $state<TileGeometryMode>(initialMode); + // The ACTIVE orientation's document — the server's copy once a probe has + // landed (sanitized defensively; the backend validates strictly), the + // cached server snapshot (or empty default) until then. Never PUT the seed. + let data = $state<TileLayoutData>(sanitizeLayout(cache[initialMode].load())); + let providerState = $state<TileLayoutProviderState>("unknown"); + + /** The last document known to come from the server (GET / WS push / PUT echo) — the rollback target. */ + let lastServerLayout: TileLayoutData | null = null; + /** The pending edit-PUT timer (the "lightly debounced" write). */ + let putTimer: ReturnType<typeof setTimeout> | null = null; + /** Logged-once guard for the degrade log (reset when the backend returns). */ + let loggedAbsent = false; + + function noteAbsent(reason: string): void { + providerState = "absent"; + if (!loggedAbsent) { + loggedAbsent = true; + warn( + `tile-layout: backend not serving the layout (${reason}) — rendering the local default; edits are ephemeral`, + ); + } + } + + function notePresent(): void { + providerState = "present"; + loggedAbsent = false; + } + + function applyServerLayout(next: TileLayoutData): void { + lastServerLayout = next; + data = next; + cache[mode].save(next); + } + + function clearPutTimer(): void { + if (putTimer !== null) { + clearTimeout(putTimer); + putTimer = null; + } + } + + async function flushPut(): Promise<void> { + putTimer = null; + // Read the CURRENT document at fire time: a newer edit supersedes the + // debounced snapshot (one in-flight write per window, last wins). + const writtenMode = mode; + const result = await http.putLayout(writtenMode, { version: 2, tiles: data.tiles }); + if (result.ok) cache[writtenMode].save(sanitizeLayout(result.layout)); + if (writtenMode !== mode) return; + if (result.ok) { + notePresent(); + // The server's validated echo is the new rollback target. + lastServerLayout = sanitizeLayout(result.layout); + return; + } + if (providerState !== "present") { + // The backend is gone (or never was): keep the local edit (ephemeral), + // no rollback — there is no server document to roll back TO. + noteAbsent(result.error); + return; + } + // A failed write on a PRESENT backend: roll back to the last server + // document, quiet console warning (never an error UI). + warn( + `tile-layout: persisting the layout failed (${result.error}) — rolled back to the last server document`, + ); + if (lastServerLayout !== null) data = lastServerLayout; + } + + async function runProbe(): Promise<void> { + // Capture the mode AT START: a setMode may happen while the fetch is in + // flight — only the CURRENT mode's result may land (the reset in + // setMode already staged the fresh default; a stale response must never + // overwrite it). + const probed = mode; + const result = await http.getLayout(mode); + if (probed !== mode) return; + if (result.ok) { + notePresent(); + applyServerLayout(sanitizeLayout(result.layout)); + } else { + // A real failure (5xx, 404 = routes not served, malformed, network) + // degrades quietly — the local default keeps rendering. + noteAbsent(result.error); + } + } + + function commit(tiles: readonly TileLayoutEntry[]): void { + // A pure op that changed nothing (e.g. a no-op place) doesn't write. + if (sameEntries(tiles, data.tiles)) return; + data = { version: 2, tiles }; + if (providerState === "unknown") return; // first probe still in flight + if (providerState === "absent") return; // ephemeral — no write, no warning + clearPutTimer(); + putTimer = setTimeout(() => { + void flushPut().catch(() => { + // Network-grade failure: roll back to the last server document, quietly. + warn("tile-layout: persisting the layout failed — rolled back to the last server document"); + if (lastServerLayout !== null) data = lastServerLayout; + }); + }, debounceMs); + } + + // Boot probe for the initial mode — the home page's setMode effect either + // confirms it (no-op) or switches it (fresh probe). + void runProbe(); + + return { + get entries(): readonly TileLayoutEntry[] { + return data.tiles; + }, + get mode(): TileGeometryMode { + return mode; + }, + get providerState(): TileLayoutProviderState { + return providerState; + }, + + setMode(next) { + if (next === mode) return; + mode = next; + // The document belongs to the OLD orientation now: seed from this + // mode's cache while the probe resolves (never flash the other + // orientation's arrangement), and drop any queued edit. + clearPutTimer(); + lastServerLayout = null; + data = sanitizeLayout(cache[mode].load()); + providerState = "unknown"; + void runProbe(); + }, + + async probe() { + await runProbe(); + }, + + onServerTileLayout(pushMode, next) { + if (pushMode !== mode) return; // clients filter by orientation + notePresent(); + clearPutTimer(); // the server's copy supersedes any queued edit + applyServerLayout(sanitizeLayout(next)); + }, + + sync(workspaceIds): void { + // Never merge into an UNPROBED empty document: before the first probe + // resolves, "empty" is our placeholder, not a real arrangement — and + // merging live ids into it could PUT a wiped layout. The caller's + // effect re-runs when the state settles (it tracks providerState). + if (providerState === "unknown") return; + const merged = mergeLayout(data, workspaceIds); + if (!sameEntries(merged, data.tiles)) commit(merged); + }, + + resize(id, colSpan, rowSpan): void { + commit(resizeTile(data.tiles, id, colSpan, rowSpan)); + }, + + placeAtCell(id, cell, geometry): void { + commit(placeAtCell(data.tiles, id, cell, geometry)); + }, + + placeFirstEmpty(id, geometry): void { + commit(pinAtFirstEmpty(data.tiles, id, geometry)); + }, + + sortByRecentActivity(lastActivityById): void { + commit(sortByRecentActivity(data.tiles, lastActivityById)); + }, + }; +} + +/** Structural equality over entries (ids, spans, orders, anchors). */ +function sameEntries(a: readonly TileLayoutEntry[], b: readonly TileLayoutEntry[]): boolean { + if (a.length !== b.length) return false; + return a.every((entry, index) => { + const other = b[index]; + return ( + other !== undefined && + entry.id === other.id && + entry.colSpan === other.colSpan && + entry.rowSpan === other.rowSpan && + entry.order === other.order && + entry.col === other.col && + entry.row === other.row + ); + }); +} diff --git a/src/features/workspaces/tile-layout-store.test.ts b/src/features/workspaces/tile-layout-store.test.ts new file mode 100644 index 0000000..b5af8b6 --- /dev/null +++ b/src/features/workspaces/tile-layout-store.test.ts @@ -0,0 +1,449 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "./adapter/tile-layout-http"; +import { geometryFor, type TileLayoutEntry } from "./logic/tile-layout"; +import { createTileLayoutStore } from "./tile-layout-store.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function tile(id: string, order: number, colSpan = 1, rowSpan = 1): TileLayoutEntry { + return { id, order, colSpan, rowSpan }; +} + +function doc(...tiles: TileLayoutEntry[]): TileLayoutData { + return { version: 2, tiles }; +} + +/** A controllable fake HTTP edge: per-mode documents + a call log (no mocks of our modules). */ +function fakeHttp( + initial: Partial<Record<TileGeometryMode, TileLayoutData>> = {}, +): TileLayoutHttp & { + docs: Map<TileGeometryMode, TileLayoutData>; + puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }>; + failNext: { get: string | null; put: string | null }; +} { + const docs = new Map<TileGeometryMode, TileLayoutData>( + Object.entries(initial) as Array<[TileGeometryMode, TileLayoutData]>, + ); + const puts: Array<{ mode: TileGeometryMode; layout: TileLayoutData }> = []; + const failNext = { get: null as string | null, put: null as string | null }; + const ok = (layout: TileLayoutData): TileLayoutHttpResult => ({ ok: true, layout }); + return { + docs, + puts, + failNext, + async getLayout(mode) { + if (failNext.get !== null) { + const error = failNext.get; + failNext.get = null; + return { ok: false, notFound: false, error }; + } + return ok(docs.get(mode) ?? doc()); + }, + async putLayout(mode, layout) { + if (failNext.put !== null) { + const error = failNext.put; + failNext.put = null; + return { ok: false, notFound: false, error }; + } + puts.push({ mode, layout }); + docs.set(mode, layout); + return ok(layout); + }, + async deleteLayout(mode) { + docs.set(mode, doc()); + return ok(docs.get(mode) as TileLayoutData); + }, + }; +} + +/** Flush the boot probe + debounced timers (microtasks first, then the clock). */ +async function settle(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(300); + await Promise.resolve(); +} + +async function flushOnly(): Promise<void> { + await vi.advanceTimersByTimeAsync(0); + await Promise.resolve(); +} + +describe("createTileLayoutStore", () => { + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + }); + afterEach(() => { + vi.useRealTimers(); + localStorage.clear(); + }); + + it("the boot probe applies the server document for the initial mode", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 2, 2)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([tile("a", 0, 2, 2)]); + expect(store.providerState).toBe("present"); + expect(store.mode).toBe("wide"); + }); + + it("honors the initial tall mode with exactly one probe and no same-mode reset", async () => { + const http = fakeHttp({ tall: doc(tile("mobile", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, initialMode: "tall" }); + store.setMode("tall"); + await flushOnly(); + expect(get.mock.calls).toEqual([["tall"]]); + expect(store.mode).toBe("tall"); + expect(store.entries).toEqual([tile("mobile", 0)]); + }); + + it("seeds from cache immediately without PUT or caching edits before revalidation", async () => { + const cached = doc(tile("cached", 0, 2, 2)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.getLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, initialMode: "tall", storage: localStorage }); + expect(store.entries).toEqual(cached.tiles); + expect(store.providerState).toBe("unknown"); + + store.sync(["cached", "new"]); + store.resize("cached", 1, 1); + await settle(); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(cached); + + const fresh = doc(tile("server", 0)); + gate.release?.({ ok: true, layout: fresh }); + await settle(); + expect(store.entries).toEqual(fresh.tiles); + expect(store.providerState).toBe("present"); + expect(http.puts).toEqual([]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual(fresh); + }); + + it("resets to the new mode's cache and re-probes on orientation change", async () => { + const cached = doc(tile("cached-mobile", 0)); + localStorage.setItem("dispatch.tile-layout.tall", JSON.stringify(cached)); + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + const get = vi.spyOn(http, "getLayout"); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + + store.setMode("tall"); + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual(cached.tiles); + await flushOnly(); + expect(store.entries).toEqual([]); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + + it("caches server GET, WS push, and successful PUT echoes, never unconfirmed edits", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, storage: localStorage }); + const cached = () => JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null"); + await flushOnly(); + expect(cached()).toEqual(doc(tile("a", 0))); + + store.onServerTileLayout("tall", doc(tile("ignored", 0))); + expect(localStorage.getItem("dispatch.tile-layout.tall")).toBeNull(); + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(cached()).toEqual(doc(tile("push", 0))); + + store.resize("push", 2, 2); + expect(cached()).toEqual(doc(tile("push", 0))); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + + http.failNext.put = "network down"; + store.resize("push", 1, 1); + await settle(); + expect(cached()).toEqual(doc(tile("push", 0, 2, 2))); + }); + + it("caches an in-flight PUT echo under its written mode after an orientation change", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)), tall: doc(tile("mobile", 0)) }); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + http.putLayout = () => + new Promise((resolve) => { + gate.release = resolve; + }); + const store = createTileLayoutStore({ http, storage: localStorage }); + await flushOnly(); + store.resize("desktop", 2, 2); + await settle(); + store.setMode("tall"); + await flushOnly(); + gate.release?.({ ok: true, layout: doc(tile("desktop", 0, 2, 2)) }); + await flushOnly(); + expect(store.entries).toEqual([tile("mobile", 0)]); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.tall") ?? "null")).toEqual( + doc(tile("mobile", 0)), + ); + expect(JSON.parse(localStorage.getItem("dispatch.tile-layout.wide") ?? "null")).toEqual( + doc(tile("desktop", 0, 2, 2)), + ); + }); + + it.each([ + "invalid JSON", + '{"version":2,"tiles":"bad"}', + ])("ignores malformed cache (%s) and supports storageless contexts", (raw) => { + localStorage.setItem("dispatch.tile-layout.wide", raw); + const store = createTileLayoutStore({ http: fakeHttp(), storage: localStorage }); + expect(store.entries).toEqual([]); + expect(createTileLayoutStore({ http: fakeHttp() }).entries).toEqual([]); + }); + + it("sanitizes the server document defensively (a junk shape degrades to empty)", async () => { + const http = fakeHttp(); + http.docs.set("wide", { junk: true } as unknown as TileLayoutData); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries).toEqual([]); + }); + + it("a probe failure degrades to absent (ephemeral edits, no PUT)", async () => { + const warn = vi.fn(); + const http = fakeHttp(); + http.failNext.get = "network down"; + const store = createTileLayoutStore({ http, warn }); + await flushOnly(); + expect(store.providerState).toBe("absent"); + + store.resize("a", 3, 3); // applied locally… + store.sync(["a"]); + await settle(); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); + expect(http.puts).toHaveLength(0); // …but never written + expect(warn).toHaveBeenCalledTimes(1); // logged ONCE, no error UI + }); + + it("setMode switches orientation: reset + fresh probe (documents are independent)", async () => { + const http = fakeHttp({ + wide: doc(tile("desktop", 0)), + tall: doc(tile("mobile", 0)), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + + store.setMode("tall"); + expect(store.entries).toEqual([]); + expect(store.providerState).toBe("unknown"); + await flushOnly(); + expect(store.mode).toBe("tall"); + expect(store.entries.map((t) => t.id)).toEqual(["mobile"]); + + store.setMode("tall"); // same-mode call: a no-op + store.setMode("wide"); + await flushOnly(); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("an in-flight probe for a stale mode never lands", async () => { + const http = fakeHttp({ wide: doc(tile("desktop", 0)) }); + // A holder object defeats TS's closure narrowing (a bare `let` read after + // the callback-assigned write narrows to `null` and the call sites to + // `never`). + const gate: { release: ((value: TileLayoutHttpResult) => void) | null } = { release: null }; + const slow = http.getLayout.bind(http); + http.getLayout = (mode) => + mode === "tall" + ? new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }) + : slow(mode); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.setMode("tall"); // probe in flight… + store.setMode("wide"); // …superseded before it resolves + gate.release?.({ ok: true, layout: doc(tile("mobile", 0)) }); + await flushOnly(); + + // The stale tall response must NOT overwrite the active wide document. + expect(store.mode).toBe("wide"); + expect(store.entries.map((t) => t.id)).toEqual(["desktop"]); + }); + + it("a WS push applies for the ACTIVE mode and is IGNORED for the other", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.onServerTileLayout("tall", doc(tile("mobile-push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["a"]); // filtered + + store.onServerTileLayout("wide", doc(tile("push", 0))); + expect(store.entries.map((t) => t.id)).toEqual(["push"]); // applied + }); + + it("edits apply locally IMMEDIATELY, then PUT the whole document (debounced)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); + expect(store.entries).toEqual([tile("a", 0, 4, 2)]); // local, no wait + expect(http.puts).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(300); + expect(http.puts).toEqual([{ mode: "wide", layout: doc(tile("a", 0, 4, 2)) }]); + }); + + it("edits ride the ACTIVE mode's document (the tall PUT names tall)", async () => { + const http = fakeHttp({ tall: doc(tile("m", 0)) }); + const store = createTileLayoutStore({ http }); + store.setMode("tall"); + await flushOnly(); + + store.resize("m", 2, 2); + await settle(); + expect(http.puts).toEqual([{ mode: "tall", layout: doc(tile("m", 0, 2, 2)) }]); + }); + + it("a failed PUT on a present backend rolls back to the last server document", async () => { + const warn = vi.fn(); + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, warn, debounceMs: 300 }); + await flushOnly(); + + http.failNext.put = "500 gone"; + store.resize("a", 4, 2); + await settle(); + + expect(store.entries).toEqual([tile("a", 0)]); // rolled back + expect(warn).toHaveBeenCalledTimes(1); + }); + + it("sync merges the live workspace list (gated while the first probe is in flight)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + // NO flush — the boot probe is still in flight (providerState unknown). + + store.sync(["a", "b"]); // would merge into the empty placeholder… + expect(store.providerState).toBe("unknown"); + expect(store.entries).toEqual([]); // …so it is a no-op until the probe settles + + await flushOnly(); + store.sync(["a", "b"]); + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("sync appends unknown ids, drops deleted ones, and persists once", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("gone", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a", "b"]); + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["a", "b"]); + // The max order counts KEPT entries (the dropped one doesn't reserve a slot). + expect(http.puts.at(-1)?.layout).toEqual( + doc(tile("a", 0), { id: "b", colSpan: 1, rowSpan: 1, order: 1 }), + ); + }); + + it("sync is a no-op (no write) when the merge changes nothing", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sync(["a"]); + store.sync(["a"]); + await settle(); + + expect(http.puts).toHaveLength(0); + }); + + it("placeAtCell commits the displacement and pins EVERY tile (persisted to the server)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + // a@(1,1), b@(1,2) → drag a to (2,1): a pins there, b keeps its cell. + store.placeAtCell("a", { col: 2, row: 1 }, WIDE); + await settle(); + + const entries = store.entries; + expect(entries.find((t) => t.id === "a")).toMatchObject({ col: 2, row: 1 }); + expect(entries.find((t) => t.id === "b")).toMatchObject({ col: 1, row: 2 }); + expect(http.puts.at(-1)?.layout.version).toBe(2); + }); + + it("placeAtCell with an unknown id is a no-op (no write)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeAtCell("nope", { col: 1, row: 1 }, WIDE); + await settle(); + + expect(http.puts).toHaveLength(0); + expect(store.entries).toEqual([tile("a", 0)]); + }); + + it("placeFirstEmpty PINS the new id at the first empty slot (idempotent on re-call)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0, 1, 2), tile("b", 1)) }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.placeFirstEmpty("new", WIDE); + await settle(); + + // a packs rows 1-2, b row 3 → the first empty slot is (col1,row4) → pinned. + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + expect(http.puts.at(-1)?.layout.tiles.map((t) => t.id)).toEqual(["a", "b", "new"]); + + store.placeFirstEmpty("new", WIDE); // the create flow's re-call must be stable + expect(store.entries.find((t) => t.id === "new")).toMatchObject({ col: 1, row: 4 }); + }); + + it("sortByRecentActivity re-applies recent-activity order and CLEARs pins", async () => { + const http = fakeHttp({ + wide: doc({ ...tile("a", 0), col: 1, row: 1 }, { ...tile("b", 1), col: 2, row: 3 }), + }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + store.sortByRecentActivity( + new Map([ + ["a", 10], + ["b", 99], + ]), + ); + + expect(store.entries.map((t) => t.id)).toEqual(["b", "a"]); + expect(store.entries.every((t) => t.col === undefined && t.row === undefined)).toBe(true); + }); + + it("a WS push supersedes a queued edit (the server's copy wins)", async () => { + const http = fakeHttp({ wide: doc(tile("a", 0)) }); + const store = createTileLayoutStore({ http, debounceMs: 300 }); + await flushOnly(); + + store.resize("a", 4, 2); // edit queued… + store.onServerTileLayout("wide", doc(tile("server-wins", 0))); // …server lands first + await settle(); + + expect(store.entries.map((t) => t.id)).toEqual(["server-wins"]); + expect(http.puts).toHaveLength(0); // the queued edit was dropped, not PUT + }); + + it("probe() re-reads the active orientation (the reconnect catch-up)", async () => { + const http = fakeHttp({ wide: doc() }); + const store = createTileLayoutStore({ http }); + await flushOnly(); + + http.docs.set("wide", doc(tile("fresh", 0))); + await store.probe(); + expect(store.entries.map((t) => t.id)).toEqual(["fresh"]); + }); +}); diff --git a/src/features/workspaces/ui/ArchivedWorkspaces.svelte b/src/features/workspaces/ui/ArchivedWorkspaces.svelte new file mode 100644 index 0000000..584fd68 --- /dev/null +++ b/src/features/workspaces/ui/ArchivedWorkspaces.svelte @@ -0,0 +1,119 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + + let { + store, + onOpen, + onClose, + }: { + store: WorkspaceStore; + /** Open (view) a workspace — navigates to `/<id>`. */ + onOpen: (id: string) => void; + onClose: () => void; + } = $props(); + + // Load the archived list when the modal mounts. + $effect(() => { + void store.refreshArchived(); + }); + + let busyId = $state<string | null>(null); + let actionError = $state<string | null>(null); + + async function unarchive(ws: WorkspaceEntry): Promise<void> { + if (busyId !== null) return; + busyId = ws.id; + actionError = null; + const result = await store.setArchived(ws.id, false); + busyId = null; + if (!result.ok) { + actionError = result.error; + return; + } + // Refresh the archived list so the unarchived workspace leaves it. + void store.refreshArchived(); + } + + function open(ws: WorkspaceEntry): void { + onOpen(ws.id); + onClose(); + } +</script> + +<div class="modal modal-open" role="dialog" aria-label="Archived workspaces"> + <div class="modal-box max-w-lg"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">Archived workspaces</h2> + <p class="text-xs opacity-50">Hidden from the home grid. Unarchive to restore.</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close archived workspaces" + onclick={onClose}>✕</button + > + </div> + + {#if actionError} + <p class="mt-3 text-xs text-error">{actionError}</p> + {/if} + + <div class="mt-4"> + {#if store.archivedLoading && store.archivedList.length === 0} + <div class="flex justify-center py-8"> + <span class="loading loading-spinner loading-md opacity-60"></span> + </div> + {:else if store.archivedList.length === 0} + <p class="py-8 text-center text-sm opacity-50">No archived workspaces.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each store.archivedList as ws (ws.id)} + <li + class="flex items-center justify-between gap-3 rounded-box border border-base-content/10 bg-base-200/40 px-3 py-2" + > + <div class="min-w-0 flex-1"> + <p class="truncate font-semibold">{ws.title}</p> + <p class="font-mono text-xs opacity-50"> + /{ws.id} · {ws.conversationCount} conv · {relativeTime( + ws.lastActivityAt, + Date.now(), + )} ago + </p> + </div> + <div class="flex shrink-0 items-center gap-1"> + <button + type="button" + class="btn btn-ghost btn-xs" + onclick={() => open(ws)} + aria-label={`Open ${ws.title}`}>View</button + > + <button + type="button" + class="btn btn-outline btn-xs" + disabled={busyId === ws.id} + onclick={() => void unarchive(ws)} + aria-label={`Unarchive ${ws.title}`} + > + {#if busyId === ws.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Unarchive + {/if} + </button> + </div> + </li> + {/each} + </ul> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close archived workspaces backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/HomeHeader.svelte b/src/features/workspaces/ui/HomeHeader.svelte new file mode 100644 index 0000000..5329b2d --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.svelte @@ -0,0 +1,198 @@ +<script lang="ts"> + import ArchiveIcon from "phosphor-svelte/lib/ArchiveIcon"; + import DotsThreeIcon from "phosphor-svelte/lib/DotsThreeIcon"; + import PlusIcon from "phosphor-svelte/lib/PlusIcon"; + import PuzzlePieceIcon from "phosphor-svelte/lib/PuzzlePieceIcon"; + import { isValidSlug } from "../logic/route"; + + /** The create outcome the page reports back to the popover. */ + export type CreateResult = { readonly ok: true } | { readonly ok: false; readonly error: string }; + + let { + compact = false, + withPlugins = false, + withSort = false, + onCreate, + onOpenPlugins, + onSort, + onOpenArchived, + }: { + /** Tall geometry: the controls collapse to compact icon buttons and the + * new-workspace popover becomes a bottom sheet. */ + compact?: boolean; + /** The plugins port is present → the Plugins button renders. */ + withPlugins?: boolean; + /** The Sort overflow action renders (recent-activity re-apply). */ + withSort?: boolean; + /** Create the workspace on the backend (does NOT navigate); the page + * places the new tile + enters rename mode. */ + onCreate: (slug: string) => Promise<CreateResult>; + onOpenPlugins: () => void; + onSort: () => void; + /** Open the archived-workspaces view. */ + onOpenArchived: () => void; + } = $props(); + + // ── New workspace popover (today's inline form logic, preserved) ────────── + let newSlug = $state(""); + let slugError = $state<string | null>(null); + let popoverOpen = $state(false); + let submitting = $state(false); + let slugInput = $state<HTMLInputElement | undefined>(); + + const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + + function openPopover(): void { + popoverOpen = true; + slugError = null; + queueMicrotask(() => slugInput?.focus()); + } + + function closePopover(): void { + popoverOpen = false; + newSlug = ""; + slugError = null; + } + + async function submit(): Promise<void> { + const slug = newSlug.trim(); + if (!isValidSlug(slug)) { + slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + return; + } + slugError = null; + submitting = true; + const result = await onCreate(slug); + submitting = false; + if (!result.ok) { + slugError = result.error; + return; + } + closePopover(); + } +</script> + +<!-- `.app-navbar` (app.css): the same Artix-wiki navbar look as the workspace + top bar — gradient, drop shadow, engraved primary-blue title. --> +<header class="app-navbar flex shrink-0 items-end justify-between gap-2 px-8 pt-6 pb-4 md:px-12"> + <div class="flex items-baseline gap-3"> + <h1 class="app-navbar-engraved text-4xl font-black tracking-tight text-primary">Dispatch</h1> + <span class="hidden text-sm font-medium tracking-widest uppercase text-primary/70 sm:inline"> + Workspaces + </span> + </div> + + <!-- Navbar actions (app.css): "New workspace" is the one raised button + (`.navbar-btn`, primary-tinted); the secondary actions are plain text + links (`.navbar-link`) — the light-blue colour + spacing say clickable + without button chrome. Compact: labels collapse to Phosphor icons. --> + <div class="flex items-center gap-1" class:gap-3={!compact}> + <!-- New workspace --> + <button + type="button" + class="navbar-btn navbar-btn-primary" + class:navbar-btn-icon={compact} + aria-label="New workspace" + title="New workspace" + onclick={() => (popoverOpen ? closePopover() : openPopover())} + > + <PlusIcon size={16} weight="bold" aria-hidden="true" /> + {#if !compact}<span>New workspace</span>{/if} + </button> + + {#if withPlugins} + <button + type="button" + class="navbar-link" + aria-label="Plugins" + title="Open the plugin manager (load/unload plugins globally)" + onclick={onOpenPlugins} + > + {#if compact}<PuzzlePieceIcon size={18} aria-hidden="true" />{:else}<span>Plugins</span>{/if} + </button> + {/if} + + <!-- Archived workspaces --> + <button + type="button" + class="navbar-link" + aria-label="Archived workspaces" + title="View archived workspaces" + onclick={onOpenArchived} + > + {#if compact}<ArchiveIcon size={18} aria-hidden="true" />{:else}<span>Archived</span>{/if} + </button> + + {#if withSort} + <!-- Overflow last, like a navbar's trailing menu. --> + <div class="dropdown dropdown-end"> + <button + type="button" + class="navbar-link" + aria-label="More actions" + title="More" + > + <DotsThreeIcon size={20} weight="bold" aria-hidden="true" /> + </button> + <ul class="dropdown-content menu z-50 mt-1 w-52 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-xl"> + <li> + <button type="button" onclick={onSort}>Reset arrangement by recent activity</button> + </li> + </ul> + </div> + {/if} + </div> +</header> + +{#if popoverOpen} + <!-- Backdrop closes the popover. --> + <button + type="button" + class="fixed inset-0 z-40 cursor-default" + aria-label="Close new workspace" + onclick={closePopover}></button> + + <!-- One popover body; the geometry only changes its positioning (bottom + sheet on tall screens, a corner card on wide ones). --> + <div + class="fixed z-50 border border-base-content/15 bg-base-100 p-4 shadow-2xl" + class:inset-x-0={compact} + class:bottom-0={compact} + class:rounded-t-box={compact} + class:top-14={!compact} + class:right-6={!compact} + class:w-80={!compact} + class:rounded-box={!compact} + > + <form + class="flex flex-col gap-2" + onsubmit={(e) => { + e.preventDefault(); + void submit(); + }} + > + <label for="new-ws" class="text-xs font-semibold uppercase opacity-60">New workspace</label> + <input + id="new-ws" + bind:this={slugInput} + class="input input-bordered w-full" + placeholder="my-workspace" + bind:value={newSlug} + autocomplete="off" + spellcheck="false" + /> + {#if slugError}<p class="text-xs text-error">{slugError}</p>{/if} + <div class="flex justify-end gap-2"> + {#if compact} + <button type="button" class="btn btn-ghost btn-sm" onclick={closePopover}>Cancel</button> + {/if} + <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid || submitting}> + {#if submitting} + <span class="loading loading-spinner loading-xs"></span> + {/if} + Create + </button> + </div> + </form> + </div> +{/if} diff --git a/src/features/workspaces/ui/HomeHeader.test.ts b/src/features/workspaces/ui/HomeHeader.test.ts new file mode 100644 index 0000000..4db8b04 --- /dev/null +++ b/src/features/workspaces/ui/HomeHeader.test.ts @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import HomeHeader from "./HomeHeader.svelte"; + +function props() { + return { + onCreate: vi.fn(async (_slug: string): Promise<{ ok: true }> => ({ ok: true })), + onOpenPlugins: vi.fn(), + onSort: vi.fn(), + onOpenArchived: vi.fn(), + }; +} + +describe("HomeHeader", () => { + it("renders the display title and the primary New workspace button", () => { + const p = props(); + render(HomeHeader, { props: p }); + expect(screen.getByRole("heading", { name: "Dispatch" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "New workspace" })).toBeInTheDocument(); + }); + + it("the Plugins button renders only with the plugins port", () => { + const p = props(); + const without = render(HomeHeader, { props: p }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(HomeHeader, { props: { ...p, withPlugins: true } }); + screen.getByRole("button", { name: "Plugins" }).click(); + expect(p.onOpenPlugins).toHaveBeenCalledTimes(1); + }); + + it("the Sort overflow renders only when asked, and dispatches", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: { ...p, withSort: true } }); + + await user.click(screen.getByRole("button", { name: "More actions" })); + await user.click(screen.getByRole("button", { name: "Reset arrangement by recent activity" })); + expect(p.onSort).toHaveBeenCalledTimes(1); + }); + + it("the Create button disables for an invalid slug (validation before submit)", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + const input = screen.getByRole("textbox", { name: "New workspace" }); + await user.type(input, "Invalid Slug!"); + expect(screen.getByRole("button", { name: "Create" })).toBeDisabled(); + expect(p.onCreate).not.toHaveBeenCalled(); + + // A valid slug enables it. + await user.clear(input); + await user.type(input, "my-workspace"); + expect(screen.getByRole("button", { name: "Create" })).toBeEnabled(); + }); + + it("submitting the form with Enter creates via the callback and closes the popover", async () => { + const user = userEvent.setup(); + const p = props(); + render(HomeHeader, { props: p }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace{Enter}"); + + expect(p.onCreate).toHaveBeenCalledWith("my-workspace"); + }); + + it("a backend error keeps the form open with the message", async () => { + const user = userEvent.setup(); + const onCreate = vi.fn( + async (): Promise<{ ok: false; error: string }> => ({ ok: false, error: "taken" }), + ); + render(HomeHeader, { props: { ...props(), onCreate } }); + + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "my-workspace"); + await user.click(screen.getByRole("button", { name: "Create" })); + + expect(await screen.findByText("taken")).toBeInTheDocument(); + }); + + it("compact mode collapses the primary button to an icon", () => { + const p = props(); + render(HomeHeader, { props: { ...p, compact: true } }); + const button = screen.getByRole("button", { name: "New workspace" }); + expect(button).toHaveClass("navbar-btn-icon"); + expect(button).toHaveTextContent(""); // icon only — the label collapses + expect(button.querySelector("svg")).not.toBeNull(); + }); +}); diff --git a/src/features/workspaces/ui/HomeTileGrid.svelte b/src/features/workspaces/ui/HomeTileGrid.svelte new file mode 100644 index 0000000..40a46c5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.svelte @@ -0,0 +1,568 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import { gsap } from "gsap"; + import { Flip } from "gsap/Flip"; + import { tick } from "svelte"; + import { nextDragPhase, type DragEvent, type DragPhase } from "../logic/drag-controller"; + import { + displacePlace, + dragAppendLimit, + type Cell, + type GridGeometry, + type Placement, + type Span, + } from "../logic/tile-layout"; + import { tileTone, type TileTone } from "../logic/view-model"; + import type { WorkspaceStore } from "../store.svelte"; + import WorkspaceTile from "./WorkspaceTile.svelte"; + + gsap.registerPlugin(Flip); + + let { + geometry, + placements, + workspacesById, + store, + selectedId = null, + renamingId = null, + preview = null, + hasActive, + onOpen, + onSelect, + onPlace, + onRenameEnd, + }: { + geometry: GridGeometry; + /** Packed placements of the COMMITTED layout (the drag preview derives). */ + placements: readonly Placement[]; + workspacesById: ReadonlyMap<string, WorkspaceEntry>; + /** Forwarded to tiles for the inline rename (semantics preserved). */ + store: WorkspaceStore; + selectedId?: string | null; + renamingId?: string | null; + /** The resize picker's transient ghost footprint for this tile. */ + preview?: { readonly id: string; readonly colSpan: number; readonly rowSpan: number } | null; + /** Optional port: does the workspace have an active (generating) conversation. */ + hasActive?: (workspaceId: string) => boolean; + onOpen: (id: string) => void; + onSelect: (id: string) => void; + /** The drag dropped `id` at `cell` (null = the pointer never resolved to a cell). */ + onPlace: (id: string, cell: Cell | null) => void; + onRenameEnd: () => void; + } = $props(); + + // ── Drag/press state machine (pure logic, wired to pointer events) ──────── + const DRAG_THRESHOLD_PX = 6; + const LONG_PRESS_MS = 500; + let phase = $state<DragPhase>({ kind: "idle" }); + let longPressTimer: ReturnType<typeof setTimeout> | undefined; + /** Pointer position where the current press began (the follow transform base). */ + let pressOrigin = $state<{ x: number; y: number } | null>(null); + /** Where within the pressed tile the pointer grabbed (its follow-transform base). */ + let grabOffset = $state<{ x: number; y: number } | null>(null); + /** The tile mid-settle after a drop (excluded from FLIP until it lands). */ + let settling = $state<string | null>(null); + + function armLongPress(): void { + disarmLongPress(); + longPressTimer = setTimeout(() => { + longPressTimer = undefined; + dispatch({ type: "long-press" }); + }, LONG_PRESS_MS); + } + + function disarmLongPress(): void { + if (longPressTimer !== undefined) { + clearTimeout(longPressTimer); + longPressTimer = undefined; + } + } + + function dispatch(event: DragEvent): void { + const wasDragging = phase.kind === "dragging" ? phase.id : null; + const transition = nextDragPhase(phase, event, { dragThresholdPx: DRAG_THRESHOLD_PX }); + phase = transition.phase; + const emit = transition.emit; + if (emit) { + if (emit.kind === "tap") onOpen(emit.id); + else if (emit.kind === "lift") onSelect(emit.id); + else if (emit.kind === "drop") drop(emit.id, emit.targetCell); + } + if (phase.kind === "idle") { + // The gesture ended (tap/drop/cancel). A DROP settles via `drop` above; + // a CANCEL mid-drag flies the tile back to its committed slot. + if (wasDragging && emit?.kind !== "drop") flyBack(wasDragging); + pressOrigin = null; + grabOffset = null; + } + // Keep the long-press armed exactly while a TOUCH press is undecided. + if (phase.kind === "pressing" && phase.touch) armLongPress(); + else disarmLongPress(); + } + + /** The tile being dragged (or settling after a drop) follows no FLIP. */ + const excludedFromFlip = $derived( + new Set( + [phase.kind === "dragging" ? phase.id : null, settling].filter( + (id): id is string => id !== null, + ), + ), + ); + + /** Commit the drop, then settle the tile from the pointer onto its new cell. */ + function drop(id: string, cell: Cell | null): void { + if (cell !== null) onPlace(id, cell); + flyBack(id); + } + + /** + * Animate the tile's follow transform to identity at its (possibly new) + * committed slot — the drop settle AND the cancel/Escape fly-back. The tile + * is excluded from Flip until the animation completes so the two never + * fight over the same transform. + */ + function flyBack(id: string): void { + const el = tileEls.get(id); + if (!el) return; + settling = id; + void tick().then(() => { + gsap.to(el, { + x: 0, + y: 0, + scale: 1, + duration: 0.2, + ease: "power2.out", + onComplete: () => { + gsap.set(el, { clearProps: "transform" }); + settling = null; + }, + }); + }); + } + + /** Interactive targets inside a tile (the rename input) own their gestures. */ + function insideInteractive(target: EventTarget | null): boolean { + const el = target instanceof HTMLElement ? target : null; + return el?.closest("input, select, textarea, button, a") !== null; + } + + /** + * Drag gestures live on the GRID CONTAINER, not the tiles: the container is + * never re-parented, never toggles `pointer-events`, and never unmounts, so + * pointer capture set here survives the whole gesture. (A captured element + * whose DOM node MOVES loses its capture — the "stuck tile" bug — and an + * uncaptured pointer can't stream events over empty cells — the empty-cell + * bug.) Capture also keeps move/up flowing when the pointer leaves the grid. + */ + function gridPointerDown(e: PointerEvent): void { + if (e.button !== 0) return; + const tileEl = (e.target as Element | null)?.closest("[data-tile-id]"); + if (!tileEl || insideInteractive(e.target)) return; + const id = tileEl.getAttribute("data-tile-id"); + if (id === null) return; + // Capture mouse/pen so fast drags keep streaming to this container. TOUCH + // is purposely NOT captured here: an early touch capture can suppress + // native scrolling (WebKit), and touch already gets the browser's implicit + // capture — after the long-press lift the moved events bubble up to this + // container anyway. + if (e.pointerType !== "touch") gridEl?.setPointerCapture?.(e.pointerId); + // Where within the tile the pointer grabbed — the follow transform keeps + // THIS point under the pointer (not the tile's top-left origin). + const rect = tileEl.getBoundingClientRect(); + grabOffset = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + pressOrigin = { x: e.clientX, y: e.clientY }; + dispatch({ + type: "pointer-down", + id, + x: e.clientX, + y: e.clientY, + touch: e.pointerType === "touch", + }); + } + + function gridPointerMove(e: PointerEvent): void { + if (phase.kind === "idle") return; + dispatch({ + type: "pointer-move", + x: e.clientX, + y: e.clientY, + targetCell: cellAt(e.clientX, e.clientY), + }); + } + + function gridPointerUp(): void { + if (phase.kind === "idle") return; + dispatch({ type: "pointer-up" }); + } + + // Escape mid-drag: cancel BEFORE the page-level Escape unwinds the + // selection — the committed layout is untouched and the tile flies home. + function windowKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape" || phase.kind === "idle") return; + e.stopPropagation(); + dispatch({ type: "cancel" }); + } + + // ── Cell hit math (pointer → grid cell; empty cells are drop targets) ───── + let gridEl = $state<HTMLDivElement | undefined>(); + + const pitchX = $derived(geometry.unit.width + geometry.unit.gap); + const pitchY = $derived(geometry.unit.height + geometry.unit.gap); + + /** + * The drag target's clamp on the growth axis — at most 3 columns (wide) / + * rows (tall) past the current content edge. Measured on the COMMITTED + * placements (the drag preview never ratchets the limit mid-drag), so the + * canvas can't endlessly grow when dragging far right / down. + */ + const appendLimit = $derived(dragAppendLimit(placements, geometry)); + + function cellAt(clientX: number, clientY: number): Cell | null { + const el = gridEl; + if (!el) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + // Wide: fixed-width columns at the pitch. Tall: fluid 1fr columns. + const cellW = wide ? pitchX : rect.width / geometry.flowCols; + const col = Math.max(1, Math.floor((clientX - rect.left) / cellW) + 1); + const row = Math.max(1, Math.floor((clientY - rect.top) / pitchY) + 1); + return { + col: wide ? Math.min(col, appendLimit) : Math.min(col, geometry.flowCols), + row: wide ? Math.min(row, geometry.flowRows) : Math.min(row, appendLimit), + }; + } + + // ── The drag preview: COMMITTED placements + displacement, fresh per cell ─ + /** The dragged tile's id while a drag is in flight (else null). */ + const draggedId = $derived<string | null>( + phase.kind === "dragging" ? phase.id : null, + ); + const draggedPlacement = $derived( + draggedId !== null + ? (placements.find((placement) => placement.id === draggedId) ?? null) + : null, + ); + + /** The pointer's hovered cell while dragging (else null). */ + const hoveredCell = $derived<Cell | null>( + phase.kind === "dragging" ? phase.targetCell : null, + ); + + /** The landing cell: where the pointer is, else the tile's committed slot. */ + const targetCell = $derived<Cell | null>( + hoveredCell ?? + (draggedPlacement ? { col: draggedPlacement.col, row: draggedPlacement.row } : null), + ); + + /** + * What to render: while dragging, the displacement of the COMMITTED layout + * at the target cell — recomputed from committed on EVERY hover change (as + * if the user moved straight from the original to here); the FLIP below + * animates from wherever the tiles currently are. Otherwise the committed + * placements. + */ + const rendered = $derived( + draggedPlacement && targetCell && draggedId !== null + ? displacePlace(placements, draggedId, targetCell, geometry) + : placements, + ); + + // ── FLIP reflow (GSAP Flip — re-entrant: captures mid-flight positions) ─── + let tileEls = new Map<string, HTMLElement>(); + + function trackTile(el: HTMLElement, id: string): { destroy: () => void } { + tileEls.set(id, el); + return { + destroy() { + tileEls.delete(id); + }, + }; + } + + let flipState: ReturnType<typeof Flip.getState> | null = null; + + $effect.pre(() => { + // Track the layout inputs; the DOM read below is intentionally untracked. + void rendered + .map((p) => `${p.id}:${p.col},${p.row},${p.colSpan}x${p.rowSpan}`) + .join("|"); + void geometry.mode; + flipState = Flip.getState( + [...tileEls].filter(([id]) => !excludedFromFlip.has(id)).map(([, el]) => el), + ); + }); + + $effect(() => { + void rendered; + void geometry.mode; + const state = flipState; + flipState = null; + if (!state) return; + Flip.from(state, { + duration: 0.25, + // Fast start, decelerating arrival — the reflow sweeps out and settles + // into the destination (an inOut curve would ease INTO the motion too). + ease: "power3.out", + nested: true, + onEnter: (elements) => gsap.fromTo(elements, { opacity: 0 }, { opacity: 1, duration: 0.2 }), + }); + }); + + // ── Wide-mode horizontal panning: wheel + pan arrow + scroll indicator ──── + let scrollerEl = $state<HTMLDivElement | undefined>(); + let scrollProgress = $state(0); + let canPan = $state(false); + + function updateScroll(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + canPan = geometry.mode === "wide" && max > 1; + scrollProgress = max > 0 ? el.scrollLeft / max : 0; + } + + $effect(() => { + // Re-measure when the geometry flips, the content changes, or a drag + // starts/ends (the canvas grows with the preview and shrinks back after). + void geometry.mode; + void placements.length; + void dragging; + updateScroll(); + }); + + function handleWheel(e: WheelEvent): void { + // Vertical wheel pans the wide canvas horizontally. + if (geometry.mode !== "wide" || Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + e.preventDefault(); + scrollerEl?.scrollBy({ left: e.deltaY }); + } + + function pan(): void { + const el = scrollerEl; + if (!el) return; + const max = el.scrollWidth - el.clientWidth; + const atEnd = el.scrollLeft >= max - 1; + // One viewport sideways; past the end, pan back to the start. + el.scrollBy({ left: atEnd ? -max - el.clientWidth : el.clientWidth, behavior: "smooth" }); + } + + // ── Grid + tile styles (the geometry decides; no breakpoint classes) ────── + const dragging = $derived(phase.kind === "dragging"); + + const gridStyle = $derived.by(() => { + const wide = geometry.mode === "wide"; + // Content-driven canvas: the grid hugs its content (max-content / auto), + // so the scrollbar GROWS as the preview reaches right/down and SHRINKS when + // the gesture ends — the drag sees the scrollbar move as it should. The + // drop-target clamp (`cellAt` → dragAppendLimit) bounds how far the preview + // can extend, so the canvas never grows past the committed edge + 3. + const base = + (wide + ? `grid-template-rows: repeat(${geometry.flowRows}, ${geometry.unit.height}px);` + + ` grid-auto-flow: column; grid-auto-columns: ${geometry.unit.width}px;` + + ` gap: ${geometry.unit.gap}px; width: max-content;` + : `grid-template-columns: repeat(${geometry.flowCols}, minmax(0, 1fr));` + + ` grid-auto-flow: row; grid-auto-rows: ${geometry.unit.height}px;` + + ` gap: ${geometry.unit.gap}px;`); + if (!dragging) return base; + // The drop-target dots: one faint dot at each unit cell's CENTER, painted + // as a background whose tile is the cell pitch. One extra pitch of padding + // past the content end gives the append-slot a dot too. + const dotSize = wide ? `${pitchX}px` : `calc(100% / ${geometry.flowCols})`; + const dotX = wide ? geometry.unit.width / 2 : 0; + const dot = + ` background-image: radial-gradient(circle 2.5px,` + + ` color-mix(in oklab, var(--color-base-content) 22%, transparent) 98%, transparent 100%);` + + ` background-size: ${dotSize} ${pitchY}px;` + + ` background-position: ${dotX}px ${geometry.unit.height / 2}px;` + + (wide ? ` padding-right: ${pitchX}px;` : ` padding-bottom: ${pitchY}px;`); + return base + dot; + }); + + function spanFor(placement: Placement): Span { + if (preview && preview.id === placement.id) { + return { colSpan: preview.colSpan, rowSpan: preview.rowSpan }; + } + return { colSpan: placement.colSpan, rowSpan: placement.rowSpan }; + } + + function wrapperStyle(placement: Placement): string { + const span = spanFor(placement); + return `grid-column: ${placement.col} / span ${span.colSpan}; grid-row: ${placement.row} / span ${span.rowSpan};`; + } + + /** + * The landing cell's top-left in CLIENT coordinates — where the CSS grid + * actually places a tile anchored at `targetCell` (the true column pitch: + * fixed in wide; the fluid column width PLUS the gap in tall). The follow + * transform cancels exactly this, so the grab point tracks the pointer. + */ + const targetCellOrigin = $derived.by<{ x: number; y: number } | null>(() => { + const el = gridEl; + if (!el || !targetCell) return null; + const rect = el.getBoundingClientRect(); + const wide = geometry.mode === "wide"; + const colPitch = wide ? pitchX : (rect.width + geometry.unit.gap) / geometry.flowCols; + return { + x: rect.left + (targetCell.col - 1) * colPitch, + y: rect.top + (targetCell.row - 1) * pitchY, + }; + }); + + /** The dragged tile's follow transform (it keeps its committed slot). */ + function followStyle(placement: Placement): string { + if (draggedId !== placement.id || !pressOrigin || !grabOffset || phase.kind !== "dragging") { + return ""; + } + const origin = targetCellOrigin; + if (!origin) return ""; + // Pointer minus the tile's GRID anchor minus the grab offset — the grab + // point the user pressed tracks the pointer. A raw pointer-displacement + // translate would ride the tile's origin instead and jump by the grab + // offset (a center grab snaps the tile's top-left under the pointer). + const dx = phase.x - origin.x - grabOffset.x; + const dy = phase.y - origin.y - grabOffset.y; + return `transform: translate(${dx}px, ${dy}px) scale(1.05);`; + } + + // ── The drop ghost: a dashed footprint at the target anchor ─────────────── + const ghostStyle = $derived.by(() => { + if (!dragging || !targetCell || !draggedPlacement) return null; + const span = spanFor(draggedPlacement); + const wide = geometry.mode === "wide"; + const h = span.rowSpan * pitchY - geometry.unit.gap; + const x = wide + ? `${(targetCell.col - 1) * pitchX}px` + : `calc(${((targetCell.col - 1) / geometry.flowCols) * 100}%)`; + const width = wide + ? `${span.colSpan * pitchX - geometry.unit.gap}px` + : `calc(${(span.colSpan / geometry.flowCols) * 100}% - ${(geometry.unit.gap * (geometry.flowCols - span.colSpan)) / geometry.flowCols}px)`; + return `left: ${x}; top: ${(targetCell.row - 1) * pitchY}px; width: ${width}; height: ${h}px;`; + }); + + /** + * The Start-screen tile tones (static classes — Tailwind/DaisyUI must see + * the literal `bg-*`/`text-*-content` names to emit them). A tile is its + * tone's SATURATED color with the tone's `-content` text, like Win8's + * colored tiles on the dark Start canvas. + */ + const TONE_CLASSES: Record<TileTone, string> = { + primary: "bg-primary text-primary-content border-primary", + secondary: "bg-secondary text-secondary-content border-secondary", + accent: "bg-accent text-accent-content border-accent", + info: "bg-info text-info-content border-info", + success: "bg-success text-success-content border-success", + warning: "bg-warning text-warning-content border-warning", + }; +</script> + +<svelte:window onkeydowncapture={windowKeydown} /> + +<div class="relative h-full"> + <div + bind:this={scrollerEl} + class="h-full overscroll-contain" + class:overflow-x-auto={geometry.mode === "wide"} + class:overflow-y-auto={geometry.mode === "tall"} + style={`padding: ${geometry.margin}px; scroll-snap-type: ${geometry.mode === "wide" ? "x" : "y"} proximity;`} + onscroll={updateScroll} + onwheel={handleWheel} + > + <!-- svelte-ignore a11y_no_static_element_interactions -- the grid owns the drag gestures (capture must live on a never-reparented element) --> + <div + bind:this={gridEl} + class="relative grid" + style={gridStyle} + onpointerdown={gridPointerDown} + onpointermove={gridPointerMove} + onpointerup={gridPointerUp} + onpointercancel={() => dispatch({ type: "cancel" })} + > + {#if ghostStyle && draggedPlacement} + <!-- The landing footprint (the dragged tile's tone, dashed). --> + <div + aria-hidden="true" + class="pointer-events-none absolute z-0 rounded-box border-2 border-dashed opacity-70" + class:border-primary={tileTone(draggedPlacement.id) === "primary"} + class:border-secondary={tileTone(draggedPlacement.id) === "secondary"} + class:border-accent={tileTone(draggedPlacement.id) === "accent"} + class:border-info={tileTone(draggedPlacement.id) === "info"} + class:border-success={tileTone(draggedPlacement.id) === "success"} + class:border-warning={tileTone(draggedPlacement.id) === "warning"} + style={ghostStyle} + ></div> + {/if} + {#each rendered as placement (placement.id)} + {@const ws = workspacesById.get(placement.id)} + {#if ws} + {@const isDragged = draggedId === placement.id} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <div + data-tile-id={placement.id} + class={`raised flex select-none flex-col overflow-hidden rounded-box border transition-[box-shadow] duration-150 ${ + TONE_CLASSES[tileTone(placement.id)] + } ${ + selectedId === placement.id + ? "ring-4 ring-base-content/80 ring-offset-2 ring-offset-base-100" + : "border-transparent" + } ${ + isDragged + ? "pointer-events-none relative z-40 shadow-2xl" + : "hover:-translate-y-0.5 hover:shadow-xl" + }`} + style={`${wrapperStyle(placement)} ${isDragged ? followStyle(placement) : ""} scroll-snap-align: start;`} + role="button" + tabindex="0" + aria-label={`Open workspace ${ws.title}`} + use:trackTile={placement.id} + oncontextmenu={(e) => { + e.preventDefault(); + onSelect(placement.id); + }} + onkeydown={(e) => { + if (insideInteractive(e.target)) return; + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onOpen(placement.id); + } + }} + > + <WorkspaceTile + {ws} + {store} + {placement} + selected={selectedId === placement.id} + renaming={renamingId === placement.id} + active={hasActive?.(ws.id) ?? false} + {onRenameEnd} + /> + </div> + {/if} + {/each} + </div> + </div> + + {#if canPan} + <!-- Pan arrow + thin scroll-position indicator (wide geometry only). + Both sit INSIDE the canvas' outer margin. --> + <button + type="button" + class="btn btn-circle absolute z-10 border-none bg-base-100 text-base-content shadow-xl" + style={`right: ${Math.max(12, geometry.margin / 2)}px; bottom: ${Math.max(12, geometry.margin / 2)}px;`} + aria-label="Pan right" + title="Pan one screen sideways" + onclick={pan} + > + → + </button> + <div + class="absolute right-0 bottom-0 left-0 z-10 h-0.5 bg-base-content/10" + style={`margin: 0 ${geometry.margin}px;`} + > + <div + class="h-full bg-primary transition-[width] duration-100" + style={`width: ${Math.round(scrollProgress * 100)}%;`} + ></div> + </div> + {/if} +</div> diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts new file mode 100644 index 0000000..a8365d5 --- /dev/null +++ b/src/features/workspaces/ui/HomeTileGrid.test.ts @@ -0,0 +1,200 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { fireEvent, render, screen } from "@testing-library/svelte"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { geometryFor, type Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import HomeTileGrid from "./HomeTileGrid.svelte"; + +const WIDE = geometryFor(1920, 1080); + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "Workspace A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +function placement(id: string, col: number, row: number): Placement { + return { id, col, row, colSpan: 1, rowSpan: 1 }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn(), + } as unknown as WorkspaceStore; +} + +function baseProps() { + return { + geometry: WIDE, + workspacesById: new Map([ + ["a", ws()], + ["b", ws({ id: "b", title: "Workspace B" })], + ]), + store: fakeStore(), + onOpen: vi.fn(), + onSelect: vi.fn(), + onPlace: vi.fn(), + onRenameEnd: vi.fn(), + }; +} + +describe("HomeTileGrid", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("renders one tile per placement, positioned by the packed spans", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1), placement("b", 2, 1)], + }, + }); + + expect(screen.getByRole("button", { name: "Open workspace Workspace A" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open workspace Workspace B" })).toBeInTheDocument(); + const wrappers = container.querySelectorAll("[data-tile-id]"); + expect(wrappers).toHaveLength(2); + expect(wrappers[0]?.getAttribute("style")).toContain("grid-column: 1 / span 1"); + expect(wrappers[1]?.getAttribute("style")).toContain("grid-column: 2 / span 1"); + }); + + it("a tap (press + release without moving) opens the workspace", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerUp(wrapper); + + expect(props.onOpen).toHaveBeenCalledWith("a"); + expect(props.onSelect).not.toHaveBeenCalled(); + }); + + it("right-click selects the tile", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.contextMenu(wrapper); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + // The cell math resolves against the grid element's rect (jsdom: 0s). + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + // The dashed landing ghost appears… + const ghost = container.querySelector("[aria-hidden='true'].border-dashed"); + expect(ghost).not.toBeNull(); + // …and the dragged tile itself follows the pointer (transform on the tile). + const tile = container.querySelector("[data-tile-id='a']"); + expect(tile?.getAttribute("style")).toContain("translate("); + + fireEvent.pointerUp(wrapper); + // The ghost + follow disappear on drop… + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + // …and the drop committed a PLACE at the hovered cell (a lands where it + // already was — the pointer hovered its own slot; a no-move drop). + expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 }); + // And no navigation either (a drag is not a tap). + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" }); + fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 }); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(props.onPlace).not.toHaveBeenCalled(); + // The drag visuals are gone (the tile is no longer lifted). + expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull(); + }); + + it("a touch long-press lifts the tile and selects it (never opens)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).toHaveBeenCalledWith("a"); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("a touch drag that moves before the long-press cancels (scroll wins)", async () => { + const props = baseProps(); + render(HomeTileGrid, { + props: { ...props, placements: [placement("a", 1, 1)] }, + }); + + const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" }); + fireEvent.pointerDown(wrapper, { + button: 0, + clientX: 10, + clientY: 10, + pointerType: "touch", + }); + fireEvent.pointerMove(wrapper, { clientX: 10, clientY: 120 }); + await vi.advanceTimersByTimeAsync(500); + + expect(props.onSelect).not.toHaveBeenCalled(); + expect(props.onOpen).not.toHaveBeenCalled(); + }); + + it("previews a transient span for the resizing tile", () => { + const props = baseProps(); + const { container } = render(HomeTileGrid, { + props: { + ...props, + placements: [placement("a", 1, 1)], + preview: { id: "a", colSpan: 3, rowSpan: 2 }, + }, + }); + + const wrapper = container.querySelector("[data-tile-id='a']"); + expect(wrapper?.getAttribute("style")).toContain("span 3"); + expect(wrapper?.getAttribute("style")).toContain("span 2"); + }); +}); diff --git a/src/features/workspaces/ui/ResizePicker.svelte b/src/features/workspaces/ui/ResizePicker.svelte new file mode 100644 index 0000000..e066519 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.svelte @@ -0,0 +1,107 @@ +<script lang="ts"> + import { onMount, untrack } from "svelte"; + import type { GridGeometry, Span } from "../logic/tile-layout"; + + let { + geometry, + initial, + onPreview, + onCommit, + onCancel, + }: { + geometry: GridGeometry; + /** The tile's current (clamped) spans — the picker's starting cell. */ + initial: Span; + /** Transiently ghost-preview the hovered footprint on the real grid. */ + onPreview: (span: Span | null) => void; + onCommit: (colSpan: number, rowSpan: number) => void; + onCancel: () => void; + } = $props(); + + // The picker IS the span cap: wide = 4×2 small squares, tall = 2×2. + const cols = $derived(geometry.maxColSpan); + const rows = $derived(geometry.maxRowSpan); + + // The currently hovered cell (c, r) — the highlighted rectangle spans + // 1..c × 1..r from the picker's top-left (insert-table style). The picker + // mounts fresh per resize session, so seeding from the prop is intended. + let hovered = $state({ + colSpan: untrack(() => Math.min(initial.colSpan, cols)), + rowSpan: untrack(() => Math.min(initial.rowSpan, rows)), + }); + + function hover(colSpan: number, rowSpan: number): void { + hovered = { colSpan, rowSpan }; + onPreview({ colSpan, rowSpan }); + } + + function commit(): void { + onCommit(hovered.colSpan, hovered.rowSpan); + } + + function keydown(e: KeyboardEvent): void { + if (e.key === "Escape") { + e.preventDefault(); + onCancel(); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commit(); + return; + } + let dc = 0; + let dr = 0; + if (e.key === "ArrowRight") dc = 1; + else if (e.key === "ArrowLeft") dc = -1; + else if (e.key === "ArrowDown") dr = 1; + else if (e.key === "ArrowUp") dr = -1; + else return; + e.preventDefault(); + hover( + Math.max(1, Math.min(cols, hovered.colSpan + dc)), + Math.max(1, Math.min(rows, hovered.rowSpan + dr)), + ); + } + + let root = $state<HTMLDivElement | undefined>(); + onMount(() => root?.focus()); +</script> + +<!-- svelte-ignore a11y_no_noninteractive_element_interactions, a11y_no_noninteractive_tabindex --> +<div + bind:this={root} + class="fixed inset-x-0 bottom-20 z-50 mx-auto flex w-max flex-col gap-2 rounded-box border border-base-content/20 bg-base-100 p-3 shadow-2xl outline-none" + role="group" + aria-label="Resize tile" + tabindex="0" + onkeydown={keydown} + onmouseleave={() => onPreview(null)} +> + <p class="text-xs font-semibold uppercase opacity-60">Resize — click a size</p> + <div class="grid gap-1" style={`grid-template-columns: repeat(${cols}, 1.5rem);`}> + {#each Array.from({ length: rows }) as _, row (row)} + {#each Array.from({ length: cols }) as __, col (col)} + <button + type="button" + class={`size-6 border border-base-content/20 ${ + col < hovered.colSpan && row < hovered.rowSpan + ? "bg-primary/60" + : col === hovered.colSpan - 1 && row === hovered.rowSpan - 1 + ? "bg-primary/20" + : "" + }`} + aria-label={`Resize to ${col + 1} by ${row + 1}`} + onmouseenter={() => hover(col + 1, row + 1)} + onfocus={() => hover(col + 1, row + 1)} + onclick={() => { + hover(col + 1, row + 1); + commit(); + }} + ></button> + {/each} + {/each} + </div> + <p class="text-center font-mono text-xs opacity-70">{hovered.colSpan} × {hovered.rowSpan}</p> + <p class="text-center text-[10px] opacity-50">Enter commits · Escape cancels</p> +</div> diff --git a/src/features/workspaces/ui/ResizePicker.test.ts b/src/features/workspaces/ui/ResizePicker.test.ts new file mode 100644 index 0000000..796a556 --- /dev/null +++ b/src/features/workspaces/ui/ResizePicker.test.ts @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { geometryFor } from "../logic/tile-layout"; +import ResizePicker from "./ResizePicker.svelte"; + +const WIDE = geometryFor(1920, 1080); +const TALL = geometryFor(390, 844); + +function props() { + return { + onPreview: vi.fn(), + onCommit: vi.fn(), + onCancel: vi.fn(), + }; +} + +describe("ResizePicker", () => { + it("renders one small square per cap (4×2 wide, 2×2 tall)", () => { + const p = props(); + const wide = render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(8); + wide.unmount(); + + render(ResizePicker, { + props: { geometry: TALL, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + expect(screen.getAllByRole("button")).toHaveLength(4); + }); + + it("hovering square (r, c) previews that rectangle", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 3 by 2" })); + expect(p.onPreview).toHaveBeenCalledWith({ colSpan: 3, rowSpan: 2 }); + }); + + it("clicking a square commits that size", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.click(screen.getByRole("button", { name: "Resize to 2 by 1" })); + expect(p.onCommit).toHaveBeenCalledWith(2, 1); + }); + + it("arrow keys move within the picker, Enter commits, Escape cancels", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 2, rowSpan: 1 }, ...p }, + }); + + await user.keyboard("{ArrowRight}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 1 }); + await user.keyboard("{ArrowDown}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 3, rowSpan: 2 }); + await user.keyboard("{ArrowLeft}"); + expect(p.onPreview).toHaveBeenLastCalledWith({ colSpan: 2, rowSpan: 2 }); + + await user.keyboard("{Enter}"); + expect(p.onCommit).toHaveBeenCalledWith(2, 2); + expect(p.onCancel).not.toHaveBeenCalled(); + + await user.keyboard("{Escape}"); + expect(p.onCancel).toHaveBeenCalledTimes(1); + }); + + it("leaving the picker clears the preview", async () => { + const user = userEvent.setup(); + const p = props(); + render(ResizePicker, { + props: { geometry: WIDE, initial: { colSpan: 1, rowSpan: 1 }, ...p }, + }); + + await user.hover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + await user.unhover(screen.getByRole("button", { name: "Resize to 4 by 2" })); + expect(p.onPreview).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/features/workspaces/ui/TileActionBar.svelte b/src/features/workspaces/ui/TileActionBar.svelte new file mode 100644 index 0000000..ffb8f1d --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.svelte @@ -0,0 +1,73 @@ +<script lang="ts"> + import { fly } from "svelte/transition"; + import type { WorkspaceEntry } from "@dispatch/wire"; + + let { + ws, + withPlugins = false, + onOpen, + onRename, + onResize, + onToggleStar, + onDetails, + onPlugins, + onArchive, + onDelete, + onClose, + }: { + ws: WorkspaceEntry; + /** The plugins port is present → the Plugins action renders. */ + withPlugins?: boolean; + onOpen: () => void; + onRename: () => void; + onResize: () => void; + onToggleStar: () => void; + /** The details flyout (default cwd + default computer). */ + onDetails: () => void; + onPlugins: () => void; + /** Archive the workspace (hide from the home grid; reversible). */ + onArchive: () => void; + onDelete: () => void; + onClose: () => void; + } = $props(); +</script> + +<!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> +<div + class="fixed inset-x-0 bottom-0 z-40 flex justify-center p-4" + transition:fly={{ y: 80, duration: 150 }} + onclick={(e) => { + // The bar owns its clicks — don't let them deselect via the backdrop. + e.stopPropagation(); + }} +> + <div + class="flex flex-wrap items-center justify-center gap-1 rounded-box border border-base-content/15 bg-base-100 p-2 shadow-2xl" + role="toolbar" + aria-label={`Actions for ${ws.title}`} + > + <button type="button" class="btn btn-primary btn-sm" onclick={onOpen}>Open</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onRename}>Rename</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onResize}>Resize</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onDetails}>Details</button> + <button type="button" class="btn btn-ghost btn-sm" onclick={onToggleStar}> + {ws.starred ? "Unstar" : "Star"} + </button> + {#if withPlugins} + <button type="button" class="btn btn-ghost btn-sm" onclick={onPlugins}>Plugins</button> + {/if} + <button type="button" class="btn btn-ghost btn-sm" onclick={onArchive}>Archive</button> + <button + type="button" + class="btn btn-ghost btn-sm text-error" + onclick={onDelete}>Delete</button + > + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close actions" + title="Close" + onclick={onClose}>✕</button + > + </div> +</div> diff --git a/src/features/workspaces/ui/TileActionBar.test.ts b/src/features/workspaces/ui/TileActionBar.test.ts new file mode 100644 index 0000000..c00de19 --- /dev/null +++ b/src/features/workspaces/ui/TileActionBar.test.ts @@ -0,0 +1,93 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import TileActionBar from "./TileActionBar.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function handlers() { + return { + onOpen: vi.fn(), + onRename: vi.fn(), + onResize: vi.fn(), + onToggleStar: vi.fn(), + onDetails: vi.fn(), + onPlugins: vi.fn(), + onArchive: vi.fn(), + onDelete: vi.fn(), + onClose: vi.fn(), + }; +} + +describe("TileActionBar", () => { + it("renders the Open · Rename · Resize · Star · Details · Delete actions", () => { + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Rename" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Resize" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Details" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Archive" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Delete" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Close actions" })).toBeInTheDocument(); + }); + + it("omits Plugins when no plugins port is present, renders it when one is", () => { + const h = handlers(); + const without = render(TileActionBar, { props: { ws: ws(), ...h } }); + expect(without.queryByRole("button", { name: "Plugins" })).toBeNull(); + without.unmount(); + + render(TileActionBar, { props: { ws: ws(), withPlugins: true, ...h } }); + expect(screen.getByRole("button", { name: "Plugins" })).toBeInTheDocument(); + }); + + it("labels the star action by the current state (Star ↔ Unstar)", () => { + const h = handlers(); + const unstarred = render(TileActionBar, { props: { ws: ws({ starred: false }), ...h } }); + expect(screen.getByRole("button", { name: "Star" })).toBeInTheDocument(); + unstarred.unmount(); + + render(TileActionBar, { props: { ws: ws({ starred: true }), ...h } }); + expect(screen.getByRole("button", { name: "Unstar" })).toBeInTheDocument(); + }); + + it("dispatches each action", async () => { + const user = userEvent.setup(); + const h = handlers(); + render(TileActionBar, { props: { ws: ws(), ...h } }); + + await user.click(screen.getByRole("button", { name: "Open" })); + await user.click(screen.getByRole("button", { name: "Rename" })); + await user.click(screen.getByRole("button", { name: "Resize" })); + await user.click(screen.getByRole("button", { name: "Star" })); + await user.click(screen.getByRole("button", { name: "Details" })); + await user.click(screen.getByRole("button", { name: "Archive" })); + await user.click(screen.getByRole("button", { name: "Delete" })); + await user.click(screen.getByRole("button", { name: "Close actions" })); + + expect(h.onOpen).toHaveBeenCalledTimes(1); + expect(h.onRename).toHaveBeenCalledTimes(1); + expect(h.onResize).toHaveBeenCalledTimes(1); + expect(h.onToggleStar).toHaveBeenCalledTimes(1); + expect(h.onDetails).toHaveBeenCalledTimes(1); + expect(h.onArchive).toHaveBeenCalledTimes(1); + expect(h.onDelete).toHaveBeenCalledTimes(1); + expect(h.onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceCard.svelte b/src/features/workspaces/ui/WorkspaceCard.svelte deleted file mode 100644 index 6de4109..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.svelte +++ /dev/null @@ -1,279 +0,0 @@ -<script lang="ts"> - import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; - import { untrack } from "svelte"; - import type { WorkspaceStore } from "../store.svelte"; - import { relativeTime } from "../logic/view-model"; - import { workspacePath } from "../logic/route"; - import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; - - let { - ws, - store, - onNavigate, - computers, - hasActive, - }: { - ws: WorkspaceEntry; - store: WorkspaceStore; - onNavigate: (path: string) => void; - /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ - computers: readonly ComputerEntry[]; - /** - * Optional port: returns whether the workspace has at least one active - * (generating / queued) conversation — drives a loading-dots indicator on - * the card. Wired by the composition root to the app store's - * `workspaceHasActiveConversations`. Absent → no indicator (e.g. tests). - */ - hasActive?: (workspaceId: string) => boolean; - } = $props(); - - // Whether at least one conversation in this workspace is currently active - // (generating). Reactive: the composition-root port reads the app store's - // reactive tab set + lifecycle statuses, so this re-derives on change. - const active = $derived(hasActive?.(ws.id) ?? false); - - // ── Title: double-click to rename inline ────────────────────────────────── - let editingTitle = $state(false); - let titleDraft = $state(""); - let titleInput = $state<HTMLInputElement | undefined>(); - let titleError = $state<string | null>(null); - - function startEditTitle(): void { - titleDraft = ws.title; - titleError = null; - editingTitle = true; - queueMicrotask(() => titleInput?.focus()); - } - - async function saveTitle(): Promise<void> { - if (!editingTitle) return; - const title = titleDraft.trim(); - editingTitle = false; - if (title === "" || title === ws.title) return; - const result = await store.rename(ws.id, title); - if (!result.ok) titleError = result.error; - } - - function cancelTitle(): void { - editingTitle = false; - titleError = null; - } - - // ── Default cwd: inline input ───────────────────────────────────────────── - let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); - // Reseed when the backend value changes (e.g., after a save or an external - // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. - $effect(() => { - cwdDraft = ws.defaultCwd ?? ""; - }); - - const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); - let savingCwd = $state(false); - let cwdError = $state<string | null>(null); - - async function saveCwd(): Promise<void> { - if (!cwdDirty || savingCwd) return; - savingCwd = true; - cwdError = null; - const cwd = cwdDraft.trim(); - const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); - savingCwd = false; - if (!result.ok) cwdError = result.error; - } - - // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── - let savingComputer = $state(false); - let computerError = $state<string | null>(null); - - async function saveComputer(computerId: string | null): Promise<void> { - if (savingComputer) return; - // No-op when unchanged (the select only fires on a real change, but guard). - if (computerId === (ws.defaultComputerId ?? null)) return; - savingComputer = true; - computerError = null; - const result = await store.setDefaultComputer(ws.id, computerId); - savingComputer = false; - if (!result.ok) computerError = result.error; - } - - // ── Star (concurrency priority) ────────────────────────────────────────────── - let savingStar = $state(false); - let starError = $state<string | null>(null); - - async function toggleStar(): Promise<void> { - if (savingStar) return; - savingStar = true; - starError = null; - try { - // Optimistic: the store flips `starred` immediately and re-sorts; revert - // on error is handled there. We read `ws.starred` for the target value. - const result = await store.setStarred(ws.id, !ws.starred); - if (!result.ok) starError = result.error; - } catch (err) { - // A throw (e.g. a rejected effect) — surface it; the store already - // reverted the optimistic flip if it got far enough to apply it. - starError = err instanceof Error ? err.message : "Star toggle failed"; - } finally { - savingStar = false; - } - } - - // ── Delete ───────────────────────────────────────────────────────────────── - let deleting = $state(false); - - async function handleDelete(): Promise<void> { - if ( - !window.confirm( - `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, - ) - ) { - return; - } - deleting = true; - await store.remove(ws.id); - deleting = false; - } -</script> - -<li class="flex flex-col gap-2 rounded-box border border-primary bg-primary/10 p-3"> - <div class="flex items-center gap-2"> - {#if editingTitle} - <input - bind:this={titleInput} - bind:value={titleDraft} - class="input input-bordered input-sm flex-1" - aria-label="Workspace title" - onkeydown={(e) => { - if (e.key === "Enter") saveTitle(); - else if (e.key === "Escape") cancelTitle(); - }} - onblur={saveTitle} - /> - {:else} - <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> - <span - class="flex-1 cursor-default truncate font-semibold" - title="Double-click to rename" - ondblclick={startEditTitle}>{ws.title}</span - > - {/if} - {#if active} - <span - class="loading loading-dots loading-xs shrink-0 text-primary" - aria-label="Workspace has active conversations" - title="A conversation in this workspace is generating"></span - > - {/if} - <span class="font-mono text-xs opacity-50">/{ws.id}</span> - <button - type="button" - class="btn btn-ghost btn-xs px-1" - disabled={savingStar} - aria-pressed={ws.starred} - aria-label={ws.starred ? "Unstar workspace" : "Star workspace"} - title={ws.starred - ? "Starred — its agents get concurrency priority. Click to unstar." - : "Star this workspace to give its agents concurrency priority."} - onclick={toggleStar} - > - {#if savingStar} - <span class="loading loading-spinner loading-xs"></span> - {:else if ws.starred} - <span class="text-warning" aria-hidden="true">★</span> - {:else} - <span class="opacity-40" aria-hidden="true">☆</span> - {/if} - </button> - <span class="ml-auto text-xs opacity-50"> - {ws.conversationCount} - {ws.conversationCount === 1 ? "conversation" : "conversations"} - · {relativeTime(ws.lastActivityAt, Date.now())} - </span> - <button - type="button" - class="btn btn-ghost btn-xs" - disabled={deleting} - title="Delete workspace" - aria-label="Delete workspace" - onclick={handleDelete} - > - {#if deleting} - <span class="loading loading-spinner loading-xs"></span> - {:else} - ✕ - {/if} - </button> - </div> - - {#if titleError} - <p class="text-xs text-error">{titleError}</p> - {/if} - - {#if starError} - <p class="text-xs text-error">{starError}</p> - {/if} - - <div class="flex items-center gap-2"> - <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> - <input - type="text" - class="input input-bordered input-sm flex-1 font-mono text-xs" - placeholder="inherits the server default" - bind:value={cwdDraft} - aria-label="Default working directory" - onkeydown={(e) => { - if (e.key === "Enter") saveCwd(); - }} - /> - <button - type="button" - class="btn btn-primary btn-xs" - disabled={!cwdDirty || savingCwd} - onclick={saveCwd} - > - {#if savingCwd} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Set - {/if} - </button> - </div> - - <div class="flex items-center gap-2"> - <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> - <ComputerSelect - value={ws.defaultComputerId} - {computers} - disabled={savingComputer} - onSelect={saveComputer} - /> - {#if savingComputer} - <span class="loading loading-spinner loading-xs shrink-0"></span> - {/if} - </div> - - <div class="flex justify-start"> - <a - class="btn" - href={workspacePath(ws.id)} - onclick={(e) => { - e.preventDefault(); - onNavigate(workspacePath(ws.id)); - }} - > - Open - </a> - </div> - - {#if cwdError} - <p class="text-xs text-error">{cwdError}</p> - {:else if !cwdDirty && !ws.defaultCwd} - <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> - {/if} - - {#if computerError} - <p class="text-xs text-error">{computerError}</p> - {:else if !ws.defaultComputerId} - <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> - {/if} -</li> diff --git a/src/features/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts deleted file mode 100644 index 72f4e60..0000000 --- a/src/features/workspaces/ui/WorkspaceCard.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import type { WorkspaceEntry } from "@dispatch/wire"; -import { render, screen } from "@testing-library/svelte"; -import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; -import type { WorkspaceResult } from "../adapter/http"; -import type { WorkspaceStore } from "../store.svelte"; -import WorkspaceCard from "./WorkspaceCard.svelte"; - -function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { - return { - id: "my-ws", - title: "My Workspace", - defaultCwd: null, - defaultComputerId: null, - starred: false, - createdAt: 1, - lastActivityAt: 2, - conversationCount: 3, - ...overrides, - }; -} - -/** A fake store that records calls + resolves ok. */ -function fakeStore() { - return { - rename: vi.fn( - async (id: string, _title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, title: _title }), - }), - ), - setDefaultCwd: vi.fn( - async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, defaultCwd }), - }), - ), - setDefaultComputer: vi.fn( - async (id: string, computerId: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, defaultComputerId: computerId }), - }), - ), - setStarred: vi.fn( - async (id: string, starred: boolean): Promise<WorkspaceResult<WorkspaceEntry>> => ({ - ok: true, - value: fakeEntry({ id, starred }), - }), - ), - remove: vi.fn( - async (): Promise<WorkspaceResult<{ closedCount: number }>> => ({ - ok: true, - value: { closedCount: 0 }, - }), - ), - }; -} - -describe("WorkspaceCard", () => { - it("renders the title, slug, and an Open link", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - expect(screen.getByText("My Workspace")).toBeInTheDocument(); - expect(screen.getByText("/my-ws")).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Open" })).toHaveAttribute("href", "/my-ws"); - }); - - it("double-clicking the title reveals an edit input", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); - }); - - it("renames via the store on Enter", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.dblClick(screen.getByText("My Workspace")); - const input = screen.getByLabelText("Workspace title"); - await user.clear(input); - await user.type(input, "Renamed{Enter}"); - - expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); - }); - - it("enables Set only when the cwd differs, then saves it", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - expect(input).toHaveValue("/old"); - expect(screen.getByRole("button", { name: "Set" })).toBeDisabled(); - - await user.clear(input); - await user.type(input, "/new/path"); - expect(screen.getByRole("button", { name: "Set" })).toBeEnabled(); - - await user.click(screen.getByRole("button", { name: "Set" })); - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", "/new/path"); - }); - - it("clears the cwd to null when saved empty (inherits the server default)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ defaultCwd: "/old" }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const input = screen.getByLabelText("Default working directory"); - await user.clear(input); - await user.click(screen.getByRole("button", { name: "Set" })); - - expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); - }); - - it("the Open link navigates to the workspace in the same tab (SPA navigation, no new tab)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - const onNavigate = vi.fn(); - render(WorkspaceCard, { props: { ws: fakeEntry(), store, onNavigate, computers: [] } }); - - const open = screen.getByRole("link", { name: "Open" }); - // Still a real link (progressive enhancement): href points at the workspace. - expect(open).toHaveAttribute("href", "/my-ws"); - // But it no longer opens a new browser tab. - expect(open).not.toHaveAttribute("target", "_blank"); - // Clicking navigates in-place via the SPA callback. - await user.click(open); - expect(onNavigate).toHaveBeenCalledTimes(1); - expect(onNavigate).toHaveBeenCalledWith("/my-ws"); - }); - - // ── Active indicator (loading dots) ────────────────────────────────────── - - it("shows no loading-dots when no hasActive port is given", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] }, - }); - expect(container.querySelector(".loading-dots")).toBeNull(); - }); - - it("shows no loading-dots when hasActive returns false", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { - ws: fakeEntry(), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: () => false, - }, - }); - expect(container.querySelector(".loading-dots")).toBeNull(); - }); - - it("shows loading-dots when hasActive returns true", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const { container } = render(WorkspaceCard, { - props: { - ws: fakeEntry(), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: () => true, - }, - }); - const dots = container.querySelector(".loading-dots"); - expect(dots).not.toBeNull(); - // Accessible label ties the indicator to the workspace-active concept. - expect(dots?.getAttribute("aria-label")).toBe("Workspace has active conversations"); - }); - - it("forwards the workspace id to hasActive", () => { - const store = fakeStore() as unknown as WorkspaceStore; - const seen: string[] = []; - render(WorkspaceCard, { - props: { - ws: fakeEntry({ id: "proj-x" }), - store, - onNavigate: vi.fn(), - computers: [], - hasActive: (id: string) => { - seen.push(id); - return false; - }, - }, - }); - expect(seen).toEqual(["proj-x"]); - }); - - it("renders an outline star button for an unstarred workspace", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - const star = screen.getByRole("button", { name: "Star workspace" }); - expect(star).toHaveAttribute("aria-pressed", "false"); - }); - - it("renders a filled star button for a starred workspace", () => { - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, - }); - const star = screen.getByRole("button", { name: "Unstar workspace" }); - expect(star).toHaveAttribute("aria-pressed", "true"); - }); - - it("toggles the star via the store on click", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - expect(store.setStarred).toHaveBeenCalledWith("my-ws", true); - }); - - it("clicking a starred workspace's star calls setStarred(id, false)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: true }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Unstar workspace" })); - expect(store.setStarred).toHaveBeenCalledWith("my-ws", false); - }); - - it("renders no star error on a successful toggle", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - expect(screen.queryByText(/Star toggle failed/i)).not.toBeInTheDocument(); - }); - - it("shows an inline error when setStarred fails (result.ok false)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - // The store reverts the optimistic flip on failure, so the entry's - // `starred` stays false — fake the revert by returning ok:false unchanged. - store.setStarred = vi.fn( - async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), - ); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - await user.click(screen.getByRole("button", { name: "Star workspace" })); - - expect(screen.getByText("boom")).toBeInTheDocument(); - }); - - it("re-enables the star button after a failure (savingStar resets)", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - store.setStarred = vi.fn( - async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "boom" }), - ); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const star = screen.getByRole("button", { name: "Star workspace" }); - await user.click(star); - // After the failed toggle, the button is NOT disabled (savingStar reset). - expect(star).not.toBeDisabled(); - }); - - it("re-enables the star button even when setStarred throws", async () => { - const user = userEvent.setup(); - const store = fakeStore() as unknown as WorkspaceStore; - store.setStarred = vi.fn(async (): Promise<WorkspaceResult<WorkspaceEntry>> => { - throw new Error("network"); - }); - render(WorkspaceCard, { - props: { ws: fakeEntry({ starred: false }), store, onNavigate: vi.fn(), computers: [] }, - }); - - const star = screen.getByRole("button", { name: "Star workspace" }); - await user.click(star); - // savingStar must reset via try/finally even on a throw. - expect(star).not.toBeDisabled(); - expect(screen.getByText("network")).toBeInTheDocument(); - }); -}); diff --git a/src/features/workspaces/ui/WorkspaceDetails.svelte b/src/features/workspaces/ui/WorkspaceDetails.svelte new file mode 100644 index 0000000..6595725 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.svelte @@ -0,0 +1,132 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import ComputerSelect from "../../computer/ui/ComputerSelect.svelte"; + + let { + ws, + store, + computers, + onClose, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** Discovered computers (`GET /computers`), for the default-computer dropdown. */ + computers: readonly ComputerEntry[]; + onClose: () => void; + } = $props(); + + // ── Default cwd: input + Set (semantics preserved from the WorkspaceCard) ── + let cwdDraft = $state(untrack(() => ws.defaultCwd ?? "")); + // Reseed when the backend value changes (e.g., after a save or an external + // refresh). Mid-edit (same value) does NOT re-run, so typing is never clobbered. + $effect(() => { + cwdDraft = ws.defaultCwd ?? ""; + }); + + const cwdDirty = $derived(cwdDraft.trim() !== (ws.defaultCwd ?? "")); + let savingCwd = $state(false); + let cwdError = $state<string | null>(null); + + async function saveCwd(): Promise<void> { + if (!cwdDirty || savingCwd) return; + savingCwd = true; + cwdError = null; + const cwd = cwdDraft.trim(); + const result = await store.setDefaultCwd(ws.id, cwd === "" ? null : cwd); + savingCwd = false; + if (!result.ok) cwdError = result.error; + } + + // ── Default computer: dropdown (Local / discovered SSH aliases) ──────────── + let savingComputer = $state(false); + let computerError = $state<string | null>(null); + + async function saveComputer(computerId: string | null): Promise<void> { + if (savingComputer) return; + // No-op when unchanged (the select only fires on a real change, but guard). + if (computerId === (ws.defaultComputerId ?? null)) return; + savingComputer = true; + computerError = null; + const result = await store.setDefaultComputer(ws.id, computerId); + savingComputer = false; + if (!result.ok) computerError = result.error; + } +</script> + +<div class="modal modal-open" role="dialog" aria-label={`Workspace details: ${ws.title}`}> + <div class="modal-box"> + <div class="flex items-start justify-between gap-2"> + <div> + <h2 class="text-lg font-bold">{ws.title}</h2> + <p class="font-mono text-xs opacity-50">/{ws.id}</p> + </div> + <button + type="button" + class="btn btn-circle btn-ghost btn-sm" + aria-label="Close details" + onclick={onClose}>✕</button + > + </div> + + <div class="mt-4 flex flex-col gap-3"> + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">cwd</span> + <input + type="text" + class="input input-bordered input-sm flex-1 font-mono text-xs" + placeholder="inherits the server default" + bind:value={cwdDraft} + aria-label="Default working directory" + onkeydown={(e) => { + if (e.key === "Enter") void saveCwd(); + }} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!cwdDirty || savingCwd} + onclick={() => void saveCwd()} + > + {#if savingCwd} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + </div> + + {#if cwdError} + <p class="text-xs text-error">{cwdError}</p> + {:else if !cwdDirty && !ws.defaultCwd} + <p class="text-xs opacity-50">No default cwd set — conversations inherit the server default.</p> + {/if} + + <div class="flex items-center gap-2"> + <span class="w-8 shrink-0 text-xs opacity-60">ssh</span> + <ComputerSelect + value={ws.defaultComputerId} + {computers} + disabled={savingComputer} + onSelect={(id) => void saveComputer(id)} + /> + {#if savingComputer} + <span class="loading loading-spinner loading-xs shrink-0"></span> + {/if} + </div> + + {#if computerError} + <p class="text-xs text-error">{computerError}</p> + {:else if !ws.defaultComputerId} + <p class="text-xs opacity-50">No default computer — conversations run locally (no SSH).</p> + {/if} + </div> + </div> + <!-- Click outside closes. --> + <button + type="button" + class="modal-backdrop" + aria-label="Close details backdrop" + onclick={onClose}></button> +</div> diff --git a/src/features/workspaces/ui/WorkspaceDetails.test.ts b/src/features/workspaces/ui/WorkspaceDetails.test.ts new file mode 100644 index 0000000..9138742 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceDetails.test.ts @@ -0,0 +1,115 @@ +import type { ComputerEntry, WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceDetails from "./WorkspaceDetails.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 0, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + setDefaultCwd: vi.fn( + async (id: string, defaultCwd: string | null): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultCwd }), + }), + ), + setDefaultComputer: vi.fn( + async ( + id: string, + defaultComputerId: string | null, + ): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, defaultComputerId }), + }), + ), + } as unknown as WorkspaceStore; +} + +describe("WorkspaceDetails", () => { + it("renders the title, slug, cwd input, and computer select", () => { + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose: () => {} }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + expect(screen.getByLabelText("Default working directory")).toBeInTheDocument(); + expect(screen.getByLabelText("Computer")).toBeInTheDocument(); + }); + + it("saves the cwd via the store; an empty value clears to null", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose }, + }); + + const input = screen.getByLabelText("Default working directory"); + await user.clear(input); + await user.click(screen.getByRole("button", { name: "Set" })); + expect(store.setDefaultCwd).toHaveBeenCalledWith("my-ws", null); + }); + + it("a failed cwd save surfaces the error", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + store.setDefaultCwd = vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ ok: false, error: "nope" }), + ); + render(WorkspaceDetails, { + props: { ws: fakeEntry({ defaultCwd: "/old" }), store, computers: [], onClose: () => {} }, + }); + + await user.type(screen.getByLabelText("Default working directory"), "!{Enter}"); + expect(await screen.findByText("nope")).toBeInTheDocument(); + }); + + it("picking a computer saves it via the store", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const computers: ComputerEntry[] = [ + { + alias: "lab", + hostName: "lab.example", + port: 22, + user: "me", + identityFile: null, + knownHost: true, + usageCount: 0, + }, + ]; + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store, computers, onClose: () => {} }, + }); + + await user.selectOptions(screen.getByLabelText("Computer"), "lab"); + expect(store.setDefaultComputer).toHaveBeenCalledWith("my-ws", "lab"); + }); + + it("the close button dismisses the dialog", async () => { + const user = userEvent.setup(); + const onClose = vi.fn(); + render(WorkspaceDetails, { + props: { ws: fakeEntry(), store: fakeStore(), computers: [], onClose }, + }); + + await user.click(screen.getByRole("button", { name: "Close details" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/workspaces/ui/WorkspaceTile.svelte b/src/features/workspaces/ui/WorkspaceTile.svelte new file mode 100644 index 0000000..b101192 --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import type { WorkspaceEntry } from "@dispatch/wire"; + import type { WorkspaceStore } from "../store.svelte"; + import { relativeTime } from "../logic/view-model"; + import type { Placement } from "../logic/tile-layout"; + + let { + ws, + store, + placement, + selected = false, + renaming = false, + active = false, + onRenameEnd, + }: { + ws: WorkspaceEntry; + store: WorkspaceStore; + /** The tile's resolved footprint (already preview-adjusted by the grid). */ + placement: Placement; + selected?: boolean; + /** The parent-driven rename mode (the action bar's Rename action). */ + renaming?: boolean; + /** At least one conversation in this workspace is generating/queued. */ + active?: boolean; + /** The tile finished its rename session (saved or cancelled). */ + onRenameEnd: () => void; + } = $props(); + + // ── Title: double-click to rename inline (semantics preserved from the + // WorkspaceCard), or enter via the action bar's Rename action (`renaming`). + let editing = $state(false); + let titleDraft = $state(""); + let titleInput = $state<HTMLInputElement | undefined>(); + let titleError = $state<string | null>(null); + + function startEdit(): void { + titleDraft = ws.title; + titleError = null; + editing = true; + queueMicrotask(() => titleInput?.focus()); + } + + // Seed (only) when the parent-driven rename mode turns on. + $effect(() => { + if (renaming) startEdit(); + }); + + async function saveTitle(): Promise<void> { + if (!editing) return; + const title = titleDraft.trim(); + editing = false; + onRenameEnd(); + if (title === "" || title === ws.title) return; + const result = await store.rename(ws.id, title); + if (!result.ok) titleError = result.error; + } + + function cancelTitle(): void { + editing = false; + titleError = null; + onRenameEnd(); + } + + // The face's semantic key (tests read it). + const faceKey = $derived(`tile-${placement.id}`); + + // Span-aware face: bigger footprints get more air + bigger type (all styles + // static so Tailwind sees them). + const wide = $derived(placement.colSpan > 1); + const tall = $derived(placement.rowSpan > 1); + const facePadding = $derived(wide || tall ? "p-5" : "p-4"); + const titleClass = $derived( + wide && tall + ? "text-3xl leading-tight line-clamp-3" + : wide + ? "text-2xl leading-snug line-clamp-2" + : tall + ? "text-xl leading-snug line-clamp-4" + : "text-lg leading-snug line-clamp-3", + ); + /** 1×1 tiles are tight: "20 conv." — wider tiles spell "conversations" out. */ + const countLabel = $derived( + ws.conversationCount === 1 ? (wide ? "conversation" : "conv.") : wide ? "conversations" : "conv.", + ); +</script> + +<div data-tile-face={faceKey} class={`relative flex h-full w-full flex-col ${facePadding}`}> + <!-- The slug label at the very top — the Win8 tile's small "app" caption. --> + <div class="flex items-center gap-1.5 text-[11px] font-semibold tracking-wider uppercase"> + <span class="truncate opacity-70">/{ws.id}</span> + {#if active} + <span + class="loading loading-dots loading-xs shrink-0" + aria-label="Workspace has active conversations" + title="A conversation in this workspace is generating"></span> + {/if} + </div> + + {#if ws.starred} + <!-- Starred is a badge, not a tint — the tone stays the tile's identity. --> + <span + class="absolute top-2.5 right-3 text-lg leading-none opacity-90" + aria-label="Starred workspace" + title="Starred — priority in the agent queue">★</span + > + {/if} + + {#if editing} + <input + bind:this={titleInput} + bind:value={titleDraft} + class="input input-sm mt-2 w-full border-current/40 bg-black/25 text-inherit placeholder:text-current/50" + aria-label="Workspace title" + onkeydown={(e) => { + if (e.key === "Enter") void saveTitle(); + else if (e.key === "Escape") cancelTitle(); + }} + onblur={() => void saveTitle()} + /> + {:else} + <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events --> + <span + class={`mt-1 cursor-default font-bold ${titleClass}`} + title="Double-click to rename" + ondblclick={startEdit}>{ws.title}</span + > + {/if} + + {#if titleError} + <p class="mt-1 text-xs font-semibold">{titleError}</p> + {/if} + + <!-- The stat line pinned to the tile's bottom edge, like a live tile's + status row. Single-line, truncation-safe (narrow 1×1 tiles stay clean). --> + <div + class="mt-auto flex items-center gap-2 overflow-hidden border-t border-current/20 pt-2 text-xs font-medium whitespace-nowrap" + > + <span class="min-w-0 truncate"> + {ws.conversationCount} + {countLabel} + </span> + <span class="ml-auto shrink-0 font-mono opacity-70"> + {relativeTime(ws.lastActivityAt, Date.now())} + </span> + </div> +</div> diff --git a/src/features/workspaces/ui/WorkspaceTile.test.ts b/src/features/workspaces/ui/WorkspaceTile.test.ts new file mode 100644 index 0000000..420791a --- /dev/null +++ b/src/features/workspaces/ui/WorkspaceTile.test.ts @@ -0,0 +1,171 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceResult } from "../adapter/http"; +import type { Placement } from "../logic/tile-layout"; +import type { WorkspaceStore } from "../store.svelte"; +import WorkspaceTile from "./WorkspaceTile.svelte"; + +function fakeEntry(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "my-ws", + title: "My Workspace", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: Date.now() - 90_000, // "1m" relative time + conversationCount: 3, + ...overrides, + }; +} + +function fakeStore(): WorkspaceStore { + return { + rename: vi.fn( + async (id: string, title: string): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: true, + value: fakeEntry({ id, title }), + }), + ), + } as unknown as WorkspaceStore; +} + +function fakePlacement(overrides: Partial<Placement> = {}): Placement { + return { id: "my-ws", col: 1, row: 1, colSpan: 1, rowSpan: 1, ...overrides }; +} + +describe("WorkspaceTile", () => { + it("renders the title, slug, conversation count, and relative last-activity", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText("My Workspace")).toBeInTheDocument(); + expect(screen.getByText("/my-ws")).toBeInTheDocument(); + // A 1×1 tile is tight: the count compacts to "conv." (wide tiles spell it out). + expect(screen.getByText(/3 conv\./)).toBeInTheDocument(); + expect(screen.getByText("1m")).toBeInTheDocument(); + }); + + it("spells out 'conversations' on a wide tile", () => { + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement({ colSpan: 2, rowSpan: 1 }), + onRenameEnd: () => {}, + }, + }); + expect(screen.getByText(/3 conversations/)).toBeInTheDocument(); + }); + + it("shows the active indicator only when the active port says so", async () => { + const { container, rerender } = render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: true, + onRenameEnd: () => {}, + }, + }); + expect( + container.querySelector('[aria-label="Workspace has active conversations"]'), + ).not.toBeNull(); + + await rerender({ + ws: fakeEntry(), + store: fakeStore(), + placement: fakePlacement(), + active: false, + onRenameEnd: () => {}, + }); + expect(container.querySelector('[aria-label="Workspace has active conversations"]')).toBeNull(); + }); + + it("double-clicking the title reveals the rename input (semantics preserved)", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + onRenameEnd: () => {}, + }, + }); + + await user.dblClick(screen.getByText("My Workspace")); + expect(screen.getByLabelText("Workspace title")).toHaveValue("My Workspace"); + }); + + it("the parent-driven rename mode (action bar) opens the same inline editor", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + const input = screen.getByLabelText("Workspace title"); + expect(input).toHaveValue("My Workspace"); + + await user.clear(input); + await user.type(input, "Renamed{Enter}"); + expect(store.rename).toHaveBeenCalledWith("my-ws", "Renamed"); + }); + + it("a failed rename surfaces the error on the face", async () => { + const user = userEvent.setup(); + const store = { + rename: vi.fn( + async (): Promise<WorkspaceResult<WorkspaceEntry>> => ({ + ok: false, + error: "boom", + }), + ), + } as unknown as WorkspaceStore; + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd: () => {}, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "x{Enter}"); + expect(screen.getByText("boom")).toBeInTheDocument(); + }); + + it("an unchanged title does not hit the store and still ends the session", async () => { + const user = userEvent.setup(); + const store = fakeStore(); + const onRenameEnd = vi.fn(); + render(WorkspaceTile, { + props: { + ws: fakeEntry(), + store, + placement: fakePlacement(), + renaming: true, + onRenameEnd, + }, + }); + + await user.type(screen.getByLabelText("Workspace title"), "{Enter}"); + expect(store.rename).not.toHaveBeenCalled(); + expect(onRenameEnd).toHaveBeenCalled(); + }); +}); diff --git a/src/features/workspaces/ui/WorkspacesHome.svelte b/src/features/workspaces/ui/WorkspacesHome.svelte index d97eab7..196e831 100644 --- a/src/features/workspaces/ui/WorkspacesHome.svelte +++ b/src/features/workspaces/ui/WorkspacesHome.svelte @@ -1,110 +1,363 @@ <script lang="ts"> - import { onMount } from "svelte"; + import { onMount, untrack } from "svelte"; import type { ComputerEntry } from "@dispatch/wire"; + import { + PluginManager, + WorkspacePluginModal, + type PluginsStore, + type WorkspaceLayoutPort, + } from "../../plugins"; import type { WorkspaceStore } from "../store.svelte"; - import { isValidSlug, workspacePath } from "../logic/route"; - import WorkspaceCard from "./WorkspaceCard.svelte"; + import { workspacePath } from "../logic/route"; + import { + DEFAULT_VIEWPORT, + geometryFor, + mergeLayout, + pack, + type GridGeometry, + type Span, + } from "../logic/tile-layout"; + import { type TileLayoutStore } from "../tile-layout-store.svelte"; + import HomeHeader from "./HomeHeader.svelte"; + import HomeTileGrid from "./HomeTileGrid.svelte"; + import TileActionBar from "./TileActionBar.svelte"; + import ResizePicker from "./ResizePicker.svelte"; + import WorkspaceDetails from "./WorkspaceDetails.svelte"; + import ArchivedWorkspaces from "./ArchivedWorkspaces.svelte"; let { store, onNavigate, computers, hasActive, + plugins, + layout, + tiles: tilesOverride, }: { store: WorkspaceStore; onNavigate: (path: string) => void; computers: readonly ComputerEntry[]; /** - * Optional port forwarded to each {@link WorkspaceCard}: whether the - * workspace has at least one active (generating / queued) conversation. + * Optional port: whether the workspace has at least one active + * (generating / queued) conversation — the tile's live indicator. * Wired by the composition root to the app store. Absent → no indicator. */ hasActive?: (workspaceId: string) => boolean; + /** + * The plugin manager store (plan 07): drives the header's global + * "Plugins" button + the action bar's per-workspace Plugins action. + * Optional (absent in tests that don't exercise plugins). + */ + plugins?: PluginsStore; + /** + * The layout port (plan 04 §4.4): forwarded to the per-workspace plugins + * modal's "Reset layout to default" affordance. Absent → the modal's + * layout section doesn't render. + */ + layout?: WorkspaceLayoutPort; + /** + * The synced tile layout store (ONE shared document per device + * orientation, persisted by the backend's home-tile-layout plugin). + * Created by the page entry (App.svelte — the composition root that owns + * the httpBase); tests inject one. + */ + tiles: TileLayoutStore; } = $props(); onMount(() => { void store.refresh(); }); - let newSlug = $state(""); - let slugError = $state<string | null>(null); + // The store arrives fully wired (the page entry created it over the real + // HTTP edge). The `untrack` capture (the repo's initial-value pattern, cf. + // App.svelte) dealiases the prop once — the component instance is the + // store's single owner from here on. + const tiles: TileLayoutStore = untrack(() => tilesOverride); + + // Keep the layout merged with the live workspace list: unknown workspaces + // (deep-link auto-creates) append at the end; deleted ones drop. The + // no-op guards inside sync keep this effect from looping. Skipped until + // the first non-empty list arrives — `sync([])` before the initial + // `refresh()` resolves would WIPE the persisted order (the backend always + // serves "default", so an empty list only ever means "not loaded yet"). + $effect(() => { + const ids = store.list.map((ws) => ws.id); + if (ids.length === 0) return; + tiles.sync(ids); + }); + + // ── The tile canvas' size decides the geometry (ONE pure function — no + // breakpoint classes). Start from the viewport, matching the boot probe; + // the canvas measurement then refines it (no transient wide reset on phones). + const initialViewport = typeof window === "undefined" + ? DEFAULT_VIEWPORT + : { width: window.innerWidth, height: window.innerHeight }; + let regionEl = $state<HTMLDivElement | undefined>(); + let size = $state<{ width: number; height: number } | undefined>(); + const geometry: GridGeometry = $derived( + geometryFor(size?.width ?? initialViewport.width, size?.height ?? initialViewport.height), + ); + + $effect(() => { + const el = regionEl; + if (!el || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const rect = entries[0]?.contentRect; + if (rect) size = { width: rect.width, height: rect.height }; + }); + observer.observe(el); + return () => observer.disconnect(); + }); + + // Lock the store's active orientation to the resolved grid geometry: the + // landscape/portrait split IS the device-type split — every landscape + // device shares the `wide` document, every portrait device the `tall` one. + // Same-mode calls are a no-op inside the store; an orientation change + // resets + re-probes (never flashing the other orientation's arrangement). + $effect(() => { + tiles.setMode(geometry.mode); + }); + + // The grid view: the SAME persisted data packs into either geometry — + // positions re-derive here, spans clamp for the RENDER only. + // Render-only fallback: workspace discovery must not wait for layout GET. + // Never sync/commit this provisional arrangement while the probe is unknown. + const entries = $derived(tiles.entries.length > 0 + ? tiles.entries + : mergeLayout(null, store.list.map((ws) => ws.id))); + const placements = $derived(pack(entries, geometry)); + const workspacesById = $derived(new Map(store.list.map((ws) => [ws.id, ws]))); + + // ── Selection + action bar ───────────────────────────────────────────────── + let selectedId = $state<string | null>(null); + const selected = $derived(selectedId !== null ? (workspacesById.get(selectedId) ?? null) : null); + + function select(id: string): void { + selectedId = id; + } + + function deselect(): void { + selectedId = null; + } + + // ── Rename mode (the create flow + the action bar's Rename action) ──────── + let renamingId = $state<string | null>(null); + + // ── Resize picker (transient ghost preview; commit persists) ────────────── + let resizing = $state<{ id: string; span: Span | null } | null>(null); + const resizingEntry = $derived( + resizing ? (tiles.entries.find((tile) => tile.id === resizing?.id) ?? null) : null, + ); + + function startResize(): void { + if (!selected) return; + const entry = tiles.entries.find((tile) => tile.id === selected.id); + resizing = { + id: selected.id, + span: entry + ? { colSpan: entry.colSpan, rowSpan: entry.rowSpan } + : { colSpan: 1, rowSpan: 1 }, + }; + } + + function commitResize(colSpan: number, rowSpan: number): void { + if (!resizing) return; + tiles.resize(resizing.id, colSpan, rowSpan); + resizing = null; + } + + function cancelResize(): void { + resizing = null; + } + + // ── Details flyout (the dense per-card settings). Id-based + gated on the + // selection, so deselecting (Escape) also dismisses it. ───────────────────── + let detailsForId = $state<string | null>(null); + const detailsFor = $derived( + selectedId !== null && detailsForId === selectedId + ? (workspacesById.get(selectedId) ?? null) + : null, + ); - const slugValid = $derived(newSlug.length > 0 && isValidSlug(newSlug)); + // ── Per-workspace plugin modal ───────────────────────────────────────────── + let pluginsModalId = $state<string | null>(null); + const pluginsModalFor = $derived( + pluginsModalId !== null ? (workspacesById.get(pluginsModalId) ?? null) : null, + ); - function createWorkspace(): void { - const slug = newSlug.trim(); - if (!isValidSlug(slug)) { - slugError = "Lowercase letters, digits, and hyphens (1–40 chars)."; + // Global plugin manager modal (plan 07 §7.2): the ONLY surface that + // loads/unloads code (strict layering). + let managerOpen = $state(false); + + // ── Archived workspaces modal ────────────────────────────────────────────── + let archivedOpen = $state(false); + + // ── Actions (the action bar) ─────────────────────────────────────────────── + function openWorkspace(id: string): void { + deselect(); + onNavigate(workspacePath(id)); + } + + async function deleteSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + if ( + !window.confirm( + `Delete workspace "${ws.title}"? Its conversations will be closed and moved to "default".`, + ) + ) { return; } - slugError = null; - newSlug = ""; - onNavigate(workspacePath(slug)); + const result = await store.remove(ws.id); + if (result.ok) deselect(); + } + + async function toggleStar(): Promise<void> { + if (!selected) return; + await store.setStarred(selected.id, !selected.starred); + } + + async function archiveSelected(): Promise<void> { + if (!selected) return; + const ws = selected; + // Reversible without a confirm dialog — the workspace is hidden, not deleted. + const result = await store.setArchived(ws.id, true); + if (result.ok) deselect(); + } + + // ── Create (header popover): check the slug resolves to no archived + // workspace, create on the backend — do NOT navigate — place the new tile in + // the first empty slot, and go straight into rename. + async function createWorkspace( + slug: string, + ): Promise<{ ok: true } | { ok: false; error: string }> { + const result = await store.create(slug); + if (!result.ok) return { ok: false, error: result.error }; + await store.refresh(); + // Idempotent: if the list refresh's sync already appended the id, this + // MOVES it into the first empty slot instead of duplicating. + tiles.placeFirstEmpty(slug, geometry); + renamingId = slug; + return { ok: true }; + } + + // ── Sort overflow: re-apply the recent-activity order ────────────────────── + function sortByRecent(): void { + const activity = new Map(store.list.map((ws) => [ws.id, ws.lastActivityAt])); + tiles.sortByRecentActivity(activity); + } + + // Escape unwinds the overlays: resize → selection. + function handleKeydown(e: KeyboardEvent): void { + if (e.key !== "Escape") return; + if (resizing) { + cancelResize(); + } else if (selectedId !== null) { + deselect(); + } } </script> -<div class="mx-auto flex h-screen w-full max-w-3xl flex-col gap-4 p-6"> - <header class="flex items-center justify-between"> - <h1 class="text-2xl font-bold">Workspaces</h1> - <a - href="/default" - class="btn btn-ghost btn-sm" - onclick={(e) => { - e.preventDefault(); - onNavigate("/default"); - }} - > - Default - </a> - </header> - - <form - class="flex items-end gap-2" - onsubmit={(e) => { - e.preventDefault(); - createWorkspace(); - }} - > - <div class="flex-1"> - <label for="new-ws" class="mb-1 block text-xs font-semibold uppercase opacity-60" - >New workspace</label - > - <input - id="new-ws" - class="input input-bordered w-full" - placeholder="my-workspace" - bind:value={newSlug} - autocomplete="off" - spellcheck="false" - /> - </div> - <button type="submit" class="btn btn-primary btn-sm" disabled={!slugValid}>Create</button> - </form> - {#if slugError} - <p class="text-xs text-error">{slugError}</p> - {/if} - - <div class="flex-1 overflow-y-auto"> +<svelte:window onkeydown={handleKeydown} /> + +<div class="h-app flex w-full flex-col"> + <HomeHeader + compact={geometry.mode === "tall"} + withPlugins={plugins !== undefined} + withSort={store.list.length > 1} + onCreate={createWorkspace} + onOpenPlugins={() => (managerOpen = true)} + onSort={() => void sortByRecent()} + onOpenArchived={() => (archivedOpen = true)} + /> + + <div bind:this={regionEl} class="min-h-0 w-full flex-1"> {#if store.loading && store.list.length === 0} - <div class="flex h-32 items-center justify-center"> - <span class="loading loading-spinner loading-sm opacity-60"></span> + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-lg opacity-60"></span> </div> {:else if store.list.length === 0} - <p class="py-8 text-center text-sm opacity-60"> - No workspaces yet. Create one above or visit <code>/your-name</code> in the URL. - </p> + <div class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center"> + <p class="text-2xl font-black tracking-tight opacity-80">No workspaces yet</p> + <p class="max-w-md text-sm opacity-60"> + Create one with <strong>New workspace</strong> above, or visit + <code class="rounded bg-base-200 px-1.5 py-0.5">/your-name</code> in the URL — the first + tile drops onto the canvas ready to rename. + </p> + </div> {:else} - <ul class="flex flex-col gap-2"> - {#each store.list as ws (ws.id)} - <WorkspaceCard - {ws} - {store} - {onNavigate} - {computers} - {...(hasActive ? { hasActive } : {})} - /> - {/each} - </ul> + <HomeTileGrid + {geometry} + {placements} + {workspacesById} + {store} + {selectedId} + {renamingId} + preview={ + resizing?.id !== undefined && resizing.span !== null + ? { id: resizing.id, ...resizing.span } + : null + } + {...(hasActive ? { hasActive } : {})} + onOpen={openWorkspace} + onSelect={select} + onPlace={(id, cell) => cell !== null && tiles.placeAtCell(id, cell, geometry)} + onRenameEnd={() => (renamingId = null)} + /> {/if} </div> </div> + +{#if selected && !renamingId} + <TileActionBar + ws={selected} + withPlugins={plugins !== undefined} + onOpen={() => openWorkspace(selected.id)} + onRename={() => (renamingId = selected.id)} + onResize={startResize} + onToggleStar={() => void toggleStar()} + onDetails={() => (detailsForId = selected.id)} + onPlugins={() => (pluginsModalId = selected.id)} + onArchive={() => void archiveSelected()} + onDelete={() => void deleteSelected()} + onClose={deselect} + /> +{/if} + +{#if resizing && resizingEntry} + <ResizePicker + {geometry} + initial={{ colSpan: resizingEntry.colSpan, rowSpan: resizingEntry.rowSpan }} + onPreview={(span) => { + if (resizing) resizing = { id: resizing.id, span }; + }} + onCommit={commitResize} + onCancel={cancelResize} + /> +{/if} + +{#if detailsFor} + <WorkspaceDetails ws={detailsFor} {store} {computers} onClose={() => (detailsForId = null)} /> +{/if} + +{#if pluginsModalFor && plugins} + <WorkspacePluginModal + workspaceId={pluginsModalFor.id} + workspaceTitle={pluginsModalFor.title} + {plugins} + {...(layout ? { layout } : {})} + onClose={() => (pluginsModalId = null)} + /> +{/if} + +{#if managerOpen && plugins} + <PluginManager {plugins} onClose={() => (managerOpen = false)} /> +{/if} + +{#if archivedOpen} + <ArchivedWorkspaces + {store} + onOpen={(id) => openWorkspace(id)} + onClose={() => (archivedOpen = false)} + /> +{/if} diff --git a/src/features/workspaces/ui/WorkspacesHome.test.ts b/src/features/workspaces/ui/WorkspacesHome.test.ts new file mode 100644 index 0000000..5a66a42 --- /dev/null +++ b/src/features/workspaces/ui/WorkspacesHome.test.ts @@ -0,0 +1,333 @@ +import type { TileGeometryMode, TileLayoutData } from "@dispatch/tile-layout-contract"; +import type { Workspace, WorkspaceEntry } from "@dispatch/wire"; +import { render, screen, waitFor } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceHttp, WorkspaceResult } from "../adapter/http"; +import type { TileLayoutHttp, TileLayoutHttpResult } from "../adapter/tile-layout-http"; +import type { WorkspaceLookup } from "../logic/create"; +import { createTileLayoutStore } from "../tile-layout-store.svelte"; +import WorkspacesHome from "./WorkspacesHome.svelte"; + +function ws(overrides: Partial<WorkspaceEntry> = {}): WorkspaceEntry { + return { + id: "a", + title: "A", + defaultCwd: null, + defaultComputerId: null, + starred: false, + archived: false, + createdAt: 1, + lastActivityAt: 2, + conversationCount: 1, + ...overrides, + }; +} + +/** A fake WorkspaceHttp edge with a controllable list (no mocks of our modules). */ +function fakeHttp( + list: WorkspaceEntry[] = [ws()], + lookup: (id: string) => Promise<WorkspaceLookup> = async () => ({ kind: "missing" }), +): WorkspaceHttp { + return { + list: vi.fn(async (): Promise<WorkspaceEntry[]> => list), + // Create-on-miss: the backend persists the new workspace, so the next + // `list` serves it too. + ensure: vi.fn(async (id: string): Promise<WorkspaceResult<Workspace>> => { + if (!list.some((w) => w.id === id)) list.push(ws({ id, title: id })); + return { ok: true, value: ws({ id, title: id }) }; + }), + get: vi.fn(lookup), + setTitle: vi.fn(), + setDefaultCwd: vi.fn(), + setDefaultComputer: vi.fn(), + star: vi.fn(), + unstar: vi.fn(), + delete: vi.fn(async (): Promise<WorkspaceResult<{ closedCount: number }>> => { + // Mirrors the backend: the workspace leaves the list. + list.splice(0); + return { ok: true, value: { closedCount: 0 } }; + }), + } as unknown as WorkspaceHttp; +} + +/** + * A fake tile-layout HTTP edge serving ONE controllable document per mode + * (no mocks of our modules). `docs` is the live store — assertions read it + * after `waitFor` (the boot probe + debounced sync writes land there). + */ +function memoryLayout( + initial: TileLayoutData | null = null, +): TileLayoutHttp & { docs: Map<TileGeometryMode, TileLayoutData> } { + const docs = new Map<TileGeometryMode, TileLayoutData>(); + if (initial !== null) docs.set("wide", initial); + return { + docs, + async getLayout(mode) { + return { ok: true, layout: docs.get(mode) ?? { version: 2, tiles: [] } }; + }, + async putLayout(mode, layout) { + docs.set(mode, layout); + return { ok: true, layout }; + }, + async deleteLayout(mode) { + docs.set(mode, { version: 2, tiles: [] }); + return { ok: true, layout: docs.get(mode) as TileLayoutData }; + }, + }; +} + +describe("WorkspacesHome (tile canvas)", () => { + it("renders one tile per workspace, in the persisted tile order (not the list order)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 1, rowSpan: 1 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); + }); + + it.each([ + false, + true, + ])("renders before the layout probe resolves (cached: %s)", async (cached) => { + const layout = memoryLayout(); + const gate: { release?: (result: TileLayoutHttpResult) => void } = {}; + layout.getLayout = vi.fn( + () => + new Promise<TileLayoutHttpResult>((resolve) => { + gate.release = resolve; + }), + ); + const put = vi.spyOn(layout, "putLayout"); + const stored = { + version: 2, + tiles: [ + { id: "b", order: 0, colSpan: 2, rowSpan: 2 }, + { id: "a", order: 1, colSpan: 1, rowSpan: 1 }, + ], + }; + const storage = cached ? localStorage : undefined; + storage?.setItem("dispatch.tile-layout.tall", JSON.stringify(stored)); + const width = vi.spyOn(window, "innerWidth", "get").mockReturnValue(390); + const height = vi.spyOn(window, "innerHeight", "get").mockReturnValue(844); + const tiles = createTileLayoutStore({ http: layout, initialMode: "tall", storage }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore( + fakeHttp([ws(), ws({ id: "b", title: "B" })]), + ), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + await screen.findByRole("button", { name: "Open workspace B" }); + expect( + [...document.querySelectorAll("[data-tile-id]")].map((el) => + el.getAttribute("data-tile-id"), + ), + ).toEqual(cached ? ["b", "a"] : ["a", "b"]); + expect(tiles.providerState).toBe("unknown"); + expect(tiles.entries).toEqual(cached ? stored.tiles : []); + expect(layout.getLayout).toHaveBeenCalledExactlyOnceWith("tall"); + expect(put).not.toHaveBeenCalled(); + gate.release?.({ + ok: true, + layout: { + version: 2, + tiles: stored.tiles + .slice() + .reverse() + .map((tile, order) => ({ ...tile, order })), + }, + }); + await waitFor(() => { + expect(tiles.providerState).toBe("present"); + expect(tiles.entries.map((tile) => tile.id)).toEqual(["a", "b"]); + // CSS-grid coordinates determine visual order; keyed DOM nodes may stay put. + expect(document.querySelector('[data-tile-id="a"]')).toHaveStyle("grid-row: 1 / span 1"); + expect(document.querySelector('[data-tile-id="b"]')).toHaveStyle("grid-row: 2 / span 2"); + }); + } finally { + width.mockRestore(); + height.mockRestore(); + storage?.removeItem("dispatch.tile-layout.tall"); + } + }); + + it("re-probes when the canvas changes orientation", async () => { + let measure: ResizeObserverCallback = () => {}; + const observer = vi.spyOn(globalThis, "ResizeObserver").mockImplementation((callback) => { + measure = callback; + return { observe() {}, unobserve() {}, disconnect() {} }; + }); + const layout = memoryLayout(); + const get = vi.spyOn(layout, "getLayout"); + const tiles = createTileLayoutStore({ http: layout }); + try { + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate: vi.fn(), + computers: [], + tiles, + }, + }); + await screen.findByRole("button", { name: "Open workspace A" }); + measure( + [{ contentRect: { width: 390, height: 700 } }] as ResizeObserverEntry[], + {} as ResizeObserver, + ); + await waitFor(() => { + expect(tiles.mode).toBe("tall"); + expect(get.mock.calls).toEqual([["wide"], ["tall"]]); + }); + await screen.findByRole("button", { name: "Open workspace A" }); + } finally { + observer.mockRestore(); + } + }); + + it("clicking a tile navigates to the workspace path", async () => { + const user = userEvent.setup(); + const onNavigate = vi.fn(); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp()), + onNavigate, + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(tile); + expect(onNavigate).toHaveBeenCalledWith("/a"); + }); + + it("creating a workspace from the header does NOT navigate; the new tile enters rename mode", async () => { + const user = userEvent.setup(); + const list = [ws({ id: "a", title: "A" })]; + const onNavigate = vi.fn(); + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate, + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "fresh-ws{Enter}"); + + await waitFor(() => { + expect(screen.getByLabelText("Workspace title", { selector: "input" })).toBeInTheDocument(); + }); + expect(onNavigate).not.toHaveBeenCalled(); + // The backend was asked to create it (create-on-miss). + expect(store.list.map((w) => w.id)).toContain("fresh-ws"); + }); + + it("refuses to create an archived slug: message in the popover, form stays open, no tile", async () => { + const user = userEvent.setup(); + const list = [ws({ id: "a", title: "A" })]; + const http = fakeHttp(list, async (id) => ({ + kind: "found", + workspace: ws({ id, title: id, archived: true }), + })); + const store = (await import("../store.svelte")).createWorkspaceStore(http); + render(WorkspacesHome, { + props: { + store, + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace A" }); + await user.click(screen.getByRole("button", { name: "New workspace" })); + await user.type(screen.getByRole("textbox", { name: "New workspace" }), "old-ws{Enter}"); + + expect(await screen.findByText(/already exists and is archived/)).toBeInTheDocument(); + // The popover stays open (the form/input is still mounted). + expect(screen.getByRole("textbox", { name: "New workspace" })).toBeInTheDocument(); + // Nothing was ensured, no tile appeared, and no rename mode was entered. + expect(http.ensure).not.toHaveBeenCalled(); + expect(store.list.map((w) => w.id)).toEqual(["a"]); + expect(screen.queryByLabelText("Workspace title")).toBeNull(); + }); + + it("right-clicking a tile opens the action bar; Delete keeps the confirm + store call", async () => { + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + const list = [ws({ id: "a", title: "A" })]; + const store = (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)); + render(WorkspacesHome, { + props: { + store, + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: memoryLayout() }), + }, + }); + + const tile = await screen.findByRole("button", { name: "Open workspace A" }); + await user.pointer({ keys: "[MouseRight]", target: tile }); + + const deleteButton = await screen.findByRole("button", { name: "Delete" }); + await user.click(deleteButton); + + expect(confirmSpy).toHaveBeenCalledWith(expect.stringContaining('Delete workspace "A"?')); + await waitFor(() => { + expect(store.list.length).toBe(0); + }); + confirmSpy.mockRestore(); + }); + + it("workspaces unknown to the layout append at the end (deep-link auto-create)", async () => { + const list = [ws({ id: "a", title: "A" }), ws({ id: "b", title: "B" })]; + const layout = memoryLayout({ + version: 2, + tiles: [{ id: "b", order: 0, colSpan: 1, rowSpan: 1 }], + }); + render(WorkspacesHome, { + props: { + store: (await import("../store.svelte")).createWorkspaceStore(fakeHttp(list)), + onNavigate: vi.fn(), + computers: [], + tiles: createTileLayoutStore({ http: layout }), + }, + }); + + await screen.findByRole("button", { name: "Open workspace B" }); + await screen.findByRole("button", { name: "Open workspace A" }); + const wrappers = document.querySelectorAll("[data-tile-id]"); + expect([...wrappers].map((el) => el.getAttribute("data-tile-id"))).toEqual(["b", "a"]); + // And the sync persisted the appended entry. + await waitFor(() => { + const tiles = layout.docs.get("wide")?.tiles ?? []; + expect(tiles.map((tile) => tile.id)).toEqual(["b", "a"]); + }); + }); +}); |
