summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 15:03:54 +0900
committerAdam Malczewski <[email protected]>2026-06-02 15:03:54 +0900
commit9d6b7a97e8e96429815503718e1437fae41bf5d5 (patch)
treeba3b3a95454a6d150e34b595d92d39acacb8ad6a
parentecb001ec7a2e573d8dedf5064e860e5a3e7788fd (diff)
parent40b0b6a23a5cbd494f9956315c2e424d16edb282 (diff)
downloaddispatch-9d6b7a97e8e96429815503718e1437fae41bf5d5.tar.gz
dispatch-9d6b7a97e8e96429815503718e1437fae41bf5d5.zip
Merge branch 'dev' into td/todo-fix
-rw-r--r--HANDOFF.md73
-rw-r--r--packages/api/src/routes/models.ts64
-rw-r--r--packages/api/src/routes/tabs.ts13
-rw-r--r--packages/core/src/credentials/claude.ts38
-rw-r--r--packages/core/src/credentials/index.ts1
-rw-r--r--packages/core/src/db/tabs.ts14
-rw-r--r--packages/core/src/index.ts1
-rw-r--r--packages/core/tests/credentials/wake-probe.test.ts49
-rw-r--r--packages/core/tests/db/tabs.test.ts69
-rw-r--r--packages/frontend/src/App.svelte2
-rw-r--r--packages/frontend/src/lib/components/CacheRatePanel.svelte7
-rw-r--r--packages/frontend/src/lib/components/ChatInput.svelte162
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte21
-rw-r--r--packages/frontend/src/lib/components/TabBar.svelte89
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts81
-rw-r--r--packages/frontend/tests/chat-store.test.ts154
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);
+ });
+});