diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 15:03:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 15:03:54 +0900 |
| commit | 9d6b7a97e8e96429815503718e1437fae41bf5d5 (patch) | |
| tree | ba3b3a95454a6d150e34b595d92d39acacb8ad6a | |
| parent | ecb001ec7a2e573d8dedf5064e860e5a3e7788fd (diff) | |
| parent | 40b0b6a23a5cbd494f9956315c2e424d16edb282 (diff) | |
| download | dispatch-9d6b7a97e8e96429815503718e1437fae41bf5d5.tar.gz dispatch-9d6b7a97e8e96429815503718e1437fae41bf5d5.zip | |
Merge branch 'dev' into td/todo-fix
| -rw-r--r-- | HANDOFF.md | 73 | ||||
| -rw-r--r-- | packages/api/src/routes/models.ts | 64 | ||||
| -rw-r--r-- | packages/api/src/routes/tabs.ts | 13 | ||||
| -rw-r--r-- | packages/core/src/credentials/claude.ts | 38 | ||||
| -rw-r--r-- | packages/core/src/credentials/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/src/db/tabs.ts | 14 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 1 | ||||
| -rw-r--r-- | packages/core/tests/credentials/wake-probe.test.ts | 49 | ||||
| -rw-r--r-- | packages/core/tests/db/tabs.test.ts | 69 | ||||
| -rw-r--r-- | packages/frontend/src/App.svelte | 2 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/CacheRatePanel.svelte | 7 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/ChatInput.svelte | 162 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 21 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/TabBar.svelte | 89 | ||||
| -rw-r--r-- | packages/frontend/src/lib/tabs.svelte.ts | 81 | ||||
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 154 |
16 files changed, 776 insertions, 62 deletions
diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..a8fa7f6 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,73 @@ +# Handoff — cr/claude-reset-fix + +## Objective +Make the Claude Wake Schedule ("Claude reset") system work reliably end to end. + +## Root cause found +The two issues named in the task brief (toggle endpoint ignoring client intent; +server-side request reordering desyncing the UI) were **already fixed** in this +worktree from prior rounds — the toggle endpoint requires an explicit +`action: 'on' | 'off'`, and the frontend serializes mutations behind a global +`pendingHour` lock (verified: tests + code present, suite green). + +The *actual* live failure (reproduced by the user: `✗ Last wake 4 min ago — +failed`, blank reason, then `Retrying (6 left…)`) was in the **wake probe +itself**, not the scheduler or UI: + +`wakeAllClaudeAccounts()` POSTed a bare body +`{ model, max_tokens, messages: [{role:"user",content:"hi"}] }` with **no +`system[]`**. These accounts are OAuth (Claude Pro/Max) subscriptions, and +Anthropic validates `system[]` on Claude-Code-billed OAuth requests — it +rejects (401/403) any request whose system block lacks the verbatim Claude Code +identity string. So **every** scheduled wake and the manual "Wake now" button +failed. The old code recorded only `ok: res.ok` with no error text, surfacing as +a blank "— failed" that then burned the 6×5-min retry budget. + +## Files changed +- **`packages/core/src/credentials/claude.ts`** — new pure + `buildWakeProbeBody(model)` that mirrors a genuine Claude Code request: + `system: [{billing-header}, {identity}]` + `messages:[{role:"user",content:"hi"}]`, + `max_tokens: 16`. Reuses existing `buildBillingHeaderValue` + `SYSTEM_IDENTITY`. +- **`packages/core/src/credentials/index.ts`** — export `buildWakeProbeBody` + (reachable as `@dispatch/core`). +- **`packages/api/src/routes/models.ts`** — `wakeAllClaudeAccounts` now sends + `buildWakeProbeBody(WAKE_PROBE_MODEL)` plus the CLI session headers + (`X-Claude-Code-Session-Id`, `x-client-request-id`), and on `!res.ok` records + `HTTP <status>: <message>` via new `describeFailedResponse(res)` so the panel + never shows a bare "failed" again and breakage stays debuggable. +- **`packages/core/tests/credentials/wake-probe.test.ts`** — new: 4 unit tests + asserting the probe body shape (model/tokens, billing-first/identity-second + system[], single "hi" user message, determinism). + +## Public surface changed +- New export `@dispatch/core` → `buildWakeProbeBody(model: string)`. +- No API route signatures changed. `POST /models/wake`, `POST + /models/wake-schedule/toggle`, `GET /models/wake-schedule` request/response + shapes are unchanged. The only externally visible behavior change: failed + wakes now carry a descriptive `error` string (`HTTP <status>: <message>`) + instead of an empty one, which the panel already renders. +- DB schema: unchanged. + +## Verification +- `bun run check` (biome) → clean, 164 files. +- `bun run test` (vitest) → **568 passed** (post-merge with dev; +4 from this + branch's new probe-body tests). +- `tsc -p packages/core` and `tsc -p packages/api` → exit 0. +- `svelte-check` (frontend) → 0 errors, 0 warnings. + +## Published +Yes. Merged `dev` down into `cr/claude-reset-fix` (clean merge, no conflicts), +re-verified all-green, then `git push . HEAD:dev` (fast-forward, accepted). + +## Assumptions / known gaps +- **Live API not testable from the agent sandbox.** The fix is verified offline + (body shape + headers match the real `transformClaudeOAuthBody` provider path + and unit tests). The actual "200 OK wake" requires real OAuth credentials and + was deferred to the user-test gate. If a probe still fails, the panel now shows + a concrete `HTTP <status>: <message>` reason to diagnose from. +- **Probe model** is hardcoded `claude-3-5-haiku-20241022` (`WAKE_PROBE_MODEL` + in `models.ts`). Cheap/small by design — only needs to register activity. +- **Deferred (unchanged design trade-offs, pre-existing):** DST drift on the + 24h advance; no background snapshot polling (UI may show stale "Retrying…" + until next user interaction); retry storm re-probes already-succeeded accounts. + None of these block reliable wakes; the probe fix addresses the live failure. diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts index 6a0f5dc..8f64bbb 100644 --- a/packages/api/src/routes/models.ts +++ b/packages/api/src/routes/models.ts @@ -1,8 +1,10 @@ +import { randomUUID } from "node:crypto"; import { readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; import type { ModelRegistry } from "@dispatch/core"; import { ANTHROPIC_MODELS_FALLBACK, + buildWakeProbeBody, type ClaudeAccount, fetchAnthropicModels, fetchCopilotUsage, @@ -566,6 +568,39 @@ modelsRoutes.post("/remove-key", async (c) => { // ─── Shared wake function ───────────────────────────────────── +/** + * Model used for the wake probe. A small/cheap model is enough — the only + * purpose is to register activity against the subscription so its rate-limit + * window keeps resetting on schedule. + */ +const WAKE_PROBE_MODEL = "claude-3-5-haiku-20241022"; + +/** Max chars of upstream error body to keep in the surfaced message. */ +const MAX_ERROR_BODY_CHARS = 200; + +/** + * Turn a non-OK probe response into a short, human-readable reason. Anthropic + * returns a JSON error envelope (`{ error: { message } }`); fall back to a + * truncated raw body, then to the bare status. Never throws. + */ +async function describeFailedResponse(res: Response): Promise<string> { + let detail = ""; + try { + const text = await res.text(); + try { + const parsed = JSON.parse(text) as { error?: { message?: unknown } }; + const message = parsed?.error?.message; + detail = typeof message === "string" ? message : text; + } catch { + detail = text; + } + } catch { + detail = ""; + } + detail = detail.trim().slice(0, MAX_ERROR_BODY_CHARS); + return detail ? `HTTP ${res.status}: ${detail}` : `HTTP ${res.status}`; +} + async function wakeAllClaudeAccounts(): Promise< Array<{ label: string; ok: boolean; error?: string }> > { @@ -596,20 +631,37 @@ async function wakeAllClaudeAccounts(): Promise< continue; } + // Mirror a genuine Claude Code CLI request. These are OAuth + // (Pro/Max) subscription accounts: Anthropic validates the + // `system[]` array and rejects (401/403) any request whose system + // block lacks the verbatim Claude Code identity string. A bare + // `{ model, messages }` body — what this probe used to send — + // always failed, which is why scheduled wakes silently died with a + // blank "failed" status. `buildWakeProbeBody` produces the correct + // shape (billing header + identity); the session/request-id headers + // match what the real CLI stamps so the probe isn't flagged. const res = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { ...getAnthropicHeaders(creds.accessToken), "content-type": "application/json", + "X-Claude-Code-Session-Id": randomUUID(), + "x-client-request-id": randomUUID(), }, - body: JSON.stringify({ - model: "claude-3-5-haiku-20241022", - max_tokens: 16, - messages: [{ role: "user", content: "hi" }], - }), + body: JSON.stringify(buildWakeProbeBody(WAKE_PROBE_MODEL)), }); - results.push({ label: acct.label, ok: res.ok }); + if (res.ok) { + results.push({ label: acct.label, ok: true }); + } else { + // Surface WHY it failed so the panel never shows a bare + // "failed" again and breakage stays debuggable. + results.push({ + label: acct.label, + ok: false, + error: await describeFailedResponse(res), + }); + } } catch (err) { results.push({ label: acct.label, diff --git a/packages/api/src/routes/tabs.ts b/packages/api/src/routes/tabs.ts index f52ee99..28a89f1 100644 --- a/packages/api/src/routes/tabs.ts +++ b/packages/api/src/routes/tabs.ts @@ -11,6 +11,7 @@ import { listOpenTabs, setSetting, updateTabModel, + updateTabPositions, updateTabStatus, updateTabTitle, } from "@dispatch/core"; @@ -63,6 +64,18 @@ tabsRoutes.put("/settings/title-model", async (c) => { return c.json({ success: true }); }); +// Reorder open tabs. Body `{ ids }` is the new left-to-right order of tab ids; +// each tab's `position` is rewritten to its index. Must be declared before the +// `/:id` routes so "reorder" isn't captured as an id param. +tabsRoutes.patch("/reorder", async (c) => { + const body = await c.req.json<{ ids?: string[] }>(); + if (!Array.isArray(body.ids) || body.ids.some((id) => typeof id !== "string")) { + return c.json({ error: "ids must be an array of strings" }, 400); + } + updateTabPositions(body.ids); + return c.json({ success: true }); +}); + tabsRoutes.get("/:id", (c) => { const id = c.req.param("id"); const tab = getTab(id); diff --git a/packages/core/src/credentials/claude.ts b/packages/core/src/credentials/claude.ts index 168d544..432e403 100644 --- a/packages/core/src/credentials/claude.ts +++ b/packages/core/src/credentials/claude.ts @@ -373,6 +373,44 @@ export function buildBillingHeaderValue( export const SYSTEM_IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; +/** + * Build the request body for a Claude "wake" probe — a tiny, cheap message + * whose only purpose is to keep the subscription's rate-limit window warm. + * + * This MUST mirror the shape of a genuine Claude Code CLI request, because + * Anthropic validates the `system[]` array on OAuth (Pro/Max) -authenticated, + * Claude-Code-billed requests. A bare `{ model, messages }` body (no system + * identity) is rejected (401/403) — which is exactly how the old probe silently + * failed. The valid shape is: + * + * system: [ + * { type: "text", text: "x-anthropic-billing-header: ..." }, // billing, no cache_control + * { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + * ] + * messages: [ { role: "user", content: "hi" } ] + * + * Mirrors the runtime `transformClaudeOAuthBody` output for a single short user + * turn. Pure: deterministic given its inputs (the billing header samples only + * the user text), so it can be unit-tested without touching the network. + */ +export function buildWakeProbeBody(model: string): { + model: string; + max_tokens: number; + system: Array<{ type: "text"; text: string }>; + messages: Array<{ role: "user"; content: string }>; +} { + const messages = [{ role: "user" as const, content: "hi" }]; + return { + model, + max_tokens: 16, + system: [ + { type: "text", text: buildBillingHeaderValue(messages) }, + { type: "text", text: SYSTEM_IDENTITY }, + ], + messages, + }; +} + // ─── Anthropic Request Headers ──────────────────────────────── export function getAnthropicHeaders(accessToken: string): Record<string, string> { diff --git a/packages/core/src/credentials/index.ts b/packages/core/src/credentials/index.ts index ff7392b..46fa5b6 100644 --- a/packages/core/src/credentials/index.ts +++ b/packages/core/src/credentials/index.ts @@ -9,6 +9,7 @@ export { export { ANTHROPIC_MODELS_FALLBACK, buildBillingHeaderValue, + buildWakeProbeBody, type ClaudeAccount, type ClaudeCredentials, type ClaudeProfile, diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts index 8b290d2..f719a01 100644 --- a/packages/core/src/db/tabs.ts +++ b/packages/core/src/db/tabs.ts @@ -115,6 +115,20 @@ export function updateTabStatus(id: string, status: string): void { }); } +export function updateTabPositions(idsInOrder: string[]): void { + const db = getDatabase(); + const now = Date.now(); + const update = db.query("UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id"); + // One transaction so a reorder is atomic: either every tab lands at its new + // slot or none does, never a half-applied ordering. + const applyAll = db.transaction(() => { + idsInOrder.forEach((id, index) => { + update.run({ $id: id, $position: index, $now: now }); + }); + }); + applyAll(); +} + export function archiveTab(id: string): void { const db = getDatabase(); db.query("UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id").run({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a7b1cad..8334102 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,6 +56,7 @@ export { shortestUniquePrefix, type TabRow, updateTabModel, + updateTabPositions, updateTabStatus, updateTabTitle, } from "./db/tabs.js"; diff --git a/packages/core/tests/credentials/wake-probe.test.ts b/packages/core/tests/credentials/wake-probe.test.ts new file mode 100644 index 0000000..253efec --- /dev/null +++ b/packages/core/tests/credentials/wake-probe.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from "vitest"; + +// `claude.ts` transitively imports `db/index.js`, whose top-level +// `import { Database } from "bun:sqlite"` can't resolve under vitest's Node +// runtime. Stub the db module — `buildWakeProbeBody` never touches it. +vi.mock("../../src/db/index.js", () => ({ + getDatabase: vi.fn(() => { + throw new Error("db not available in this test"); + }), +})); + +const { buildWakeProbeBody } = await import("../../src/credentials/claude.js"); + +const IDENTITY = "You are Claude Code, Anthropic's official CLI for Claude."; + +describe("buildWakeProbeBody", () => { + it("targets the requested model with a tiny token budget", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.model).toBe("claude-3-5-haiku-20241022"); + expect(body.max_tokens).toBe(16); + }); + + it("emits a Claude-Code-shaped system[]: billing first, identity second", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.system).toHaveLength(2); + + // system[0] is the billing header line (no cache_control on a probe). + expect(body.system[0]).toEqual({ + type: "text", + text: expect.stringMatching(/^x-anthropic-billing-header: /), + }); + expect(body.system[0]).not.toHaveProperty("cache_control"); + + // system[1] is the VERBATIM Claude Code identity string. Anthropic + // rejects OAuth (Pro/Max) requests whose system[] lacks this. + expect(body.system[1]).toEqual({ type: "text", text: IDENTITY }); + }); + + it("carries a single short user message", () => { + const body = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(body.messages).toEqual([{ role: "user", content: "hi" }]); + }); + + it("is deterministic for a given model (pure)", () => { + const a = buildWakeProbeBody("claude-3-5-haiku-20241022"); + const b = buildWakeProbeBody("claude-3-5-haiku-20241022"); + expect(a).toEqual(b); + }); +}); diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts index 67533dc..2cd226b 100644 --- a/packages/core/tests/db/tabs.test.ts +++ b/packages/core/tests/db/tabs.test.ts @@ -50,6 +50,15 @@ class FakeDatabase { }; } + /** + * Match Bun's `db.transaction(fn)` shape: returns a callable that runs + * `fn` synchronously. The fake is in-memory and single-threaded, so we + * don't emulate rollback — callers just need the wrapper to be invocable. + */ + transaction(fn: () => void): () => void { + return () => fn(); + } + private execSelect(sql: string, params?: Record<string, unknown>): unknown[] { const norm = sql.replace(/\s+/g, " ").trim(); @@ -89,6 +98,11 @@ class FakeDatabase { return this.rows.filter((r) => r.is_open === 1).map((r) => ({ id: r.id })); } + // listOpenTabs: every open tab ordered by position. + if (norm === "SELECT * FROM tabs WHERE is_open = 1 ORDER BY position ASC") { + return this.rows.filter((r) => r.is_open === 1).sort((a, b) => a.position - b.position); + } + throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`); } @@ -129,6 +143,16 @@ class FakeDatabase { return; } + // updateTabPositions: rewrite a single tab's position (run per id inside a txn) + if (norm === "UPDATE tabs SET position = $position, updated_at = $now WHERE id = $id") { + const row = this.rows.find((r) => r.id === params?.$id); + if (row) { + row.position = (params?.$position as number) ?? row.position; + row.updated_at = (params?.$now as number) ?? Date.now(); + } + return; + } + throw new Error(`FakeDatabase: unsupported mutation: ${norm}`); } } @@ -150,8 +174,16 @@ vi.mock("../../src/db/index.js", () => ({ // Dynamic import AFTER `vi.mock` registers (vitest hoists `vi.mock` to // the very top of the file, so by the time this line runs the mock is // active for `./index.js` resolution inside `tabs.ts`). -const { archiveTab, createTab, getDescendantIds, getTab, resolveTabPrefix, shortestUniquePrefix } = - await import("../../src/db/tabs.js"); +const { + archiveTab, + createTab, + getDescendantIds, + getTab, + listOpenTabs, + resolveTabPrefix, + shortestUniquePrefix, + updateTabPositions, +} = await import("../../src/db/tabs.js"); beforeAll(() => { fakeDb = new FakeDatabase(); @@ -351,3 +383,36 @@ describe("shortestUniquePrefix", () => { expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd"); }); }); + +// --------------------------------------------------------------------------- +// updateTabPositions — drag-and-drop reorder persistence +// --------------------------------------------------------------------------- +describe("updateTabPositions", () => { + it("rewrites each tab's position to its index in the given order", () => { + createTab("a", "A"); // position 0 + createTab("b", "B"); // position 1 + createTab("c", "C"); // position 2 + + updateTabPositions(["c", "a", "b"]); + + // listOpenTabs orders by position → reflects the new order. + expect(listOpenTabs().map((t) => t.id)).toEqual(["c", "a", "b"]); + expect(getTab("c")?.position).toBe(0); + expect(getTab("a")?.position).toBe(1); + expect(getTab("b")?.position).toBe(2); + }); + + it("is a no-op for an empty list", () => { + createTab("a", "A"); + createTab("b", "B"); + updateTabPositions([]); + expect(listOpenTabs().map((t) => t.id)).toEqual(["a", "b"]); + }); + + it("ignores ids that don't exist without throwing", () => { + createTab("a", "A"); + expect(() => updateTabPositions(["ghost", "a"])).not.toThrow(); + // "a" took index 1 in the requested order. + expect(getTab("a")?.position).toBe(1); + }); +}); diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index ecfdc9f..a0b25b7 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -174,7 +174,7 @@ onMount(() => { <div class="flex-1 overflow-hidden"> <ChatPanel /> </div> - <ChatInput /> + <ChatInput {contextLimit} /> </div> <!-- Right sidebar: overlay on small screens, inline on large --> diff --git a/packages/frontend/src/lib/components/CacheRatePanel.svelte b/packages/frontend/src/lib/components/CacheRatePanel.svelte index c35cbb5..88985a0 100644 --- a/packages/frontend/src/lib/components/CacheRatePanel.svelte +++ b/packages/frontend/src/lib/components/CacheRatePanel.svelte @@ -55,7 +55,7 @@ const lastHitPct = $derived( {#if tabTitle} <span class="badge badge-xs badge-ghost">{tabTitle}</span> {/if} - <span class="badge badge-xs ml-auto">{cacheStats.requests} req</span> + <span class="badge badge-xs ml-auto whitespace-nowrap">{cacheStats.requests} req</span> </div> <!-- Headline cumulative hit rate --> @@ -120,10 +120,5 @@ const lastHitPct = $derived( </div> </div> </div> - - <p class="text-xs text-base-content/40"> - Cache reads cost ~10% of fresh input; writes cost ~25% more. A high hit - rate after the first turn means caching is working. Resets on reload. - </p> {/if} </div> diff --git a/packages/frontend/src/lib/components/ChatInput.svelte b/packages/frontend/src/lib/components/ChatInput.svelte index 0c99078..079ef4a 100644 --- a/packages/frontend/src/lib/components/ChatInput.svelte +++ b/packages/frontend/src/lib/components/ChatInput.svelte @@ -1,15 +1,53 @@ <script lang="ts"> +import { computeContextUsage } from "../context-window.js"; import { tabStore } from "../tabs.svelte.js"; +const { contextLimit = null }: { contextLimit?: number | null } = $props(); + const MAX_LINES = 7; let inputEl: HTMLTextAreaElement | undefined; -let inputValue = $state(""); const agentStatus = $derived(tabStore.activeTab?.agentStatus ?? "idle"); const tabId = $derived(tabStore.activeTab?.id ?? ""); +// The current input text lives on the active tab (in-memory draft), so +// switching tabs saves the current draft and restores the target tab's text +// automatically — drafts are never lost or clobbered by tab switching. +const inputValue = $derived(tabStore.activeTab?.draft ?? ""); +const cacheStats = $derived(tabStore.activeTab?.cacheStats ?? null); + +const isRunning = $derived(agentStatus === "running"); +const hasText = $derived(inputValue.trim().length > 0); +// While generating with an empty box, the primary action is "stop". With text +// in the box, it stays "send" (the message is queued behind the live turn). +const showStop = $derived(isRunning && !hasText); + +const usage = $derived(computeContextUsage(cacheStats, contextLimit)); +const hasUsage = $derived((cacheStats?.last ?? null) !== null); + +// As the window fills, escalate color: calm → warning → danger. Mirrors the +// Context Window sidebar view so the two displays agree. +function fillClass(pct: number): string { + if (pct >= 90) return "progress-error"; + if (pct >= 70) return "progress-warning"; + return "progress-success"; +} + +// Compact token count for the slim bar (e.g. 12.3k, 1.2M). Full numbers live +// in the sidebar's Context Window panel. +function fmtCompact(n: number): string { + if (n < 1000) return `${n}`; + if (n < 1_000_000) { + const k = n / 1000; + return `${k >= 100 ? Math.round(k) : k.toFixed(1)}k`; + } + const m = n / 1_000_000; + return `${m >= 100 ? Math.round(m) : m.toFixed(1)}M`; +} $effect(() => { + // Re-focus when switching tabs. + void tabId; inputEl?.focus(); }); @@ -29,13 +67,19 @@ function resize() { el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; } -// Re-run resize whenever the value changes (covers programmatic clears too). +// Re-run resize whenever the value changes (covers tab switches and +// programmatic clears too). $effect(() => { // Touch inputValue so this effect tracks it. void inputValue; resize(); }); +function handleInput(e: Event) { + if (!tabId) return; + tabStore.setDraft(tabId, (e.currentTarget as HTMLTextAreaElement).value); +} + function handleKeydown(e: KeyboardEvent) { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); @@ -46,48 +90,90 @@ function handleKeydown(e: KeyboardEvent) { function submit() { const text = inputValue.trim(); if (!text) return; - inputValue = ""; + if (tabId) tabStore.setDraft(tabId, ""); tabStore.sendMessage(text); } + +function primaryAction() { + if (showStop) { + tabStore.stopGeneration(tabId); + return; + } + submit(); +} </script> -<div class="flex items-end gap-2 p-3"> - {#if agentStatus === "running"} +<div class="flex flex-col"> + <!-- Top bar: expanding textarea + send/stop action --> + <div class="flex items-end gap-2 px-3 pt-3 pb-2"> + <textarea + bind:this={inputEl} + value={inputValue} + rows="1" + placeholder="Type a message..." + class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto" + onkeydown={handleKeydown} + oninput={handleInput} + ></textarea> + <!-- Single fixed-width button across all states so the layout never + shifts when it morphs between Send and Stop. --> <button type="button" - class="btn btn-ghost gap-1 btn-sm lg:btn-xs" - onclick={() => tabStore.stopGeneration(tabId)} - title="Stop generation" + class="btn w-20 shrink-0 {showStop ? 'btn-error btn-outline' : 'btn-primary'}" + disabled={!showStop && !hasText} + onclick={primaryAction} + title={showStop ? "Stop generation" : "Send message"} > - <span class="loading loading-spinner loading-sm text-primary" style="pointer-events: auto"></span> - <span class="text-xs">Stop</span> + {#if showStop} + <span class="loading loading-spinner loading-sm"></span> + Stop + {:else} + Send + {/if} </button> - {:else if agentStatus === "idle"} - <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="w-5 h-5 text-success shrink-0 mb-2"> - <polyline points="20 6 9 17 4 12"></polyline> - </svg> - {:else if agentStatus === "error"} - <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="w-5 h-5 text-error shrink-0 mb-2"> - <circle cx="12" cy="12" r="10"></circle> - <line x1="12" y1="8" x2="12" y2="12"></line> - <line x1="12" y1="16" x2="12.01" y2="16"></line> - </svg> - {/if} - <textarea - bind:this={inputEl} - bind:value={inputValue} - rows="1" - placeholder="Type a message..." - class="textarea textarea-ghost flex-1 resize-none leading-normal !min-h-0 h-auto" - onkeydown={handleKeydown} - oninput={resize} - ></textarea> - <button - type="button" - class="btn btn-primary" - disabled={!inputValue.trim()} - onclick={submit} - > - Send - </button> + </div> + + <!-- Bottom bar: status icon · context progress · token count --> + <div class="flex items-center gap-2 px-3 pb-2 text-xs text-base-content/50"> + <!-- Status icon --> + <span class="shrink-0"> + {#if agentStatus === "running"} + <span class="loading loading-spinner loading-xs text-primary"></span> + {:else if agentStatus === "error"} + <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="w-4 h-4 text-error" aria-label="Error"> + <circle cx="12" cy="12" r="10"></circle> + <line x1="12" y1="8" x2="12" y2="12"></line> + <line x1="12" y1="16" x2="12.01" y2="16"></line> + </svg> + {:else} + <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="w-4 h-4 text-success" aria-label="Idle"> + <polyline points="20 6 9 17 4 12"></polyline> + </svg> + {/if} + </span> + + <!-- Context-window fill bar --> + {#if usage.percent !== null} + <progress + class="progress flex-1 h-2 {fillClass(usage.percent)}" + value={usage.percent} + max="100" + ></progress> + {:else} + <!-- Model's max context is unknown → inert, disabled bar. --> + <progress class="progress flex-1 h-2 opacity-40" value="0" max="100"></progress> + {/if} + + <!-- Context size + percent --> + <span class="shrink-0 font-mono whitespace-nowrap"> + {#if hasUsage} + {fmtCompact(usage.current)}{#if usage.max !== null}<span class="text-base-content/40"> / {fmtCompact(usage.max)}</span>{/if} + {#if usage.percent !== null} + <span class="ml-1">· {usage.percent.toFixed(1)}%</span> + {/if} + {:else} + <span class="text-base-content/40">— tokens</span> + {/if} + </span> + </div> </div> diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index 00d179e..7c0cadc 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -131,6 +131,17 @@ function progressClass(utilization: number): string { return "progress-success"; } +// Pace-aware coloring for cycle bars that show a "time dot" (elapsed % of the +// reset window). Red once usage hits 90%, otherwise green when usage is at or +// behind the dot and orange when it has run ahead of it. Falls back to the +// plain threshold coloring when no dot is present (elapsedPct < 0). +function pacedProgressClass(percentUsed: number, elapsedPct: number): string { + if (percentUsed >= 90) return "progress-error"; + if (elapsedPct < 0) return progressClass(percentUsed / 100); + if (percentUsed <= elapsedPct) return "progress-success"; + return "progress-warning"; +} + function formatDate(ts: number): string { const diff = ts - Date.now(); const days = Math.floor(diff / 86400000); @@ -245,7 +256,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -266,7 +277,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -330,7 +341,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -351,7 +362,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} @@ -372,7 +383,7 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { <span class="text-xs font-mono">{p}%</span> </div> <div class="relative w-full h-2"> - <progress class="progress w-full h-2 {progressClass(u)} absolute inset-0" value={p} max="100"></progress> + <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress> {#if tp >= 0} <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div> {/if} diff --git a/packages/frontend/src/lib/components/TabBar.svelte b/packages/frontend/src/lib/components/TabBar.svelte index 3cbd849..4fbe3b1 100644 --- a/packages/frontend/src/lib/components/TabBar.svelte +++ b/packages/frontend/src/lib/components/TabBar.svelte @@ -1,4 +1,5 @@ <script lang="ts"> +import { tick } from "svelte"; import { tabStore } from "../tabs.svelte.js"; function statusColor(status: string): string { @@ -20,6 +21,59 @@ const activeUserTabId = $derived( ? activeTab.parentTabId : tabStore.activeTabId, ); + +// ── Drag-and-drop reorder (user tabs only) ── +// Mirrors the native HTML5 DnD pattern used in AgentBuilder.svelte. +let dragIndex = $state<number | null>(null); +let dragOverIndex = $state<number | null>(null); + +function dropReorder(targetIndex: number): void { + if (dragIndex !== null && dragIndex !== targetIndex) { + const ids = userTabs.map((t) => t.id); + const moved = ids.splice(dragIndex, 1)[0]; + if (moved) { + ids.splice(targetIndex, 0, moved); + tabStore.reorderTabs(ids); + } + } + dragIndex = null; + dragOverIndex = null; +} + +// ── Double-click rename (user tabs only) ── +let editingTabId = $state<string | null>(null); +let editValue = $state(""); +let editInputEl = $state<HTMLInputElement | undefined>(undefined); + +async function startRename(tab: { id: string; title: string }): Promise<void> { + editingTabId = tab.id; + editValue = tab.title; + await tick(); + editInputEl?.focus(); + editInputEl?.select(); +} + +function commitRename(): void { + if (editingTabId === null) return; + const id = editingTabId; + editingTabId = null; + const next = editValue.trim(); + if (next) tabStore.renameTab(id, next); +} + +function cancelRename(): void { + editingTabId = null; +} + +function handleRenameKeydown(e: KeyboardEvent): void { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelRename(); + } +} </script> <!-- Top row: user tabs --> @@ -45,19 +99,48 @@ const activeUserTabId = $derived( + </button> - {#each userTabs as tab (tab.id)} + {#each userTabs as tab, i (tab.id)} <!-- svelte-ignore a11y_no_static_element_interactions --> <div role="tab" - class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''}" + class="tab !flex items-stretch gap-1.5 {tab.id === activeUserTabId ? 'tab-active' : ''} {dragOverIndex === i ? 'bg-primary/10' : ''} {dragIndex === i ? 'opacity-50' : ''}" + draggable={editingTabId === tab.id ? "false" : "true"} onclick={() => tabStore.switchTab(tab.id)} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') tabStore.switchTab(tab.id); }} + ondragstart={(e) => { + dragIndex = i; + if (e.dataTransfer) e.dataTransfer.effectAllowed = "move"; + }} + ondragover={(e) => { + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragOverIndex = i; + }} + ondragleave={() => { if (dragOverIndex === i) dragOverIndex = null; }} + ondrop={(e) => { e.preventDefault(); dropReorder(i); }} + ondragend={() => { dragIndex = null; dragOverIndex = null; }} tabindex="0" > <span class="flex items-center gap-1.5"> <span class="w-1.5 h-1.5 rounded-full shrink-0 {statusColor(tab.agentStatus)}"></span> <span class="font-mono text-[10px] px-1 py-0.5 rounded bg-base-300 text-base-content/60 shrink-0" title="Tab ID — agents address this tab by this handle">{tabStore.shortHandleFor(tab.id)}</span> - <span class="max-w-32 truncate text-xs">{tab.title}</span> + {#if editingTabId === tab.id} + <input + bind:this={editInputEl} + bind:value={editValue} + class="max-w-32 text-xs bg-base-100 rounded px-1 outline-none ring-1 ring-primary/40" + onclick={(e) => e.stopPropagation()} + ondblclick={(e) => e.stopPropagation()} + onkeydown={handleRenameKeydown} + onblur={commitRename} + /> + {:else} + <span + class="max-w-32 truncate text-xs" + ondblclick={(e) => { e.stopPropagation(); startRename(tab); }} + title="Double-click to rename" + >{tab.title}</span> + {/if} </span> <button type="button" diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts index 875287b..9975d7b 100644 --- a/packages/frontend/src/lib/tabs.svelte.ts +++ b/packages/frontend/src/lib/tabs.svelte.ts @@ -177,6 +177,19 @@ export interface Tab { /** Total chunk count for this tab on the backend (drives "more to load?"). */ totalChunks: number; /** + * Unsent chat-input text for THIS tab (in-memory only — never persisted). + * Saved/restored on tab switch so a draft is never lost or clobbered by + * switching tabs. Cleared on send. + */ + draft: string; + /** + * True once the user has manually renamed this tab (double-click rename). + * Suppresses the first-message auto-title so a chosen name is never + * clobbered. In-memory only — a renamed tab is no longer "New Tab" on + * reload, so the auto-title guard already won't fire for it. + */ + manualTitle: boolean; + /** * Cumulative prompt-cache token telemetry for this tab since the page * loaded (in-memory only — resets on reload). Undefined until the first * `usage` event arrives. Drives the "Cache Rate" sidebar view. @@ -298,6 +311,8 @@ export function createTabStore() { workingDirectory: null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: null, totalChunks: 0, }; @@ -373,6 +388,8 @@ export function createTabStore() { workingDirectory: null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: win.oldestSeq, totalChunks: win.total, }; @@ -426,6 +443,61 @@ export function createTabStore() { } /** + * Rename a tab. Records `manualTitle` so the first-message auto-title never + * clobbers the user's chosen name, and persists the new title to the DB + * (fire-and-forget — the optimistic local update is the source of truth for + * the open session). + */ + function renameTab(id: string, title: string): void { + const trimmed = title.trim(); + if (!trimmed) return; + updateTab(id, { title: trimmed, manualTitle: true }); + fetch(`${config.apiBase}/tabs/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: trimmed }), + }).catch(() => {}); + } + + /** + * Reorder the top-row USER tabs to match `orderedUserTabIds`. Subagent tabs + * (those with a `parentTabId`) keep their relative order untouched — they + * live in a separate row and aren't draggable. The new left-to-right user + * order is persisted via `PATCH /tabs/reorder`, which rewrites each open + * tab's `position` (fire-and-forget, matching the title-persist style). + */ + function reorderTabs(orderedUserTabIds: string[]): void { + const byId = new Map(tabs.map((t) => [t.id, t])); + const ordered = orderedUserTabIds + .map((id) => byId.get(id)) + .filter((t): t is Tab => t !== undefined && t.parentTabId === null); + // Bail if the requested order doesn't cover exactly the current user tabs + // (stale drag against a since-changed tab set) — never drop tabs. + const currentUserCount = tabs.filter((t) => t.parentTabId === null).length; + if (ordered.length !== currentUserCount) return; + const subagentTabs = tabs.filter((t) => t.parentTabId !== null); + tabs = [...ordered, ...subagentTabs]; + // Persist the full open-tab order (user tabs first, then subagents) so the + // backend `position` column matches what the user sees on reload. + const persistOrder = [...ordered, ...subagentTabs].map((t) => t.id); + fetch(`${config.apiBase}/tabs/reorder`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: persistOrder }), + }).catch(() => {}); + } + + /** + * Persist the unsent chat-input text for a tab (in-memory only). Saved on + * every keystroke so switching tabs preserves the draft and restoring the + * target tab shows its own text. No-op if the tab is gone. + */ + function setDraft(id: string, text: string): void { + if (!getTabById(id)) return; + updateTab(id, { draft: text }); + } + + /** * Record whether a tab's chat view is scrolled up (viewing older history). * Used to suppress automatic eviction while the user is reading old * messages — we don't want to delete what they're currently looking at. @@ -856,6 +928,8 @@ export function createTabStore() { workingDirectory: null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: win.oldestSeq, totalChunks: win.total, cacheStats: row.usageStats ?? undefined, @@ -1209,6 +1283,8 @@ export function createTabStore() { workingDirectory: newTabEvent.workingDirectory ?? null, queuedMessages: [], chunkLimit: appSettings.chunkLimit, + draft: "", + manualTitle: false, oldestLoadedSeq: null, totalChunks: 0, }; @@ -1595,7 +1671,7 @@ export function createTabStore() { updateTab(tab.id, { live: [...tab.live, userMsg] }); // Generate a title from the first user message of an empty tab. const isFirstMessage = tab.chunks.length === 0 && tab.live.length === 0; - if (isFirstMessage || tab.title === "New Tab") { + if (!tab.manualTitle && (isFirstMessage || tab.title === "New Tab")) { const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text; updateTab(tab.id, { title: titleText }); fetch(`${config.apiBase}/tabs/${tab.id}`, { @@ -2039,6 +2115,9 @@ export function createTabStore() { createNewTab, switchTab, closeTab, + renameTab, + reorderTabs, + setDraft, sendMessage, cancelQueuedMessage, stopGeneration, diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index c0763cd..a0d4ead 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1972,3 +1972,157 @@ describe("tabStore — chunk-native eviction / pagination / reconcile", () => { expect(tab?.live.some((m) => m.turnId === "turn-a")).toBe(false); }); }); + +describe("tabStore — tab reorder", () => { + it("reorders user tabs and persists the new order", async () => { + const calls: Array<{ url: string; body: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn((url: string, opts?: { body?: string }) => { + calls.push({ url, body: opts?.body ?? "" }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true }) }); + }), + ); + const store = createTabStore(); + const a = await store.createNewTab(); + const b = await store.createNewTab(); + const c = await store.createNewTab(); + expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id, c.id]); + + // Move the last tab to the front. + store.reorderTabs([c.id, a.id, b.id]); + expect(store.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]); + + const reorderCall = calls.find((call) => call.url.endsWith("/tabs/reorder")); + expect(reorderCall).toBeTruthy(); + expect(JSON.parse(reorderCall?.body ?? "{}")).toEqual({ ids: [c.id, a.id, b.id] }); + }); + + it("ignores a stale order that doesn't cover all user tabs", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), + ); + const store = createTabStore(); + const a = await store.createNewTab(); + const b = await store.createNewTab(); + store.reorderTabs([a.id]); // missing b → no-op + expect(store.tabs.map((t) => t.id)).toEqual([a.id, b.id]); + }); + + it("keeps subagent tabs after the user tabs when reordering", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), + ); + const store = createTabStore(); + const a = await store.createNewTab(); + const b = await store.createNewTab(); + // A subagent tab arrives via WS (parentTabId set). + store.handleEvent({ + type: "tab-created", + id: "sub", + title: "Sub", + keyId: null, + modelId: null, + parentTabId: a.id, + }); + store.reorderTabs([b.id, a.id]); + const ids = store.tabs.map((t) => t.id); + expect(ids).toEqual([b.id, a.id, "sub"]); + }); +}); + +describe("tabStore — rename + auto-title guard", () => { + it("renameTab sets the title and persists it", async () => { + const calls: Array<{ url: string; method?: string; body: string }> = []; + vi.stubGlobal( + "fetch", + vi.fn((url: string, opts?: { method?: string; body?: string }) => { + calls.push({ url, method: opts?.method, body: opts?.body ?? "" }); + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + }), + ); + const store = createTabStore(); + const a = await store.createNewTab(); + store.renameTab(a.id, " My Tab "); + expect(store.tabs[0]?.title).toBe("My Tab"); + expect(store.tabs[0]?.manualTitle).toBe(true); + const patch = calls.find( + (call) => call.url.endsWith(`/tabs/${a.id}`) && call.method === "PATCH", + ); + expect(JSON.parse(patch?.body ?? "{}")).toEqual({ title: "My Tab" }); + }); + + it("renameTab ignores an empty/whitespace name", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), + ); + const store = createTabStore(); + const a = await store.createNewTab(); + store.renameTab(a.id, " "); + expect(store.tabs[0]?.title).toBe("New Tab"); + expect(store.tabs[0]?.manualTitle).toBe(false); + }); + + it("sendMessage does NOT auto-title a manually renamed tab", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })), + ); + const store = createTabStore(); + await store.createNewTab(); + store.renameTab(store.tabs[0]?.id ?? "", "Keep Me"); + await store.sendMessage("hello there this is the first message"); + expect(store.tabs[0]?.title).toBe("Keep Me"); + }); + + it("sendMessage still auto-titles a tab that was never renamed", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ status: "ok" }) })), + ); + const store = createTabStore(); + await store.createNewTab(); + await store.sendMessage("first message becomes the title"); + expect(store.tabs[0]?.title).toBe("first message becomes the title"); + expect(store.tabs[0]?.manualTitle).toBe(false); + }); +}); + +describe("tabStore — per-tab chat input draft", () => { + it("stores drafts per tab and restores them on switch", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), + ); + const store = createTabStore(); + const a = await store.createNewTab(); + const b = await store.createNewTab(); + + store.switchTab(a.id); + store.setDraft(a.id, "draft for A"); + store.switchTab(b.id); + store.setDraft(b.id, "draft for B"); + + // Active tab is B → its draft is exposed. + expect(store.activeTab?.draft).toBe("draft for B"); + // Switching back to A restores A's draft without clobbering B's. + store.switchTab(a.id); + expect(store.activeTab?.draft).toBe("draft for A"); + expect(store.tabs.find((t) => t.id === b.id)?.draft).toBe("draft for B"); + }); + + it("new tabs start with an empty draft and setDraft no-ops for unknown tabs", async () => { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) })), + ); + const store = createTabStore(); + const a = await store.createNewTab(); + expect(a.draft).toBe(""); + store.setDraft("nope", "ignored"); // unknown tab → no throw, no effect + expect(store.tabs.every((t) => t.draft === "")).toBe(true); + }); +}); |
