summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/KeyUsage.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
committerAdam Malczewski <[email protected]>2026-05-22 15:24:13 +0900
commit9ecaabd87c0e51b8a7408dabb0133a9344586859 (patch)
tree4b5809ab23948a5e3f558f3aa34c52d5038f4e19 /packages/frontend/src/lib/components/KeyUsage.svelte
parent8f1dd855f0c4c877bff8d3a4ba193432b268b1c2 (diff)
downloaddispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.tar.gz
dispatch-9ecaabd87c0e51b8a7408dabb0133a9344586859.zip
feat: agent builder, CWD support, auto-save, UI polish, unavailable tool handling
- Agent Builder: full CRUD with card grid, drag-and-drop model reorder, edit/delete - Auto-save on edit with 600ms debounce, AbortController for concurrency, fieldset disabled until name entered - Agent definitions stored as TOML with cwd field, loaded from global/project dirs - Working directory: per-tab CWD override in Chat Settings, agent default CWD, auto-create on first message - CWD validation: check-dir endpoint with ~ expansion, real-time validity indicator - Subagent CWD validated against parent's effective CWD using path.relative - Unavailable tool calls: caught gracefully, shown as tool call with error badge, model retries - UI: tab bar border radius, sidebar border removed, chat input ghost style, scroll-to-bottom rectangle - Skills dir collapse uses CSS rotation, Model Choice renamed to Chat Settings, System Prompt view removed - Reusable SkillsBrowser/ToolPermissions with external mode for Agent Builder - ModelSelector: Agent/Manual toggle, agent list, Agent Settings link - Page router, skills recursive scanning, bin/up gopass removed, docker volume mounts
Diffstat (limited to 'packages/frontend/src/lib/components/KeyUsage.svelte')
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte320
1 files changed, 158 insertions, 162 deletions
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index f5f7d6d..fc85739 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -1,201 +1,197 @@
<script lang="ts">
- import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
+import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
- const {
- keys = [],
- apiBase = "",
- }: {
- keys?: KeyInfo[];
- apiBase?: string;
- } = $props();
+const {
+ keys = [],
+ apiBase = "",
+}: {
+ keys?: KeyInfo[];
+ apiBase?: string;
+} = $props();
- interface KeyUsageEntry {
- keyId: string;
- provider: string;
- data: KeyUsageData | null;
- error: string | null;
- loading: boolean;
- }
+interface KeyUsageEntry {
+ keyId: string;
+ provider: string;
+ data: KeyUsageData | null;
+ error: string | null;
+ loading: boolean;
+}
- // In-memory cache for the current session (backend DB is the persistent cache)
- const usageCache = new Map<string, KeyUsageData>();
+// In-memory cache for the current session (backend DB is the persistent cache)
+const usageCache = new Map<string, KeyUsageData>();
- function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
- return keyList.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true, // always show spinner during refresh
- };
- });
- }
+function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
+ return keyList.map((k) => {
+ const cached = usageCache.get(k.id);
+ return {
+ keyId: k.id,
+ provider: k.provider,
+ data: cached ?? null,
+ error: null,
+ loading: true, // always show spinner during refresh
+ };
+ });
+}
- let entries = $state<KeyUsageEntry[]>([]);
+let entries = $state<KeyUsageEntry[]>([]);
- async function fetchOne(key: KeyInfo) {
- try {
- const res = await fetch(
- `${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`,
- );
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- updateEntry(key.id, {
- error: data.error ?? `HTTP ${res.status}`,
- loading: false,
- });
- } else {
- const fresh = await res.json() as KeyUsageData;
- usageCache.set(key.id, fresh);
- updateEntry(key.id, {
- data: fresh,
- error: null,
- loading: false,
- });
- }
- } catch (e) {
+async function fetchOne(key: KeyInfo) {
+ try {
+ const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`);
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ updateEntry(key.id, {
+ error: data.error ?? `HTTP ${res.status}`,
+ loading: false,
+ });
+ } else {
+ const fresh = (await res.json()) as KeyUsageData;
+ usageCache.set(key.id, fresh);
updateEntry(key.id, {
- error: e instanceof Error ? e.message : "Failed to fetch",
+ data: fresh,
+ error: null,
loading: false,
});
}
+ } catch (e) {
+ updateEntry(key.id, {
+ error: e instanceof Error ? e.message : "Failed to fetch",
+ loading: false,
+ });
}
+}
- function updateEntry(
- keyId: string,
- patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
- ) {
- entries = entries.map((e) =>
- e.keyId === keyId ? { ...e, ...patch } : e,
- );
- }
+function updateEntry(
+ keyId: string,
+ patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
+) {
+ entries = entries.map((e) => (e.keyId === keyId ? { ...e, ...patch } : e));
+}
- // Sync entries with keys reactively — runs before DOM update so
- // cached data renders on first paint without a flash of empty state.
- $effect.pre(() => {
- entries = buildEntries(keys);
- });
+// Sync entries with keys reactively — runs before DOM update so
+// cached data renders on first paint without a flash of empty state.
+$effect.pre(() => {
+ entries = buildEntries(keys);
+});
+
+// Fetch data and set up 90s auto-refresh
+$effect(() => {
+ const currentKeys = keys;
+ // Fire all fetches in parallel
+ for (const key of currentKeys) {
+ fetchOne(key);
+ }
- // Fetch data and set up 90s auto-refresh
- $effect(() => {
- const currentKeys = keys;
- // Fire all fetches in parallel
+ // Refresh every 90s
+ const interval = setInterval(() => {
for (const key of currentKeys) {
+ updateEntry(key.id, { loading: true });
fetchOne(key);
}
+ }, 90_000);
- // Refresh every 90s
- const interval = setInterval(() => {
- for (const key of currentKeys) {
- updateEntry(key.id, { loading: true });
- fetchOne(key);
- }
- }, 90_000);
+ return () => clearInterval(interval);
+});
- return () => clearInterval(interval);
- });
-
- // Merge duplicate Claude entries — all anthropic keys return the same
- // set of accounts, so collect and deduplicate under one "Claude" card.
- const claudeAccounts = $derived.by(() => {
- const seen = new Set<string>();
- const accounts: Array<{
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
- }> = [];
- const claudeEntries = entries.filter((e) => e.provider === "anthropic");
- for (const e of claudeEntries) {
- if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
- for (const acct of e.data.accounts) {
- if (!seen.has(acct.source)) {
- seen.add(acct.source);
- accounts.push(acct);
- }
+// Merge duplicate Claude entries — all anthropic keys return the same
+// set of accounts, so collect and deduplicate under one "Claude" card.
+const claudeAccounts = $derived.by(() => {
+ const seen = new Set<string>();
+ const accounts: Array<{
+ label: string;
+ source: string;
+ subscriptionType?: string;
+ fiveHour?: UsageBucket;
+ sevenDay?: UsageBucket;
+ error?: string;
+ }> = [];
+ const claudeEntries = entries.filter((e) => e.provider === "anthropic");
+ for (const e of claudeEntries) {
+ if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
+ for (const acct of e.data.accounts) {
+ if (!seen.has(acct.source)) {
+ seen.add(acct.source);
+ accounts.push(acct);
}
}
- return accounts;
- });
+ }
+ return accounts;
+});
- const claudeLoading = $derived(
- entries.some((e) => e.provider === "anthropic" && e.loading),
- );
- const claudeError = $derived(
- entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
- );
+const claudeLoading = $derived(entries.some((e) => e.provider === "anthropic" && e.loading));
+const claudeError = $derived(
+ entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
+);
- const nonClaudeEntries = $derived(
- entries.filter((e) => e.provider !== "anthropic"),
- );
+const nonClaudeEntries = $derived(entries.filter((e) => e.provider !== "anthropic"));
- function progressClass(utilization: number): string {
- if (utilization > 0.8) return "progress-error";
- if (utilization >= 0.5) return "progress-warning";
- return "progress-success";
- }
+function progressClass(utilization: number): string {
+ if (utilization > 0.8) return "progress-error";
+ if (utilization >= 0.5) return "progress-warning";
+ return "progress-success";
+}
- function formatDate(ts: number): string {
- const diff = ts - Date.now();
- const days = Math.floor(diff / 86400000);
- const d = new Date(ts);
- const dateStr = d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" })
- + " "
- + d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
+function formatDate(ts: number): string {
+ const diff = ts - Date.now();
+ const days = Math.floor(diff / 86400000);
+ const d = new Date(ts);
+ const dateStr =
+ d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" }) +
+ " " +
+ d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
- if (diff <= 0) {
- return d.toLocaleString();
+ if (diff <= 0) {
+ return d.toLocaleString();
+ }
+ if (diff < 48 * 60 * 60 * 1000) {
+ const hours = Math.floor(diff / 3600000);
+ const minutes = Math.floor((diff % 3600000) / 60000);
+ return `in ${hours}:${String(minutes).padStart(2, "0")}`;
+ }
+ if (days <= 30) {
+ const weeks = Math.floor(days / 7);
+ const remDays = days % 7;
+ if (weeks > 0 && remDays > 0) {
+ return `in ${weeks}w ${remDays}d (${dateStr})`;
}
- if (diff < 48 * 60 * 60 * 1000) {
- const hours = Math.floor(diff / 3600000);
- const minutes = Math.floor((diff % 3600000) / 60000);
- return `in ${hours}:${String(minutes).padStart(2, "0")}`;
+ if (weeks > 0) {
+ return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
}
- if (days <= 30) {
- const weeks = Math.floor(days / 7);
- const remDays = days % 7;
- if (weeks > 0 && remDays > 0) {
- return `in ${weeks}w ${remDays}d (${dateStr})`;
- }
- if (weeks > 0) {
- return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
- }
- return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
- }
- return d.toLocaleDateString("en-US", {
- month: "2-digit",
- day: "2-digit",
- year: "numeric",
- });
+ return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
}
+ return d.toLocaleDateString("en-US", {
+ month: "2-digit",
+ day: "2-digit",
+ year: "numeric",
+ });
+}
- function formatKeyId(id: string): string {
- if (/^github-copilot/i.test(id)) return "Copilot";
- return id.split("-").map((part) => {
+function formatKeyId(id: string): string {
+ if (/^github-copilot/i.test(id)) return "Copilot";
+ return id
+ .split("-")
+ .map((part) => {
if (part.toLowerCase() === "opencode") return "OpenCode";
return part.charAt(0).toUpperCase() + part.slice(1);
- }).join(" ");
- }
+ })
+ .join(" ");
+}
- // Cycle durations in ms
- const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
- const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
- const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
+// Cycle durations in ms
+const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
+const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
+const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
- function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
- if (!resetsAt) return -1;
- const timeRemaining = resetsAt - Date.now();
- const elapsed = cycleDurationMs - timeRemaining;
- return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
- }
+function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
+ if (!resetsAt) return -1;
+ const timeRemaining = resetsAt - Date.now();
+ const elapsed = cycleDurationMs - timeRemaining;
+ return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
+}
- function hasBucketData(bucket: UsageBucket | undefined): boolean {
- return bucket !== undefined && bucket.utilization !== undefined;
- }
+function hasBucketData(bucket: UsageBucket | undefined): boolean {
+ return bucket !== undefined && bucket.utilization !== undefined;
+}
</script>
<div class="flex flex-col gap-3 flex-1 min-h-0">