>([]);
+
+ useEffect(() => {
+ const group = groupRef.current;
+ const path = pathRef.current;
+ if (!group || !path) return;
+ const particles = particlesRef.current;
+
+ const render = (elapsed: number) => {
+ const progress = (elapsed % DURATION_MS) / DURATION_MS;
+ const detailScale = getDetailScale(elapsed);
+
+ group.setAttribute("transform", `rotate(${getRotation(elapsed)} 50 50)`);
+ path.setAttribute("d", buildPathD(detailScale));
+
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ const node = particles[i];
+ if (!node) continue;
+ const tailOffset = i / (PARTICLE_COUNT - 1);
+ const point = computePoint(
+ normalizeProgress(progress - tailOffset * TRAIL_SPAN),
+ detailScale,
+ );
+ const fade = (1 - tailOffset) ** 0.56;
+ node.setAttribute("cx", point.x.toFixed(2));
+ node.setAttribute("cy", point.y.toFixed(2));
+ node.setAttribute("r", (0.9 + fade * 2.7).toFixed(2));
+ node.setAttribute("opacity", (0.04 + fade * 0.96).toFixed(3));
+ }
+ };
+
+ const prefersReduced =
+ window?.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
+
+ if (prefersReduced) {
+ render(0);
+ return;
+ }
+
+ const startedAt = performance.now();
+ let rafId = 0;
+ const loop = (now: number) => {
+ render(now - startedAt);
+ rafId = requestAnimationFrame(loop);
+ };
+ rafId = requestAnimationFrame(loop);
+ return () => cancelAnimationFrame(rafId);
+ }, []);
+
+ const dim = typeof size === "number" ? `${size}px` : size;
+
+ return (
+
+ {label}
+
+
+ {PARTICLE_IDS.map((id, i) => {
+ const p = INITIAL_PARTICLES[i];
+ return (
+ {
+ particlesRef.current[i] = el;
+ }}
+ fill="currentColor"
+ cx={p.cx}
+ cy={p.cy}
+ r={p.r}
+ opacity={p.opacity}
+ />
+ );
+ })}
+
+
+ );
+}
diff --git a/apps/web/src/components/sandbox-result.tsx b/apps/web/src/components/sandbox-result.tsx
index 9377340..3a302cf 100644
--- a/apps/web/src/components/sandbox-result.tsx
+++ b/apps/web/src/components/sandbox-result.tsx
@@ -175,6 +175,7 @@ function CodeExecutionResult({
{highlightedCode ? (
0 && (
{charts.map((chart, i) => (
-
+
{chart.title && (
{chart.title}
@@ -241,7 +248,7 @@ function CodeExecutionResult({
{chart.png && (
)}
@@ -357,7 +364,10 @@ function FileContentResult({ data }: { data: Record }) {
{highlighted ? (
-
+
) : (
@@ -619,7 +629,7 @@ function GitDiffResult({ data }: { data: Record }) {
{diff.split("\n").map((line, i) => (
}) {
{items.length > 0 && (
{matches.length > 0
- ? matches.map((m, i) => (
+ ? matches.map((m) => (
diff --git a/apps/web/src/components/sandbox/command-input.tsx b/apps/web/src/components/sandbox/command-input.tsx
index 069cc6d..7bd17da 100644
--- a/apps/web/src/components/sandbox/command-input.tsx
+++ b/apps/web/src/components/sandbox/command-input.tsx
@@ -1,14 +1,17 @@
import { useAuth } from "@clerk/clerk-react";
+import { AlertCircle, ChevronRight, TerminalSquare } from "lucide-react";
import {
- AlertCircle,
- ChevronRight,
- Loader2,
- TerminalSquare,
-} from "lucide-react";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+ useCallback,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import { type CommandResponse, createSandboxApi } from "../../lib/sandbox-api";
import { useSandboxPanel } from "../../lib/sandbox-panel-context";
import { cn } from "../../lib/utils";
+import { RoseCurveSpinner } from "../rose-curve-spinner";
import { ScrollArea } from "../ui/scroll-area";
interface CommandEntry {
@@ -198,13 +201,12 @@ export function CommandRunner() {
const isRunning = entries.some((e) => e.running);
const displayCwd = cwd.replace(/^\/home\/daytona/, "~");
+ const commandInputId = useId();
return (
- inputRef.current?.focus()}
- onKeyDown={() => {}}
- role="presentation"
+
{/* Output area */}
@@ -235,9 +237,9 @@ export function CommandRunner() {
{entry.command}
{entry.running && (
-
)}
@@ -296,6 +298,7 @@ export function CommandRunner() {
-
+
);
}
diff --git a/apps/web/src/components/sandbox/file-explorer.tsx b/apps/web/src/components/sandbox/file-explorer.tsx
index 004540f..4b8f615 100644
--- a/apps/web/src/components/sandbox/file-explorer.tsx
+++ b/apps/web/src/components/sandbox/file-explorer.tsx
@@ -4,7 +4,6 @@ import {
File,
Folder,
FolderOpen,
- Loader2,
RefreshCw,
Search,
} from "lucide-react";
@@ -12,6 +11,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { createSandboxApi, type SandboxFile } from "../../lib/sandbox-api";
import { useSandboxPanel } from "../../lib/sandbox-panel-context";
import { cn } from "../../lib/utils";
+import { RoseCurveSpinner } from "../rose-curve-spinner";
import { ScrollArea } from "../ui/scroll-area";
import { FileContextMenu } from "./file-context-menu";
import { FileSearch } from "./file-search";
@@ -159,9 +159,9 @@ export function FileExplorer() {
{rootNode?.loading && rootNode.files.length === 0 ? (
-
) : rootNode?.files.length === 0 ? (
@@ -293,7 +293,7 @@ function FileTreeNode({
className="flex items-center gap-1 py-1 font-mono text-[10px] text-muted-foreground/35"
style={{ paddingLeft: `${(depth + 1) * 12 + 8}px` }}
>
-
+
) : (
node.files.map((child) => (
diff --git a/apps/web/src/components/sandbox/file-search.tsx b/apps/web/src/components/sandbox/file-search.tsx
index 990d7ca..14164c0 100644
--- a/apps/web/src/components/sandbox/file-search.tsx
+++ b/apps/web/src/components/sandbox/file-search.tsx
@@ -1,8 +1,9 @@
import { useAuth } from "@clerk/clerk-react";
-import { FileText, Loader2, Search, X } from "lucide-react";
+import { FileText, Search, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
import { createSandboxApi, type SearchMatch } from "../../lib/sandbox-api";
import { useSandboxPanel } from "../../lib/sandbox-panel-context";
+import { RoseCurveSpinner } from "../rose-curve-spinner";
import { ScrollArea } from "../ui/scroll-area";
export function FileSearch({ onClose }: { onClose: () => void }) {
@@ -61,9 +62,9 @@ export function FileSearch({ onClose }: { onClose: () => void }) {
className="flex-1 bg-transparent font-mono text-[11px] text-foreground/80 outline-none placeholder:text-muted-foreground/30"
/>
{searching && (
-
)}
-
+
);
}
@@ -242,11 +243,7 @@ function FileContent({
title="Save (Cmd+S)"
className="flex h-6 items-center gap-1 px-1.5 font-mono text-[10px] text-emerald-500/70 transition-colors hover:text-emerald-500 disabled:opacity-30"
>
- {saving ? (
-
- ) : (
-
- )}
+ {saving ? : }
Save
>
@@ -297,8 +294,8 @@ function FileContent({
className="sticky left-0 shrink-0 select-none border-r border-border/60 bg-muted/15 px-2 py-1.5 text-right font-mono text-[10.5px] leading-[1.65] text-muted-foreground/20"
aria-hidden
>
- {lines.map((_, i) => (
- {i + 1}
+ {lines.map((line, i) => (
+ {i + 1}
))}
@@ -316,7 +313,10 @@ function FileContent({
/>
) : highlighted ? (
-
+
) : (
diff --git a/apps/web/src/components/sandbox/git-panel.tsx b/apps/web/src/components/sandbox/git-panel.tsx
index e20bb04..9255c38 100644
--- a/apps/web/src/components/sandbox/git-panel.tsx
+++ b/apps/web/src/components/sandbox/git-panel.tsx
@@ -4,7 +4,6 @@ import {
ChevronRight,
GitBranch,
GitCommitHorizontal,
- Loader2,
Minus,
Plus,
RefreshCw,
@@ -17,6 +16,7 @@ import {
} from "../../lib/sandbox-api";
import { useSandboxPanel } from "../../lib/sandbox-panel-context";
import { cn } from "../../lib/utils";
+import { RoseCurveSpinner } from "../rose-curve-spinner";
import { ScrollArea } from "../ui/scroll-area";
type Section = "changes" | "log";
@@ -206,7 +206,7 @@ export function GitPanel() {
title="Refresh"
className="flex h-5 w-5 items-center justify-center text-muted-foreground/40 transition-colors hover:text-muted-foreground disabled:opacity-30"
>
-
+ {loading ? : }
@@ -240,10 +240,7 @@ export function GitPanel() {
{loading && files.length === 0 && commits.length === 0 ? (
-
+
) : (
<>
@@ -268,7 +265,7 @@ export function GitPanel() {
className="flex items-center gap-1 rounded bg-foreground/10 px-2 py-1 font-mono text-[10px] text-foreground/80 transition-colors hover:bg-foreground/15 disabled:opacity-30"
>
{committing ? (
-
+
) : (
)}
diff --git a/apps/web/src/components/sandbox/sandbox-config-form.tsx b/apps/web/src/components/sandbox/sandbox-config-form.tsx
new file mode 100644
index 0000000..48f76d5
--- /dev/null
+++ b/apps/web/src/components/sandbox/sandbox-config-form.tsx
@@ -0,0 +1,137 @@
+import { Cpu, HardDrive, Play } from "lucide-react";
+import { motion } from "motion/react";
+import { DEFAULT_SANDBOX_CONFIG, type SandboxConfig } from "../../lib/sandbox";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "../ui/select";
+
+type SandboxConfigFormProps = {
+ config: SandboxConfig;
+ setConfig: (config: SandboxConfig) => void;
+ description?: string;
+};
+
+export function SandboxConfigForm({
+ config,
+ setConfig,
+ description = "Create a sandbox for code execution, file management, terminal commands, and git operations.",
+}: SandboxConfigFormProps) {
+ return (
+
+
{description}
+
+
+
+
+ Sandbox Type
+
+
+
+ setConfig({
+ ...config,
+ persistent: DEFAULT_SANDBOX_CONFIG.persistent,
+ })
+ }
+ className={`flex items-start gap-2.5 border px-3 py-2.5 text-left transition-colors ${
+ !config.persistent
+ ? "border-foreground bg-foreground/5"
+ : "border-border hover:bg-muted/30"
+ }`}
+ >
+
+
+
Ephemeral
+
+ Created per conversation, auto-deleted when done
+
+
+
+
setConfig({ ...config, persistent: true })}
+ className={`flex items-start gap-2.5 border px-3 py-2.5 text-left transition-colors ${
+ config.persistent
+ ? "border-foreground bg-foreground/5"
+ : "border-border hover:bg-muted/30"
+ }`}
+ >
+
+
+
Persistent
+
+ Maintains state across conversations
+
+
+
+
+
+
+
+
+ Resource Tier
+
+
+ setConfig({
+ ...config,
+ resourceTier: value as SandboxConfig["resourceTier"],
+ })
+ }
+ >
+
+
+
+
+
+
+ Basic - 1 CPU, 1 GB RAM, 3 GB Disk
+
+
+
+ Standard - 2 CPU, 4 GB RAM, 8 GB Disk
+
+
+
+ Performance - 4 CPU, 8 GB RAM, 10 GB Disk
+
+
+
+
+
+
+
+ Default Language
+
+
+ setConfig({ ...config, defaultLanguage: value })
+ }
+ >
+
+
+
+
+ Python
+ JavaScript
+ TypeScript
+ Bash
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/sandbox/sandbox-panel.tsx b/apps/web/src/components/sandbox/sandbox-panel.tsx
index 5a776a5..6d5735e 100644
--- a/apps/web/src/components/sandbox/sandbox-panel.tsx
+++ b/apps/web/src/components/sandbox/sandbox-panel.tsx
@@ -13,6 +13,7 @@ import {
useSandboxPanel,
} from "../../lib/sandbox-panel-context";
import { cn } from "../../lib/utils";
+import { RoseCurveSpinner } from "../rose-curve-spinner";
import { CommandRunner } from "./command-input";
import { FileExplorer } from "./file-explorer";
import { FileViewer } from "./file-viewer";
@@ -89,6 +90,7 @@ export function SandboxPanel() {
const {
activeTab,
setActiveTab,
+ reloadKey,
activeFile,
openFiles,
togglePanel,
@@ -108,8 +110,10 @@ export function SandboxPanel() {
className="relative flex h-full flex-col overflow-hidden border-l border-border bg-background"
>
{/* Resize handle */}
-
@@ -179,7 +183,10 @@ export function SandboxPanel() {
{/* Content */}
-
+
{/* Files tab */}
{activeTab === "files" && (
@@ -266,7 +273,11 @@ export function SandboxPanel() {
{terminalMode === "pty" ? (
+
+
Loading terminal...
diff --git a/apps/web/src/components/sandbox/terminal.tsx b/apps/web/src/components/sandbox/terminal.tsx
index d1bef6b..90230f1 100644
--- a/apps/web/src/components/sandbox/terminal.tsx
+++ b/apps/web/src/components/sandbox/terminal.tsx
@@ -1,9 +1,10 @@
import { useAuth } from "@clerk/clerk-react";
import "@xterm/xterm/css/xterm.css";
-import { Loader2, RotateCcw, TerminalSquare } from "lucide-react";
+import { RotateCcw, TerminalSquare } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { env } from "../../env";
import { useSandboxPanel } from "../../lib/sandbox-panel-context";
+import { RoseCurveSpinner } from "../rose-curve-spinner";
const WS_URL = (() => {
const url = new URL(env.VITE_FASTAPI_URL ?? "http://localhost:8000");
@@ -230,10 +231,7 @@ export function WebTerminal() {
: "disconnected"}
{state === "connecting" && (
-
+
)}
{(state === "disconnected" || state === "error") && (
diff --git a/apps/web/src/components/skill-viewer-dialog.tsx b/apps/web/src/components/skill-viewer-dialog.tsx
new file mode 100644
index 0000000..7cd4790
--- /dev/null
+++ b/apps/web/src/components/skill-viewer-dialog.tsx
@@ -0,0 +1,139 @@
+import { convexQuery, useConvexAction } from "@convex-dev/react-query";
+import { api } from "@harness/convex-backend/convex/_generated/api";
+import { useQuery } from "@tanstack/react-query";
+import { Download } from "lucide-react";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { MarkdownMessage } from "./markdown-message";
+import { RoseCurveSpinner } from "./rose-curve-spinner";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "./ui/dialog";
+
+interface SkillViewerDialogProps {
+ /** The full skill ID, e.g. "vercel-labs/agent-skills/vercel-react-best-practices" */
+ fullId: string | null;
+ /** Short display name */
+ skillId?: string;
+ /** Source repo path */
+ source?: string;
+ /** Install count */
+ installs?: number;
+ onClose: () => void;
+}
+
+export function SkillViewerDialog({
+ fullId,
+ skillId,
+ source,
+ installs,
+ onClose,
+}: SkillViewerDialogProps) {
+ const ensureSkillDetailsFn = useConvexAction(api.skills.ensureSkillDetails);
+ // Track per-skill ensure status: maps fullId → { done, error }
+ const ensureStatusRef = useRef(
+ new Map(),
+ );
+ // Force re-render when a status changes
+ const [, forceUpdate] = useState(0);
+
+ const detailQuery = useQuery({
+ ...convexQuery(api.skills.getByName, { name: fullId ?? "" }),
+ enabled: !!fullId,
+ });
+
+ const currentStatus = fullId
+ ? ensureStatusRef.current.get(fullId)
+ : undefined;
+ const ensureDone = currentStatus?.done ?? false;
+ const ensureError = currentStatus?.error ?? false;
+
+ // Fetch details when a new fullId is opened
+ useEffect(() => {
+ if (!fullId) return;
+ if (ensureStatusRef.current.has(fullId)) return;
+ ensureStatusRef.current.set(fullId, { done: false, error: false });
+ forceUpdate((n) => n + 1);
+ ensureSkillDetailsFn({ names: [fullId] })
+ .then(() => {
+ ensureStatusRef.current.set(fullId, { done: true, error: false });
+ forceUpdate((n) => n + 1);
+ })
+ .catch(() => {
+ ensureStatusRef.current.set(fullId, { done: true, error: true });
+ forceUpdate((n) => n + 1);
+ });
+ }, [fullId, ensureSkillDetailsFn]);
+
+ const formatInstalls = useCallback((n: number) => {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
+ return n.toString();
+ }, []);
+
+ const displayName = skillId ?? fullId?.split("/").pop() ?? "";
+ const displaySource =
+ source ?? (fullId ? fullId.split("/").slice(0, -1).join("/") : "");
+
+ return (
+ {
+ if (!open) onClose();
+ }}
+ >
+
+
+ {displayName}
+
+ {displaySource}
+ {installs != null && installs > 0 && (
+
+
+ {formatInstalls(installs)}
+
+ )}
+
+
+
+ {detailQuery.data?.detail ? (
+
+ ) : detailQuery.isError || ensureError ? (
+
+
+ Failed to load skill documentation.
+
+
+ ) : ensureDone &&
+ !detailQuery.isLoading &&
+ !detailQuery.isFetching ? (
+
+
+ No documentation available for this skill.
+
+
+ ) : (
+
+
+
+ Fetching skill documentation...
+
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/web/src/components/skills-browser.tsx b/apps/web/src/components/skills-browser.tsx
index 219a21d..34cd8b0 100644
--- a/apps/web/src/components/skills-browser.tsx
+++ b/apps/web/src/components/skills-browser.tsx
@@ -5,7 +5,7 @@ import {
ArrowLeft,
ArrowRight,
Download,
- Loader2,
+ Eye,
Search,
X,
Zap,
@@ -13,6 +13,8 @@ import {
import { useCallback, useEffect, useRef, useState } from "react";
import type { SkillEntry, SkillRow } from "../lib/skills";
import { searchSkillsSh } from "../lib/skills-api";
+import { RoseCurveSpinner } from "./rose-curve-spinner";
+import { SkillViewerDialog } from "./skill-viewer-dialog";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { Checkbox } from "./ui/checkbox";
@@ -20,6 +22,43 @@ import { Input } from "./ui/input";
const PAGE_SIZE = 20;
+/**
+ * Score a skill for search ranking.
+ * Combines text relevance (how well query matches skillId/description)
+ * with popularity (log-scaled installs). This is the standard approach
+ * used by package registries like npm, PyPI, etc.
+ */
+function scoreSkill(skill: SkillRow, query: string): number {
+ const q = query.toLowerCase();
+ const id = skill.skillId.toLowerCase();
+ const desc = skill.description.toLowerCase();
+
+ // Text relevance score (0-100)
+ let textScore = 0;
+ if (id === q) {
+ textScore = 100; // Exact match on skillId
+ } else if (id.startsWith(q)) {
+ textScore = 80; // Prefix match
+ } else if (id.includes(q)) {
+ textScore = 60; // Substring match on skillId
+ } else if (desc.includes(q)) {
+ textScore = 40; // Match in description
+ } else {
+ // Partial/fuzzy: check if all query words appear somewhere
+ const words = q.split(/\s+/);
+ const combined = `${id} ${desc}`;
+ const matchCount = words.filter((w) => combined.includes(w)).length;
+ textScore = 20 * (matchCount / Math.max(words.length, 1));
+ }
+
+ // Popularity score: log-scaled installs (0 ~50 range for typical values)
+ // log10(1) = 0, log10(1000) = 3, log10(1M) = 6
+ const popularityScore = Math.log10(skill.installs + 1) * 8;
+
+ // Weighted combination: relevance is primary, popularity is tiebreaker
+ return textScore * 0.7 + popularityScore * 0.3;
+}
+
export function SkillsBrowser({
currentSkills,
onToggle,
@@ -35,6 +74,8 @@ export function SkillsBrowser({
const [browsePageIndex, setBrowsePageIndex] = useState(0);
const debounceRef = useRef>(undefined);
+ const [viewingSkill, setViewingSkill] = useState(null);
+
const discoverSkillsFn = useConvexAction(api.skills.discoverSkillsFromSearch);
useEffect(() => {
@@ -97,7 +138,7 @@ export function SkillsBrowser({
}).catch(() => {});
}, [skillsShQuery.data, discoverSkillsFn]);
- // Merge search results: prefer Convex (has descriptions) over skills.sh
+ // Merge and rank search results by relevance + popularity
const searchResults = (() => {
if (!debouncedSearch) return null;
const convexRows: SkillRow[] = (convexSearchQuery.data ?? []).map((d) => ({
@@ -109,15 +150,21 @@ export function SkillsBrowser({
}));
const shRows = skillsShQuery.data?.rows ?? [];
- // Merge: convex results first, then skills.sh results not already in convex
- const seen = new Set(convexRows.map((r) => r.fullId));
- const merged = [...convexRows];
- for (const row of shRows) {
+ // Merge and deduplicate, preferring Convex rows (have descriptions)
+ const seen = new Set();
+ const merged: SkillRow[] = [];
+ for (const row of [...convexRows, ...shRows]) {
if (!seen.has(row.fullId)) {
seen.add(row.fullId);
merged.push(row);
}
}
+
+ // Rank by combined text relevance + popularity score
+ merged.sort(
+ (a, b) => scoreSkill(b, debouncedSearch) - scoreSkill(a, debouncedSearch),
+ );
+
return merged;
})();
@@ -217,14 +264,14 @@ export function SkillsBrowser({
{(searchResults ?? []).length.toLocaleString()} results
{isFetching && (
-
+
)}
)}
{isLoading ? (
-
+
) : rows.length === 0 ? (
@@ -239,17 +286,14 @@ export function SkillsBrowser({
{rows.map((skill) => {
const added = isAdded(skill.fullId);
+ const skillPayload = {
+ name: skill.fullId,
+ description: skill.description,
+ };
return (
-
- onToggle({
- name: skill.fullId,
- description: skill.description,
- })
- }
- className={`flex items-start gap-3 border p-3 text-left transition-colors ${
+ className={`flex items-start gap-3 border p-3 transition-colors ${
added
? "border-foreground bg-foreground/3"
: "border-border hover:border-foreground/20"
@@ -258,9 +302,14 @@ export function SkillsBrowser({
onToggle(skillPayload)}
/>
-
+
onToggle(skillPayload)}
+ className="min-w-0 flex-1 border-0 bg-transparent p-0 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
+ >
{skill.skillId}
@@ -275,8 +324,16 @@ export function SkillsBrowser({
{skill.source}
-
-
+
+
setViewingSkill(skill)}
+ className="mt-0.5 shrink-0 border-0 bg-transparent p-0 text-muted-foreground/40 transition-colors hover:text-foreground"
+ >
+
+
+
);
})}
@@ -313,6 +370,13 @@ export function SkillsBrowser({
)}
+
setViewingSkill(null)}
+ />
);
}
diff --git a/apps/web/src/components/slash-commands.tsx b/apps/web/src/components/slash-commands.tsx
new file mode 100644
index 0000000..493708c
--- /dev/null
+++ b/apps/web/src/components/slash-commands.tsx
@@ -0,0 +1,267 @@
+import { Wrench } from "lucide-react";
+import type React from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import toast from "react-hot-toast";
+import type { McpServerCommand } from "../lib/mcp";
+import { cn } from "../lib/utils";
+
+// ─── Types ───────────────────────────────────────────────────────────────────
+
+export type SlashCommand = McpServerCommand;
+
+// ─── Internal helpers ────────────────────────────────────────────────────────
+
+function parseSlashCommand(
+ text: string,
+ commands: SlashCommand[],
+): { toolName: string; message: string } | null {
+ if (!text.startsWith("/")) return null;
+
+ const afterSlash = text.slice(1).trim();
+ if (!afterSlash) return null;
+
+ const sorted = [...commands].sort((a, b) => b.name.length - a.name.length);
+
+ for (const cmd of sorted) {
+ if (afterSlash === cmd.name || afterSlash.startsWith(`${cmd.name} `)) {
+ return {
+ toolName: cmd.name,
+ message: afterSlash.slice(cmd.name.length).trim(),
+ };
+ }
+ if (afterSlash === cmd.tool || afterSlash.startsWith(`${cmd.tool} `)) {
+ return {
+ toolName: cmd.name,
+ message: afterSlash.slice(cmd.tool.length).trim(),
+ };
+ }
+ }
+
+ return null;
+}
+
+function filterCommands(
+ commands: SlashCommand[],
+ query: string,
+): SlashCommand[] {
+ if (!query) return commands;
+ const q = query.toLowerCase();
+ return commands.filter(
+ (cmd) =>
+ cmd.tool.toLowerCase().includes(q) ||
+ cmd.server.toLowerCase().includes(q) ||
+ cmd.name.toLowerCase().includes(q) ||
+ cmd.description.toLowerCase().includes(q),
+ );
+}
+
+function extractQuery(text: string): string {
+ if (!text.startsWith("/")) return "";
+ const afterSlash = text.slice(1);
+ const spaceIdx = afterSlash.indexOf(" ");
+ return spaceIdx === -1
+ ? afterSlash.toLowerCase()
+ : afterSlash.slice(0, spaceIdx).toLowerCase();
+}
+
+// ─── Main hook: useSlashCommandInput ─────────────────────────────────────────
+
+interface UseSlashCommandInputOptions {
+ storedCommands: SlashCommand[];
+ text: string;
+ setText: (text: string) => void;
+ textareaRef: React.RefObject;
+}
+
+export function useSlashCommandInput({
+ storedCommands,
+ text,
+ setText,
+ textareaRef,
+}: UseSlashCommandInputOptions) {
+ const commands = storedCommands;
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ // ── Menu open/close based on text ──────────────────────────────────────────
+
+ useEffect(() => {
+ if (text.startsWith("/") && !text.includes("\n")) {
+ const parsed = parseSlashCommand(text, commands);
+ setMenuOpen(!parsed);
+ } else {
+ setMenuOpen(false);
+ }
+ }, [text, commands]);
+
+ // ── Derived state ──────────────────────────────────────────────────────────
+
+ const query = useMemo(() => extractQuery(text), [text]);
+ const filtered = useMemo(
+ () => filterCommands(commands, query),
+ [commands, query],
+ );
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: query is intentionally used to reset selection when search changes
+ useEffect(() => {
+ setSelectedIndex(0);
+ }, [query]);
+
+ // ── Select a command from the menu ─────────────────────────────────────────
+
+ const selectCommand = useCallback(
+ (cmd: SlashCommand) => {
+ setText(`/${cmd.tool} `);
+ setMenuOpen(false);
+ textareaRef.current?.focus();
+ },
+ [setText, textareaRef],
+ );
+
+ // ── Keyboard handler ──────────────────────────────────────────────────────
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent): boolean => {
+ if (!menuOpen) return false;
+
+ if (e.key === "Escape") {
+ e.preventDefault();
+ setMenuOpen(false);
+ return true;
+ }
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0));
+ return true;
+ }
+ if (e.key === "ArrowDown") {
+ e.preventDefault();
+ setSelectedIndex((prev) =>
+ prev < filtered.length - 1 ? prev + 1 : prev,
+ );
+ return true;
+ }
+ if (e.key === "Tab" || e.key === "Enter") {
+ e.preventDefault();
+ const selected = filtered[selectedIndex];
+ if (selected) selectCommand(selected);
+ return true;
+ }
+ return false;
+ },
+ [menuOpen, filtered, selectedIndex, selectCommand],
+ );
+
+ // ── trySend ────────────────────────────────────────────────────────────────
+ // Returns { forcedTool, message } if this is a slash command, or null if not.
+ // The caller should strip the command prefix and send the message through the
+ // normal chat stream with forced_tool set.
+
+ const trySend = useCallback(
+ (content: string): { forcedTool: string; message: string } | null => {
+ const parsed = parseSlashCommand(content, commands);
+ if (!parsed) return null;
+
+ if (!parsed.message) {
+ toast.error(
+ "Add a message after the command, e.g. /tool describe what you want",
+ );
+ return { forcedTool: "", message: "" }; // signal "handled but don't send"
+ }
+
+ setMenuOpen(false);
+ return { forcedTool: parsed.toolName, message: parsed.message };
+ },
+ [commands],
+ );
+
+ // ── Public API ─────────────────────────────────────────────────────────────
+
+ return {
+ menuOpen,
+ commands,
+ filtered,
+ selectedIndex,
+ selectCommand,
+ handleKeyDown,
+ trySend,
+ };
+}
+
+// ─── Component: SlashCommandMenu ─────────────────────────────────────────────
+
+interface SlashCommandMenuProps {
+ isOpen: boolean;
+ commands: SlashCommand[];
+ filtered: SlashCommand[];
+ selectedIndex: number;
+ onSelect: (command: SlashCommand) => void;
+}
+
+export function SlashCommandMenu({
+ isOpen,
+ commands,
+ filtered,
+ selectedIndex,
+ onSelect,
+}: SlashCommandMenuProps) {
+ const listRef = useRef(null);
+
+ useEffect(() => {
+ if (!listRef.current) return;
+ const items = listRef.current.querySelectorAll("[data-command-item]");
+ items[selectedIndex]?.scrollIntoView({ block: "nearest" });
+ }, [selectedIndex]);
+
+ if (!isOpen) return null;
+
+ return (
+
+
+ {filtered.length === 0 ? (
+
+ {commands.length === 0
+ ? "No MCP tools available"
+ : "No commands match your search"}
+
+ ) : (
+ filtered.map((cmd, idx) => (
+
{
+ e.preventDefault();
+ onSelect(cmd);
+ }}
+ className={cn(
+ "flex w-full items-start gap-2.5 px-3 py-2 text-left text-sm transition-colors",
+ idx === selectedIndex
+ ? "bg-accent text-accent-foreground"
+ : "text-foreground hover:bg-accent/50",
+ )}
+ >
+
+
+
+ /{cmd.tool}
+
+ {cmd.server}
+
+
+ {cmd.description && (
+
+ {cmd.description}
+
+ )}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/thinking-five-spinner.tsx b/apps/web/src/components/thinking-five-spinner.tsx
new file mode 100644
index 0000000..0f01bdd
--- /dev/null
+++ b/apps/web/src/components/thinking-five-spinner.tsx
@@ -0,0 +1,166 @@
+import { useEffect, useRef } from "react";
+import { cn } from "../lib/utils";
+
+interface ThinkingFiveSpinnerProps {
+ size?: number | string;
+ className?: string;
+ label?: string;
+}
+
+const PARTICLE_COUNT = 62;
+const TRAIL_SPAN = 0.38;
+const DURATION_MS = 4600;
+const ROTATION_DURATION_MS = 28000;
+const PULSE_DURATION_MS = 4200;
+const STROKE_WIDTH = 5.5;
+const BASE_RADIUS = 7;
+const DETAIL_AMPLITUDE = 3;
+const PETAL_COUNT = 5;
+const CURVE_SCALE = 3.9;
+const PATH_STEPS = 480;
+
+function point(progress: number, detailScale: number) {
+ const t = progress * Math.PI * 2;
+ const x =
+ BASE_RADIUS * Math.cos(t) -
+ DETAIL_AMPLITUDE * detailScale * Math.cos(PETAL_COUNT * t);
+ const y =
+ BASE_RADIUS * Math.sin(t) -
+ DETAIL_AMPLITUDE * detailScale * Math.sin(PETAL_COUNT * t);
+ return { x: 50 + x * CURVE_SCALE, y: 50 + y * CURVE_SCALE };
+}
+
+function normalizeProgress(p: number) {
+ return ((p % 1) + 1) % 1;
+}
+
+function getDetailScale(time: number) {
+ const pulseProgress = (time % PULSE_DURATION_MS) / PULSE_DURATION_MS;
+ const pulseAngle = pulseProgress * Math.PI * 2;
+ return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48;
+}
+
+function buildPath(detailScale: number) {
+ let d = "";
+ for (let i = 0; i <= PATH_STEPS; i++) {
+ const pt = point(i / PATH_STEPS, detailScale);
+ d += `${i === 0 ? "M" : "L"} ${pt.x.toFixed(2)} ${pt.y.toFixed(2)} `;
+ }
+ return d;
+}
+
+const INITIAL_DETAIL_SCALE = getDetailScale(0);
+const INITIAL_PATH_D = buildPath(INITIAL_DETAIL_SCALE);
+const INITIAL_PARTICLES = Array.from({ length: PARTICLE_COUNT }, (_, i) => {
+ const tailOffset = i / (PARTICLE_COUNT - 1);
+ const pt = point(
+ normalizeProgress(-tailOffset * TRAIL_SPAN),
+ INITIAL_DETAIL_SCALE,
+ );
+ const fade = (1 - tailOffset) ** 0.56;
+ return {
+ cx: pt.x.toFixed(2),
+ cy: pt.y.toFixed(2),
+ r: (0.9 + fade * 2.7).toFixed(2),
+ opacity: (0.04 + fade * 0.96).toFixed(3),
+ };
+});
+
+export function ThinkingFiveSpinner({
+ size = 96,
+ className,
+ label = "Loading",
+}: ThinkingFiveSpinnerProps) {
+ const groupRef = useRef(null);
+ const pathRef = useRef(null);
+ const particleRefs = useRef>([]);
+
+ useEffect(() => {
+ const group = groupRef.current;
+ const pathEl = pathRef.current;
+ if (!group || !pathEl) return;
+
+ const reduced = window?.matchMedia?.(
+ "(prefers-reduced-motion: reduce)",
+ ).matches;
+
+ const paintFrame = (time: number) => {
+ const progress = (time % DURATION_MS) / DURATION_MS;
+ const detailScale = getDetailScale(time);
+ const rotation =
+ -((time % ROTATION_DURATION_MS) / ROTATION_DURATION_MS) * 360;
+
+ group.setAttribute("transform", `rotate(${rotation} 50 50)`);
+ pathEl.setAttribute("d", buildPath(detailScale));
+
+ for (let i = 0; i < PARTICLE_COUNT; i++) {
+ const node = particleRefs.current[i];
+ if (!node) continue;
+ const tailOffset = i / (PARTICLE_COUNT - 1);
+ const pt = point(
+ normalizeProgress(progress - tailOffset * TRAIL_SPAN),
+ detailScale,
+ );
+ const fade = (1 - tailOffset) ** 0.56;
+ node.setAttribute("cx", pt.x.toFixed(2));
+ node.setAttribute("cy", pt.y.toFixed(2));
+ node.setAttribute("r", (0.9 + fade * 2.7).toFixed(2));
+ node.setAttribute("opacity", (0.04 + fade * 0.96).toFixed(3));
+ }
+ };
+
+ if (reduced) {
+ paintFrame(0);
+ return;
+ }
+
+ const startedAt = performance.now();
+ let rafId = 0;
+ const tick = (now: number) => {
+ paintFrame(now - startedAt);
+ rafId = requestAnimationFrame(tick);
+ };
+ rafId = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(rafId);
+ }, []);
+
+ const dim = typeof size === "number" ? `${size}px` : size;
+
+ return (
+
+
+
+ {INITIAL_PARTICLES.map((p, i) => (
+ {
+ particleRefs.current[i] = el;
+ }}
+ fill="currentColor"
+ cx={p.cx}
+ cy={p.cy}
+ r={p.r}
+ opacity={p.opacity}
+ />
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/usage-dialog.tsx b/apps/web/src/components/usage-dialog.tsx
new file mode 100644
index 0000000..f8ee515
--- /dev/null
+++ b/apps/web/src/components/usage-dialog.tsx
@@ -0,0 +1,26 @@
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { UsageDisplay } from "./usage-display";
+
+export function UsageDialog({
+ open,
+ onOpenChange,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ return (
+
+
+
+ Usage
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/usage-display.test.ts b/apps/web/src/components/usage-display.test.ts
new file mode 100644
index 0000000..a15ac2e
--- /dev/null
+++ b/apps/web/src/components/usage-display.test.ts
@@ -0,0 +1,37 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { formatResetTime } from "./usage-display";
+
+describe("formatResetTime", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-04-21T12:00:00Z"));
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns 'now' when the reset time is in the past or equal", () => {
+ expect(formatResetTime("2026-04-21T12:00:00Z")).toBe("now");
+ expect(formatResetTime("2026-04-21T11:00:00Z")).toBe("now");
+ });
+
+ it("returns minutes only when less than one hour remains", () => {
+ // 45 min away
+ expect(formatResetTime("2026-04-21T12:45:00Z")).toBe("45m");
+ });
+
+ it("returns hours and minutes when between 1 and 24 hours remain", () => {
+ // 5h 30m away
+ expect(formatResetTime("2026-04-21T17:30:00Z")).toBe("5h 30m");
+ });
+
+ it("returns days and hours when more than 24 hours remain", () => {
+ // 2 days, 3 hours away
+ expect(formatResetTime("2026-04-23T15:00:00Z")).toBe("2d 3h");
+ });
+
+ it("rounds minutes down (uses Math.floor)", () => {
+ // 1h 59m 59s away -> 1h 59m
+ expect(formatResetTime("2026-04-21T13:59:59Z")).toBe("1h 59m");
+ });
+});
diff --git a/apps/web/src/components/usage-display.tsx b/apps/web/src/components/usage-display.tsx
new file mode 100644
index 0000000..ad72f36
--- /dev/null
+++ b/apps/web/src/components/usage-display.tsx
@@ -0,0 +1,227 @@
+import { convexQuery } from "@convex-dev/react-query";
+import { api } from "@harness/convex-backend/convex/_generated/api";
+import { useQuery } from "@tanstack/react-query";
+import { cn } from "@/lib/utils";
+
+function ProgressBar({
+ pct,
+ label,
+ sublabel,
+}: {
+ pct: number;
+ label: string;
+ sublabel?: string;
+}) {
+ const color =
+ pct >= 90 ? "bg-red-500" : pct >= 70 ? "bg-yellow-500" : "bg-emerald-500";
+
+ return (
+
+
+ {label}
+ = 90
+ ? "text-red-400"
+ : pct >= 70
+ ? "text-yellow-400"
+ : "text-foreground/60",
+ )}
+ >
+ {Math.round(pct)}%
+
+
+
+ {sublabel &&
{sublabel}
}
+
+ );
+}
+
+function ModelBreakdown({
+ items,
+}: {
+ items: Array<{ model: string; pct: number; tokensUsed: number }>;
+}) {
+ if (items.length === 0) return null;
+
+ const sorted = [...items].sort((a, b) => b.pct - a.pct);
+
+ return (
+
+
+ By Model
+
+
+ {sorted.map((item) => (
+
+
+ {item.model}
+
+
+
+ {Math.round(item.pct)}%
+
+
+ ))}
+
+
+ );
+}
+
+function HarnessBreakdown({
+ items,
+}: {
+ items: Array<{
+ harnessId: string;
+ harnessName: string;
+ pct: number;
+ tokensUsed: number;
+ }>;
+}) {
+ if (items.length === 0) return null;
+
+ const sorted = [...items].sort((a, b) => b.pct - a.pct);
+
+ return (
+
+
+ By Harness
+
+
+ {sorted.map((item) => (
+
+
+ {item.harnessName}
+
+
+
+ {Math.round(item.pct)}%
+
+
+ ))}
+
+
+ );
+}
+
+export function formatResetTime(isoString: string): string {
+ const reset = new Date(isoString);
+ const now = new Date();
+ const diffMs = reset.getTime() - now.getTime();
+ if (diffMs <= 0) return "now";
+
+ const hours = Math.floor(diffMs / (1000 * 60 * 60));
+ const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
+
+ if (hours > 24) {
+ const days = Math.floor(hours / 24);
+ return `${days}d ${hours % 24}h`;
+ }
+ if (hours > 0) return `${hours}h ${minutes}m`;
+ return `${minutes}m`;
+}
+
+export function UsageDisplay() {
+ const { data: usage, error } = useQuery(
+ convexQuery(api.usage.getUserUsage, {}),
+ );
+
+ if (error) {
+ return (
+
+ Could not load usage.
+
+ );
+ }
+
+ if (!usage) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {(usage.dailyLimitReached || usage.weeklyLimitReached) && (
+
+ {usage.dailyLimitReached
+ ? "Daily usage limit reached."
+ : "Weekly usage limit reached."}{" "}
+ Your limit will reset soon.
+
+ )}
+
+
+
+
+ );
+}
+
+/**
+ * Compact usage badge for the sidebar/header.
+ * Shows the higher of daily/weekly percentage with a color indicator.
+ */
+export function UsageBadge({ onClick }: { onClick?: () => void }) {
+ const { data: usage } = useQuery(convexQuery(api.usage.getUserUsage, {}));
+
+ if (!usage) return null;
+
+ const pct = Math.max(usage.dailyPctUsed, usage.weeklyPctUsed);
+ const color =
+ pct >= 90
+ ? "text-red-400"
+ : pct >= 70
+ ? "text-yellow-400"
+ : "text-foreground/50";
+ const dotColor =
+ pct >= 90 ? "bg-red-400" : pct >= 70 ? "bg-yellow-400" : "bg-emerald-400";
+
+ return (
+
+
+ {Math.round(pct)}% used
+
+ );
+}
diff --git a/apps/web/src/components/workspace-color-picker.test.tsx b/apps/web/src/components/workspace-color-picker.test.tsx
new file mode 100644
index 0000000..ced5810
--- /dev/null
+++ b/apps/web/src/components/workspace-color-picker.test.tsx
@@ -0,0 +1,64 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import { WORKSPACE_COLORS } from "../lib/workspace-colors";
+import { WorkspaceColorPicker } from "./workspace-color-picker";
+
+describe("WorkspaceColorPicker", () => {
+ it("renders the 'No color' button plus one per color", () => {
+ render( {}} />);
+ // 1 None + N colors
+ const buttons = screen.getAllByRole("button");
+ expect(buttons).toHaveLength(1 + WORKSPACE_COLORS.length);
+ expect(screen.getByLabelText("No color")).toBeInTheDocument();
+ for (const c of WORKSPACE_COLORS) {
+ expect(screen.getByLabelText(c.label)).toBeInTheDocument();
+ }
+ });
+
+ it("marks 'No color' as pressed when value is null", () => {
+ render( {}} />);
+ expect(screen.getByLabelText("No color")).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ for (const c of WORKSPACE_COLORS) {
+ expect(screen.getByLabelText(c.label)).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+ }
+ });
+
+ it("marks the selected color as pressed", () => {
+ render( {}} />);
+ expect(screen.getByLabelText("Mint")).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ expect(screen.getByLabelText("No color")).toHaveAttribute(
+ "aria-pressed",
+ "false",
+ );
+ });
+
+ it("calls onChange with the color key when a color button is clicked", () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByLabelText("Peach"));
+ expect(onChange).toHaveBeenCalledWith("peach");
+ });
+
+ it("calls onChange with null when the 'No color' button is clicked", () => {
+ const onChange = vi.fn();
+ render( );
+ fireEvent.click(screen.getByLabelText("No color"));
+ expect(onChange).toHaveBeenCalledWith(null);
+ });
+
+ it("applies the color's background via inline style", () => {
+ render( {}} />);
+ const rose = screen.getByLabelText("Rose");
+ // jsdom lowercases hex but normalizes to rgb in some browsers; compare via style prop
+ expect(rose.getAttribute("style")).toContain("background-color");
+ });
+});
diff --git a/apps/web/src/components/workspace-color-picker.tsx b/apps/web/src/components/workspace-color-picker.tsx
new file mode 100644
index 0000000..ed839c4
--- /dev/null
+++ b/apps/web/src/components/workspace-color-picker.tsx
@@ -0,0 +1,52 @@
+import { Check } from "lucide-react";
+import { cn } from "../lib/utils";
+import { WORKSPACE_COLORS } from "../lib/workspace-colors";
+
+interface WorkspaceColorPickerProps {
+ value: string | null;
+ onChange: (value: string | null) => void;
+}
+
+export function WorkspaceColorPicker({
+ value,
+ onChange,
+}: WorkspaceColorPickerProps) {
+ return (
+
+ onChange(null)}
+ title="No color"
+ aria-label="No color"
+ aria-pressed={value === null}
+ className={cn(
+ "flex h-6 w-6 items-center justify-center rounded-full border border-border bg-background transition-all hover:scale-110",
+ value === null &&
+ "ring-2 ring-foreground/60 ring-offset-1 ring-offset-background",
+ )}
+ >
+
+
+ {WORKSPACE_COLORS.map((color) => (
+ onChange(color.key)}
+ title={color.label}
+ aria-label={color.label}
+ aria-pressed={value === color.key}
+ style={{ backgroundColor: color.hex }}
+ className={cn(
+ "flex h-6 w-6 items-center justify-center rounded-full border border-border/50 transition-all hover:scale-110",
+ value === color.key &&
+ "ring-2 ring-foreground/60 ring-offset-1 ring-offset-background",
+ )}
+ >
+ {value === color.key && (
+
+ )}
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts
index 1c8b986..7d85b12 100644
--- a/apps/web/src/env.ts
+++ b/apps/web/src/env.ts
@@ -4,6 +4,7 @@ import { z } from "zod";
export const env = createEnv({
server: {
SERVER_URL: z.string().url().optional(),
+ ARCJET_KEY: z.string().min(1),
},
/**
@@ -23,7 +24,12 @@ export const env = createEnv({
* What object holds the environment variables at runtime. This is usually
* `process.env` or `import.meta.env`.
*/
- runtimeEnv: import.meta.env,
+ runtimeEnv: {
+ ...import.meta.env,
+ SERVER_URL: process.env.SERVER_URL,
+ // Inlined by Vite's `define` in vite.config.ts at build time.
+ ARCJET_KEY: process.env.ARCJET_KEY,
+ },
/**
* By default, this library will feed the environment variables directly to
diff --git a/apps/web/src/hooks/use-command-palette-hotkey.test.tsx b/apps/web/src/hooks/use-command-palette-hotkey.test.tsx
new file mode 100644
index 0000000..05e5f0d
--- /dev/null
+++ b/apps/web/src/hooks/use-command-palette-hotkey.test.tsx
@@ -0,0 +1,165 @@
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ CommandPaletteProvider,
+ useCommandPalette,
+} from "../lib/command-palette/context";
+import { useCommandPaletteHotkey } from "./use-command-palette-hotkey";
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+function dispatchKey(init: KeyboardEventInit & { target?: Element }) {
+ const target = init.target ?? document.body;
+ const evt = new KeyboardEvent("keydown", {
+ ...init,
+ bubbles: true,
+ cancelable: true,
+ });
+ Object.defineProperty(evt, "target", { value: target, configurable: true });
+ target.dispatchEvent(evt);
+ return evt;
+}
+
+describe("useCommandPaletteHotkey", () => {
+ it("opens the palette on Meta+K", () => {
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() => {
+ dispatchKey({ key: "k", metaKey: true });
+ });
+ expect(result.current.open).toBe(true);
+ });
+
+ it("opens the palette on Ctrl+K", () => {
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() => dispatchKey({ key: "K", ctrlKey: true }));
+ expect(result.current.open).toBe(true);
+ });
+
+ it("toggles closed on a second press", () => {
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() => dispatchKey({ key: "k", metaKey: true }));
+ expect(result.current.open).toBe(true);
+ act(() => dispatchKey({ key: "k", metaKey: true }));
+ expect(result.current.open).toBe(false);
+ });
+
+ it("opens on Meta+Shift+P", () => {
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() => dispatchKey({ key: "p", metaKey: true, shiftKey: true }));
+ expect(result.current.open).toBe(true);
+ });
+
+ it("ignores Shift+P inside an input", () => {
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() =>
+ dispatchKey({ key: "p", metaKey: true, shiftKey: true, target: input }),
+ );
+ expect(result.current.open).toBe(false);
+ input.remove();
+ });
+
+ it("still fires Meta+K inside an input (mid-typing is fine)", () => {
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() => dispatchKey({ key: "k", metaKey: true, target: input }));
+ expect(result.current.open).toBe(true);
+ input.remove();
+ });
+
+ it("ignores non-hotkey keys", () => {
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() => dispatchKey({ key: "j", metaKey: true }));
+ act(() => dispatchKey({ key: "p", metaKey: true })); // no shift
+ act(() => dispatchKey({ key: "k" })); // no mod
+ expect(result.current.open).toBe(false);
+ });
+
+ it("ignores auto-repeat events", () => {
+ const { result } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ act(() => dispatchKey({ key: "k", metaKey: true, repeat: true }));
+ expect(result.current.open).toBe(false);
+ });
+
+ it("detaches handler on unmount", () => {
+ const { result, unmount } = renderHook(
+ () => {
+ useCommandPaletteHotkey();
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ unmount();
+ // After unmount the listener is gone — reopening via event should not crash.
+ act(() => dispatchKey({ key: "k", metaKey: true }));
+ // Hook's result is stale post-unmount; just ensure no exception was thrown.
+ expect(true).toBe(true);
+ void result;
+ });
+
+ it("prevents default when toggling", () => {
+ renderHook(() => useCommandPaletteHotkey(), { wrapper });
+ const evt = new KeyboardEvent("keydown", {
+ key: "k",
+ metaKey: true,
+ bubbles: true,
+ cancelable: true,
+ });
+ const preventSpy = vi.spyOn(evt, "preventDefault");
+ document.body.dispatchEvent(evt);
+ expect(preventSpy).toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/src/hooks/use-command-palette-hotkey.ts b/apps/web/src/hooks/use-command-palette-hotkey.ts
new file mode 100644
index 0000000..aebdc63
--- /dev/null
+++ b/apps/web/src/hooks/use-command-palette-hotkey.ts
@@ -0,0 +1,44 @@
+import { useEffect } from "react";
+import { useCommandPalette } from "../lib/command-palette/context";
+
+function isEditableTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false;
+ if (target.isContentEditable) return true;
+ const tag = target.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
+}
+
+/**
+ * Binds the global ⌘K / Ctrl+K (and ⌘⇧P / Ctrl+Shift+P) palette toggles.
+ * Unlike per-command shortcuts, these MUST fire from editable targets too —
+ * users expect to open the palette mid-typing.
+ */
+export function useCommandPaletteHotkey(): void {
+ const { toggle } = useCommandPalette();
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.repeat) return;
+
+ const mod = e.metaKey || e.ctrlKey;
+ if (!mod) return;
+
+ const isK = e.key === "k" || e.key === "K";
+ const isShiftP = e.shiftKey && (e.key === "p" || e.key === "P");
+
+ if (!isK && !isShiftP) return;
+
+ // Let ⌘K inside a contenteditable fall through only when it would clash
+ // with a native hotkey. Today nothing in this app binds it, so we
+ // intercept unconditionally — but guard Shift+P inside editable targets
+ // since browsers don't reserve it and users may type "P" into inputs.
+ if (isShiftP && isEditableTarget(e.target)) return;
+
+ e.preventDefault();
+ toggle();
+ };
+
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [toggle]);
+}
diff --git a/apps/web/src/hooks/use-file-attachments.test.tsx b/apps/web/src/hooks/use-file-attachments.test.tsx
new file mode 100644
index 0000000..b04e199
--- /dev/null
+++ b/apps/web/src/hooks/use-file-attachments.test.tsx
@@ -0,0 +1,260 @@
+import { act, renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { mutationMock, queryMock, toastMock } = vi.hoisted(() => ({
+ mutationMock: vi.fn(),
+ queryMock: vi.fn(),
+ toastMock: vi.fn(),
+}));
+
+vi.mock("convex/react", () => ({
+ useConvex: () => ({
+ mutation: mutationMock,
+ query: queryMock,
+ }),
+}));
+
+vi.mock("react-hot-toast", () => ({
+ default: { error: toastMock, success: toastMock },
+}));
+
+vi.mock("@harness/convex-backend/convex/_generated/api", () => ({
+ api: {
+ files: {
+ generateUploadUrl: "files:generateUploadUrl",
+ getFileUrl: "files:getFileUrl",
+ },
+ },
+}));
+
+import { useFileAttachments } from "./use-file-attachments";
+
+function makeFile(name: string, type: string, size: number) {
+ const blob = new Blob([new Uint8Array(size)], { type });
+ return new File([blob], name, { type });
+}
+
+beforeEach(() => {
+ mutationMock.mockReset();
+ queryMock.mockReset();
+ toastMock.mockReset();
+ // URL.createObjectURL / revokeObjectURL in jsdom
+ if (!URL.createObjectURL) {
+ URL.createObjectURL = vi.fn(() => "blob:test");
+ }
+ if (!URL.revokeObjectURL) {
+ URL.revokeObjectURL = vi.fn();
+ }
+});
+
+describe("useFileAttachments", () => {
+ const imageMime = "image/png";
+ const pdfMime = "application/pdf";
+ const audioMime = "audio/wav";
+ const allowed = new Set([imageMime, pdfMime, audioMime]);
+
+ it("starts empty", () => {
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ expect(result.current.attachments).toEqual([]);
+ expect(result.current.hasUploading).toBe(false);
+ });
+
+ it("rejects files with disallowed mime types", () => {
+ const { result } = renderHook(() =>
+ useFileAttachments(new Set([imageMime])),
+ );
+ act(() => {
+ result.current.addFiles([makeFile("a.pdf", pdfMime, 100)]);
+ });
+ expect(result.current.attachments).toEqual([]);
+ expect(toastMock).toHaveBeenCalledWith(
+ "a.pdf: not supported by this model",
+ );
+ });
+
+ it("rejects oversized images (>10 MB)", () => {
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([
+ makeFile("big.png", imageMime, 11 * 1024 * 1024),
+ ]);
+ });
+ expect(result.current.attachments).toEqual([]);
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.stringContaining("exceeds 10 MB limit"),
+ );
+ });
+
+ it("rejects oversized PDFs (>20 MB)", () => {
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([makeFile("big.pdf", pdfMime, 21 * 1024 * 1024)]);
+ });
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.stringContaining("exceeds 20 MB limit"),
+ );
+ });
+
+ it("rejects oversized audio (>25 MB)", () => {
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([
+ makeFile("big.wav", audioMime, 26 * 1024 * 1024),
+ ]);
+ });
+ expect(toastMock).toHaveBeenCalledWith(
+ expect.stringContaining("exceeds 25 MB limit"),
+ );
+ });
+
+ it("caps at 5 attachments", () => {
+ mutationMock.mockResolvedValue("https://upload/url");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ storageId: "sid" }), { status: 200 }),
+ );
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ const files = Array.from({ length: 6 }, (_, i) =>
+ makeFile(`f${i}.png`, imageMime, 100),
+ );
+ act(() => {
+ result.current.addFiles(files);
+ });
+ expect(result.current.attachments).toHaveLength(5);
+ expect(toastMock).toHaveBeenCalledWith("Maximum 5 attachments per message");
+ });
+
+ it("creates a preview URL for images, null for non-images", () => {
+ mutationMock.mockResolvedValue("https://upload/url");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ storageId: "sid" }), { status: 200 }),
+ );
+ const createSpy = vi
+ .spyOn(URL, "createObjectURL")
+ .mockReturnValue("blob:x");
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([
+ makeFile("a.png", imageMime, 10),
+ makeFile("b.pdf", pdfMime, 10),
+ ]);
+ });
+ const att = result.current.attachments;
+ expect(att).toHaveLength(2);
+ expect(att[0].previewUrl).toBe("blob:x");
+ expect(att[1].previewUrl).toBeNull();
+ expect(createSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("transitions attachment to ready after successful upload", async () => {
+ mutationMock.mockResolvedValue("https://upload/url");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ storageId: "stored-id" }), { status: 200 }),
+ );
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([makeFile("a.png", imageMime, 10)]);
+ });
+ await waitFor(() => {
+ expect(result.current.attachments[0].status).toBe("ready");
+ });
+ expect(result.current.attachments[0].storageId).toBe("stored-id");
+ expect(result.current.hasUploading).toBe(false);
+ });
+
+ it("marks attachment as error when upload POST fails", async () => {
+ mutationMock.mockResolvedValue("https://upload/url");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("boom", { status: 500 }),
+ );
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([makeFile("a.png", imageMime, 10)]);
+ });
+ await waitFor(() => {
+ expect(result.current.attachments[0].status).toBe("error");
+ });
+ expect(toastMock).toHaveBeenCalledWith("Failed to upload a.png");
+ });
+
+ it("marks attachment as error when generateUploadUrl throws", async () => {
+ mutationMock.mockRejectedValue(new Error("auth"));
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([makeFile("a.png", imageMime, 10)]);
+ });
+ await waitFor(() => {
+ expect(result.current.attachments[0].status).toBe("error");
+ });
+ });
+
+ it("removeAttachment drops by localId and revokes preview URL", async () => {
+ mutationMock.mockResolvedValue("https://upload");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ storageId: "s" }), { status: 200 }),
+ );
+ const revoke = vi
+ .spyOn(URL, "revokeObjectURL")
+ .mockImplementation(() => {});
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([makeFile("a.png", imageMime, 10)]);
+ });
+ const id = result.current.attachments[0].localId;
+ act(() => {
+ result.current.removeAttachment(id);
+ });
+ expect(result.current.attachments).toEqual([]);
+ expect(revoke).toHaveBeenCalled();
+ });
+
+ it("clearAttachments drops all and revokes previews", async () => {
+ mutationMock.mockResolvedValue("https://upload");
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ storageId: "s" }), { status: 200 }),
+ );
+ const revoke = vi
+ .spyOn(URL, "revokeObjectURL")
+ .mockImplementation(() => {});
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([
+ makeFile("a.png", imageMime, 10),
+ makeFile("b.png", imageMime, 10),
+ ]);
+ });
+ act(() => {
+ result.current.clearAttachments();
+ });
+ expect(result.current.attachments).toEqual([]);
+ expect(revoke).toHaveBeenCalledTimes(2);
+ });
+
+ it("hasUploading is true right after addFiles", () => {
+ mutationMock.mockReturnValue(new Promise(() => {})); // hang forever
+ vi.spyOn(globalThis, "fetch").mockReturnValue(new Promise(() => {}));
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ act(() => {
+ result.current.addFiles([makeFile("a.png", imageMime, 10)]);
+ });
+ expect(result.current.hasUploading).toBe(true);
+ });
+
+ it("resolveSignedUrls returns resolved URLs and filters null", async () => {
+ queryMock.mockImplementation(async (_name, args: { storageId: string }) => {
+ if (args.storageId === "good") return "https://signed/good";
+ return null;
+ });
+ const { result } = renderHook(() => useFileAttachments(allowed));
+ const urls = await result.current.resolveSignedUrls([
+ { storageId: "good", mimeType: "image/png", fileName: "a.png" },
+ { storageId: "bad", mimeType: "image/png", fileName: "b.png" },
+ ]);
+ expect(urls).toEqual([
+ {
+ url: "https://signed/good",
+ mime_type: "image/png",
+ file_name: "a.png",
+ },
+ ]);
+ });
+});
diff --git a/apps/web/src/hooks/use-register-commands.test.tsx b/apps/web/src/hooks/use-register-commands.test.tsx
new file mode 100644
index 0000000..a523d91
--- /dev/null
+++ b/apps/web/src/hooks/use-register-commands.test.tsx
@@ -0,0 +1,77 @@
+import { act, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ CommandPaletteProvider,
+ useCommandPalette,
+} from "../lib/command-palette/context";
+import type { Command } from "../lib/command-palette/types";
+import { useRegisterCommands } from "./use-register-commands";
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+function cmd(id: string): Command {
+ return { id, title: id, group: "chat", perform: vi.fn() };
+}
+
+describe("useRegisterCommands", () => {
+ it("registers commands on mount", () => {
+ const initial = [cmd("a"), cmd("b")];
+ const { result } = renderHook(
+ () => {
+ useRegisterCommands(initial);
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ const ids = result.current
+ .snapshot()
+ .map((c) => c.id)
+ .sort();
+ expect(ids).toEqual(["a", "b"]);
+ });
+
+ it("skips effect when commands array is empty", () => {
+ const { result } = renderHook(
+ () => {
+ useRegisterCommands([]);
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ expect(result.current.snapshot()).toEqual([]);
+ });
+
+ it("unregisters on unmount", () => {
+ const initial = [cmd("x")];
+ const { result, unmount } = renderHook(
+ () => {
+ useRegisterCommands(initial);
+ return useCommandPalette();
+ },
+ { wrapper },
+ );
+ expect(result.current.snapshot().map((c) => c.id)).toContain("x");
+ unmount();
+ // Re-mount a peek hook to check registry is empty.
+ const peek = renderHook(() => useCommandPalette(), { wrapper });
+ expect(peek.result.current.snapshot()).toEqual([]);
+ });
+
+ it("replaces stale ids when the commands array changes", () => {
+ const a = cmd("a");
+ const b = cmd("b");
+ const { result, rerender } = renderHook(
+ ({ cmds }: { cmds: Command[] }) => {
+ useRegisterCommands(cmds);
+ return useCommandPalette();
+ },
+ { wrapper, initialProps: { cmds: [a] } },
+ );
+ expect(result.current.snapshot().map((c) => c.id)).toEqual(["a"]);
+ act(() => rerender({ cmds: [b] }));
+ expect(result.current.snapshot().map((c) => c.id)).toEqual(["b"]);
+ });
+});
diff --git a/apps/web/src/hooks/use-register-commands.ts b/apps/web/src/hooks/use-register-commands.ts
new file mode 100644
index 0000000..e5b26af
--- /dev/null
+++ b/apps/web/src/hooks/use-register-commands.ts
@@ -0,0 +1,19 @@
+import { useEffect } from "react";
+import { useCommandPalette } from "../lib/command-palette/context";
+import type { Command } from "../lib/command-palette/types";
+
+/**
+ * Register a set of commands for the lifetime of the calling component.
+ * Pass a memoized `commands` array (e.g. via `useMemo`) to avoid churn.
+ * Commands are keyed by `id`; re-registering with the same id replaces the entry.
+ */
+export function useRegisterCommands(commands: Command[]): void {
+ const { register, unregister } = useCommandPalette();
+
+ useEffect(() => {
+ if (commands.length === 0) return;
+ register(commands);
+ const ids = commands.map((c) => c.id);
+ return () => unregister(ids);
+ }, [commands, register, unregister]);
+}
diff --git a/apps/web/src/hooks/use-workspace-shortcuts.test.tsx b/apps/web/src/hooks/use-workspace-shortcuts.test.tsx
new file mode 100644
index 0000000..0ab7a14
--- /dev/null
+++ b/apps/web/src/hooks/use-workspace-shortcuts.test.tsx
@@ -0,0 +1,182 @@
+import { act, renderHook } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import {
+ useModifierHeld,
+ useWorkspaceShortcuts,
+} from "./use-workspace-shortcuts";
+
+type Item = { _id: string };
+
+function dispatchKey(
+ type: "keydown" | "keyup",
+ init: KeyboardEventInit & { target?: Element },
+) {
+ const target = init.target ?? document.body;
+ const evt = new KeyboardEvent(type, {
+ ...init,
+ bubbles: true,
+ cancelable: true,
+ });
+ Object.defineProperty(evt, "target", { value: target, configurable: true });
+ target.dispatchEvent(evt);
+ return evt;
+}
+
+describe("useWorkspaceShortcuts", () => {
+ it("fires onSelect for ⌘⌥1 on mac", () => {
+ const onSelect = vi.fn();
+ const items: Item[] = [{ _id: "ws-1" }, { _id: "ws-2" }];
+ renderHook(() => useWorkspaceShortcuts(items, onSelect, true));
+ act(() => {
+ dispatchKey("keydown", { code: "Digit1", metaKey: true, altKey: true });
+ });
+ expect(onSelect).toHaveBeenCalledWith("ws-1");
+ });
+
+ it("fires onSelect for Ctrl+Alt+2 on non-mac", () => {
+ const onSelect = vi.fn();
+ const items: Item[] = [{ _id: "a" }, { _id: "b" }, { _id: "c" }];
+ renderHook(() => useWorkspaceShortcuts(items, onSelect, false));
+ act(() => {
+ dispatchKey("keydown", { code: "Digit2", ctrlKey: true, altKey: true });
+ });
+ expect(onSelect).toHaveBeenCalledWith("b");
+ });
+
+ it("ignores when modifier combo is wrong", () => {
+ const onSelect = vi.fn();
+ renderHook(() => useWorkspaceShortcuts([{ _id: "a" }], onSelect, true));
+ act(() => {
+ dispatchKey("keydown", { code: "Digit1", metaKey: true }); // no alt
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("ignores when digit exceeds workspace count", () => {
+ const onSelect = vi.fn();
+ renderHook(() => useWorkspaceShortcuts([{ _id: "only" }], onSelect, true));
+ act(() => {
+ dispatchKey("keydown", { code: "Digit5", metaKey: true, altKey: true });
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("ignores when target is editable", () => {
+ const onSelect = vi.fn();
+ const input = document.createElement("input");
+ document.body.appendChild(input);
+ renderHook(() => useWorkspaceShortcuts([{ _id: "a" }], onSelect, true));
+ act(() => {
+ dispatchKey("keydown", {
+ code: "Digit1",
+ metaKey: true,
+ altKey: true,
+ target: input,
+ });
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ input.remove();
+ });
+
+ it("ignores when a modal dialog is open", () => {
+ const onSelect = vi.fn();
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+ document.body.appendChild(dialog);
+ renderHook(() => useWorkspaceShortcuts([{ _id: "a" }], onSelect, true));
+ act(() => {
+ dispatchKey("keydown", { code: "Digit1", metaKey: true, altKey: true });
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ dialog.remove();
+ });
+
+ it("tolerates undefined workspaces", () => {
+ const onSelect = vi.fn();
+ renderHook(() => useWorkspaceShortcuts(undefined, onSelect, true));
+ act(() => {
+ dispatchKey("keydown", { code: "Digit1", metaKey: true, altKey: true });
+ });
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it("uses latest workspaces after re-render without rebinding", () => {
+ const onSelect = vi.fn();
+ let items: Item[] = [{ _id: "first" }];
+ const { rerender } = renderHook(
+ ({ list }: { list: Item[] }) =>
+ useWorkspaceShortcuts(list, onSelect, true),
+ { initialProps: { list: items } },
+ );
+ items = [{ _id: "second" }];
+ rerender({ list: items });
+ act(() => {
+ dispatchKey("keydown", { code: "Digit1", metaKey: true, altKey: true });
+ });
+ expect(onSelect).toHaveBeenCalledWith("second");
+ });
+});
+
+describe("useModifierHeld", () => {
+ it("reports held when mac combo is active", () => {
+ const { result } = renderHook(() => useModifierHeld(true));
+ expect(result.current).toBe(false);
+ act(() => {
+ dispatchKey("keydown", { key: "Meta", metaKey: true, altKey: true });
+ });
+ expect(result.current).toBe(true);
+ });
+
+ it("reports held when non-mac combo is active", () => {
+ const { result } = renderHook(() => useModifierHeld(false));
+ act(() => {
+ dispatchKey("keydown", { key: "Control", ctrlKey: true, altKey: true });
+ });
+ expect(result.current).toBe(true);
+ });
+
+ it("drops to false when combo releases", () => {
+ const { result } = renderHook(() => useModifierHeld(true));
+ act(() => {
+ dispatchKey("keydown", { key: "Meta", metaKey: true, altKey: true });
+ });
+ expect(result.current).toBe(true);
+ act(() => {
+ dispatchKey("keyup", { key: "Meta", metaKey: false, altKey: false });
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it("resets on window blur", () => {
+ const { result } = renderHook(() => useModifierHeld(true));
+ act(() => {
+ dispatchKey("keydown", { key: "Meta", metaKey: true, altKey: true });
+ });
+ expect(result.current).toBe(true);
+ act(() => {
+ window.dispatchEvent(new Event("blur"));
+ });
+ expect(result.current).toBe(false);
+ });
+
+ it("resets when document becomes hidden", () => {
+ const { result } = renderHook(() => useModifierHeld(true));
+ act(() => {
+ dispatchKey("keydown", { key: "Meta", metaKey: true, altKey: true });
+ });
+ expect(result.current).toBe(true);
+ Object.defineProperty(document, "hidden", {
+ value: true,
+ configurable: true,
+ });
+ act(() => {
+ document.dispatchEvent(new Event("visibilitychange"));
+ });
+ expect(result.current).toBe(false);
+ Object.defineProperty(document, "hidden", {
+ value: false,
+ configurable: true,
+ });
+ });
+});
diff --git a/apps/web/src/hooks/use-workspace-shortcuts.ts b/apps/web/src/hooks/use-workspace-shortcuts.ts
new file mode 100644
index 0000000..b80c9a9
--- /dev/null
+++ b/apps/web/src/hooks/use-workspace-shortcuts.ts
@@ -0,0 +1,84 @@
+import { useEffect, useRef, useState } from "react";
+
+type WithId = { _id: T };
+
+function isEditableTarget(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) return false;
+ if (target.isContentEditable) return true;
+ const tag = target.tagName;
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
+}
+
+function isModalOpen(): boolean {
+ return document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
+}
+
+export function useWorkspaceShortcuts(
+ workspaces: ReadonlyArray> | undefined,
+ onSelect: (id: T) => void,
+ isMac: boolean,
+): void {
+ const workspacesRef = useRef(workspaces);
+ const onSelectRef = useRef(onSelect);
+ useEffect(() => {
+ workspacesRef.current = workspaces;
+ onSelectRef.current = onSelect;
+ });
+
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if (e.repeat) return;
+ if (isEditableTarget(e.target)) return;
+ if (isModalOpen()) return;
+
+ const comboHeld = isMac
+ ? e.metaKey && e.altKey && !e.ctrlKey && !e.shiftKey
+ : e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey;
+ if (!comboHeld) return;
+
+ const match = /^Digit([1-9])$/.exec(e.code);
+ if (!match) return;
+ const digit = Number(match[1]);
+ const list = workspacesRef.current;
+ if (!list || digit > list.length) return;
+
+ e.preventDefault();
+ onSelectRef.current(list[digit - 1]._id);
+ };
+ document.addEventListener("keydown", handler);
+ return () => document.removeEventListener("keydown", handler);
+ }, [isMac]);
+}
+
+export function useModifierHeld(isMac: boolean): boolean {
+ const [held, setHeld] = useState(false);
+ const heldRef = useRef(false);
+
+ useEffect(() => {
+ const update = (next: boolean) => {
+ if (heldRef.current === next) return;
+ heldRef.current = next;
+ setHeld(next);
+ };
+ const fromKey = (e: KeyboardEvent) => {
+ update(isMac ? e.metaKey && e.altKey : e.ctrlKey && e.altKey);
+ };
+ const reset = () => update(false);
+ const onVisibility = () => {
+ if (document.hidden) reset();
+ };
+
+ window.addEventListener("keydown", fromKey);
+ window.addEventListener("keyup", fromKey);
+ window.addEventListener("blur", reset);
+ document.addEventListener("visibilitychange", onVisibility);
+ return () => {
+ window.removeEventListener("keydown", fromKey);
+ window.removeEventListener("keyup", fromKey);
+ window.removeEventListener("blur", reset);
+ document.removeEventListener("visibilitychange", onVisibility);
+ };
+ }, [isMac]);
+
+ return held;
+}
diff --git a/apps/web/src/lib/arcjet.ts b/apps/web/src/lib/arcjet.ts
new file mode 100644
index 0000000..7284925
--- /dev/null
+++ b/apps/web/src/lib/arcjet.ts
@@ -0,0 +1,38 @@
+import { createClient } from "@arcjet/protocol/client.js";
+import { createTransport } from "@arcjet/transport";
+import arcjetCore, { shield, tokenBucket } from "arcjet";
+
+import { env } from "@/env";
+
+const BASE_URL = "https://decide.arcjet.com";
+
+const client = createClient({
+ baseUrl: BASE_URL,
+ // biome-ignore lint/suspicious/noExplicitAny: Arcjet SDK doesn't export the sdkStack enum type
+ sdkStack: "NODEJS" as any,
+ // Keep in sync with arcjet version in package.json
+ sdkVersion: "1.3.1",
+ timeout: 500,
+ transport: createTransport(BASE_URL),
+});
+
+export const aj = arcjetCore({
+ client,
+ key: env.ARCJET_KEY,
+ log: {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: console.error,
+ },
+ rules: [
+ tokenBucket({
+ mode: "LIVE",
+ characteristics: ["userId"],
+ refillRate: 20,
+ interval: "1m",
+ capacity: 30,
+ }),
+ shield({ mode: "LIVE" }),
+ ],
+});
diff --git a/apps/web/src/lib/chat-input.ts b/apps/web/src/lib/chat-input.ts
new file mode 100644
index 0000000..d3c7dec
--- /dev/null
+++ b/apps/web/src/lib/chat-input.ts
@@ -0,0 +1,7 @@
+/** Keep in sync with FastAPI chat route validation. */
+export const CHAT_INPUT_MAX_LENGTH = 16000;
+
+/** Show the character counter once the input crosses this fraction of the cap. */
+export const CHAT_INPUT_COUNTER_THRESHOLD = Math.floor(
+ CHAT_INPUT_MAX_LENGTH * 0.8,
+);
diff --git a/apps/web/src/lib/chat-ratelimit.ts b/apps/web/src/lib/chat-ratelimit.ts
new file mode 100644
index 0000000..f287a6b
--- /dev/null
+++ b/apps/web/src/lib/chat-ratelimit.ts
@@ -0,0 +1,63 @@
+import { ArcjetHeaders } from "@arcjet/headers";
+import { createServerFn } from "@tanstack/react-start";
+import {
+ getRequestHeaders,
+ getRequestHost,
+ getRequestProtocol,
+ getWebRequest,
+} from "vinxi/http";
+import { aj } from "./arcjet";
+
+export interface RateLimitResult {
+ allowed: boolean;
+ retryAfter?: number;
+}
+
+/**
+ * Pre-flight rate limit check via Arcjet.
+ * Called before the frontend sends a chat stream request to FastAPI.
+ * Checks per-user request rate (not token budget — that's in Convex/FastAPI).
+ */
+export const checkChatRateLimit = createServerFn({ method: "POST" })
+ .inputValidator((data: { userId: string }) => data)
+ .handler(async ({ data }): Promise => {
+ const headers = getRequestHeaders();
+ const host = getRequestHost();
+ const protocol = getRequestProtocol();
+ const request = getWebRequest();
+ const url = new URL(request.url);
+
+ const ip =
+ (headers["x-forwarded-for"] as string)?.split(",")[0]?.trim() ||
+ (headers["cf-connecting-ip"] as string) ||
+ "127.0.0.1";
+
+ const decision = await aj.protect(
+ { getBody: async () => "" },
+ {
+ cookies: headers.cookie ?? "",
+ host,
+ headers: new ArcjetHeaders(headers),
+ ip,
+ method: "POST",
+ path: url.pathname,
+ protocol: `${protocol}:`,
+ query: url.search,
+ userId: data.userId,
+ requested: 1,
+ },
+ );
+
+ if (decision.isDenied()) {
+ let retryAfter: number | undefined;
+ for (const result of decision.results) {
+ if (result.reason.isRateLimit() && "reset" in result.reason) {
+ retryAfter = result.reason.reset as number;
+ break;
+ }
+ }
+ return { allowed: false, retryAfter };
+ }
+
+ return { allowed: true };
+ });
diff --git a/apps/web/src/lib/chat-stream-context.tsx b/apps/web/src/lib/chat-stream-context.tsx
new file mode 100644
index 0000000..47c8442
--- /dev/null
+++ b/apps/web/src/lib/chat-stream-context.tsx
@@ -0,0 +1,303 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import {
+ type BudgetExceededInfo,
+ type ChatStreamRequest,
+ type ConvoStreamState,
+ type StreamPart,
+ type ToolCallEvent,
+ type UsageData,
+ useChatStream,
+} from "./use-chat-stream";
+
+export const EMPTY_STREAM_STATE: ConvoStreamState = {
+ content: null,
+ reasoning: null,
+ toolCalls: [],
+ parts: [],
+ pendingDoneContent: null,
+ usage: null,
+ model: null,
+};
+
+interface ChatStreamSideEffects {
+ onDone?: (
+ conversationId: string,
+ fullContent: string,
+ usage: UsageData | undefined,
+ model: string | undefined,
+ ) => void;
+ onAbort?: (conversationId: string) => void;
+ onError?: (conversationId: string, message: string) => void;
+ onMcpError?: (
+ conversationId: string,
+ event: { server_name: string; server_url: string; reason: string },
+ ) => void;
+ onBudgetExceeded?: (conversationId: string, info: BudgetExceededInfo) => void;
+ onSandboxStatus?: (
+ conversationId: string,
+ event: { sandbox_id: string; status: string },
+ ) => void;
+}
+
+interface ChatStreamContextValue {
+ streamStates: Record;
+ streamStatesRef: React.MutableRefObject>;
+ streamingConvoIds: Set;
+ stream: (body: ChatStreamRequest) => Promise;
+ cancel: (conversationId: string) => void;
+ clearStreamState: (conversationId: string) => void;
+ setStreamState: (
+ conversationId: string,
+ updater: (state: ConvoStreamState) => ConvoStreamState,
+ ) => void;
+}
+
+const ChatStreamContext = createContext(null);
+
+type SideEffectsRegistry = Map<
+ string,
+ React.MutableRefObject
+>;
+
+const SideEffectsRegistryContext =
+ createContext | null>(null);
+
+export function ChatStreamProvider({ children }: { children: ReactNode }) {
+ const [streamStates, setStreamStates] = useState<
+ Record
+ >({});
+ const streamStatesRef = useRef(streamStates);
+ streamStatesRef.current = streamStates;
+
+ const sideEffectsRegistryRef = useRef(new Map());
+
+ const dispatchSideEffect = useCallback(
+ (
+ key: K,
+ invoke: (handler: NonNullable) => void,
+ ) => {
+ for (const ref of sideEffectsRegistryRef.current.values()) {
+ const handler = ref.current[key];
+ if (handler) invoke(handler as NonNullable);
+ }
+ },
+ [],
+ );
+
+ const chatStream = useChatStream({
+ onToken: (convoId, content) => {
+ setStreamStates((prev) => {
+ const state = prev[convoId] ?? EMPTY_STREAM_STATE;
+ const parts = [...state.parts];
+ const last = parts[parts.length - 1];
+ if (last?.type === "text") {
+ parts[parts.length - 1] = {
+ ...last,
+ content: (last.content ?? "") + content,
+ };
+ } else {
+ parts.push({ type: "text", content } as StreamPart);
+ }
+ return {
+ ...prev,
+ [convoId]: {
+ ...state,
+ content: (state.content ?? "") + content,
+ parts,
+ },
+ };
+ });
+ },
+ onThinking: (convoId, content) => {
+ setStreamStates((prev) => {
+ const state = prev[convoId] ?? EMPTY_STREAM_STATE;
+ const parts = [...state.parts];
+ const last = parts[parts.length - 1];
+ if (last?.type === "reasoning") {
+ parts[parts.length - 1] = {
+ ...last,
+ content: (last.content ?? "") + content,
+ };
+ } else {
+ parts.push({ type: "reasoning", content } as StreamPart);
+ }
+ return {
+ ...prev,
+ [convoId]: {
+ ...state,
+ reasoning: (state.reasoning ?? "") + content,
+ parts,
+ },
+ };
+ });
+ },
+ onToolCall: (convoId, event: ToolCallEvent) => {
+ setStreamStates((prev) => {
+ const state = prev[convoId] ?? EMPTY_STREAM_STATE;
+ return {
+ ...prev,
+ [convoId]: {
+ ...state,
+ toolCalls: [...state.toolCalls, event],
+ parts: [
+ ...state.parts,
+ {
+ type: "tool_call" as const,
+ tool: event.tool,
+ arguments: event.arguments,
+ call_id: event.call_id,
+ },
+ ],
+ },
+ };
+ });
+ },
+ onToolResult: (convoId, event) => {
+ setStreamStates((prev) => {
+ const state = prev[convoId] ?? EMPTY_STREAM_STATE;
+ return {
+ ...prev,
+ [convoId]: {
+ ...state,
+ toolCalls: state.toolCalls.map((tc) =>
+ tc.call_id === event.call_id
+ ? { ...tc, result: event.result }
+ : tc,
+ ),
+ parts: state.parts.map((p) =>
+ p.type === "tool_call" && p.call_id === event.call_id
+ ? { ...p, result: event.result }
+ : p,
+ ),
+ },
+ };
+ });
+ },
+ onMcpError: (convoId, event) => {
+ dispatchSideEffect("onMcpError", (h) => h(convoId, event));
+ },
+ onSandboxStatus: (convoId, event) => {
+ dispatchSideEffect("onSandboxStatus", (h) => h(convoId, event));
+ },
+ onDone: (convoId, fullContent, usage, model) => {
+ setStreamStates((prev) => ({
+ ...prev,
+ [convoId]: {
+ content: prev[convoId]?.content ?? fullContent,
+ reasoning: prev[convoId]?.reasoning ?? null,
+ toolCalls: prev[convoId]?.toolCalls ?? [],
+ parts: prev[convoId]?.parts ?? [],
+ pendingDoneContent: fullContent,
+ usage: usage ?? prev[convoId]?.usage ?? null,
+ model: model ?? prev[convoId]?.model ?? null,
+ },
+ }));
+ dispatchSideEffect("onDone", (h) =>
+ h(convoId, fullContent, usage, model),
+ );
+ },
+ onBudgetExceeded: (convoId, info) => {
+ dispatchSideEffect("onBudgetExceeded", (h) => h(convoId, info));
+ },
+ onError: (convoId, message) => {
+ setStreamStates((prev) => {
+ const next = { ...prev };
+ delete next[convoId];
+ return next;
+ });
+ dispatchSideEffect("onError", (h) => h(convoId, message));
+ },
+ onAbort: (convoId) => {
+ dispatchSideEffect("onAbort", (h) => h(convoId));
+ },
+ });
+
+ const clearStreamState = useCallback((convoId: string) => {
+ setStreamStates((prev) => {
+ const next = { ...prev };
+ delete next[convoId];
+ return next;
+ });
+ }, []);
+
+ const setStreamState = useCallback(
+ (
+ convoId: string,
+ updater: (state: ConvoStreamState) => ConvoStreamState,
+ ) => {
+ setStreamStates((prev) => ({
+ ...prev,
+ [convoId]: updater(prev[convoId] ?? EMPTY_STREAM_STATE),
+ }));
+ },
+ [],
+ );
+
+ const value = useMemo(
+ () => ({
+ streamStates,
+ streamStatesRef,
+ streamingConvoIds: chatStream.streamingConvoIds,
+ stream: chatStream.stream,
+ cancel: chatStream.cancel,
+ clearStreamState,
+ setStreamState,
+ }),
+ [
+ streamStates,
+ chatStream.streamingConvoIds,
+ chatStream.stream,
+ chatStream.cancel,
+ clearStreamState,
+ setStreamState,
+ ],
+ );
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+export function useChatStreamContext() {
+ const ctx = useContext(ChatStreamContext);
+ if (!ctx) {
+ throw new Error(
+ "useChatStreamContext must be used within ChatStreamProvider",
+ );
+ }
+ return ctx;
+}
+
+export function useChatStreamSideEffects(effects: ChatStreamSideEffects) {
+ const registry = useContext(SideEffectsRegistryContext);
+ if (!registry) {
+ throw new Error(
+ "useChatStreamSideEffects must be used within ChatStreamProvider",
+ );
+ }
+ const id = useId();
+ const effectsRef = useRef(effects);
+ effectsRef.current = effects;
+
+ useEffect(() => {
+ const map = registry.current;
+ map.set(id, effectsRef);
+ return () => {
+ map.delete(id);
+ };
+ }, [registry, id]);
+}
diff --git a/apps/web/src/lib/command-palette/context.test.tsx b/apps/web/src/lib/command-palette/context.test.tsx
new file mode 100644
index 0000000..d21956d
--- /dev/null
+++ b/apps/web/src/lib/command-palette/context.test.tsx
@@ -0,0 +1,110 @@
+import { act, render, renderHook } from "@testing-library/react";
+import type { ReactNode } from "react";
+import { describe, expect, it, vi } from "vitest";
+import { CommandPaletteProvider, useCommandPalette } from "./context";
+import {
+ COMMAND_GROUP_LABELS,
+ COMMAND_GROUP_ORDER,
+ type Command,
+} from "./types";
+
+const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+);
+
+function cmd(partial: Partial & { id: string }): Command {
+ return {
+ id: partial.id,
+ title: partial.title ?? partial.id,
+ group: partial.group ?? "chat",
+ perform: partial.perform ?? vi.fn(),
+ ...partial,
+ };
+}
+
+describe("CommandPaletteProvider", () => {
+ it("throws if useCommandPalette is called outside provider", () => {
+ const spy = vi.spyOn(console, "error").mockImplementation(() => {});
+ expect(() => renderHook(() => useCommandPalette())).toThrow(
+ /must be used within a CommandPaletteProvider/,
+ );
+ spy.mockRestore();
+ });
+
+ it("starts closed", () => {
+ const { result } = renderHook(() => useCommandPalette(), { wrapper });
+ expect(result.current.open).toBe(false);
+ });
+
+ it("setOpen flips state", () => {
+ const { result } = renderHook(() => useCommandPalette(), { wrapper });
+ act(() => result.current.setOpen(true));
+ expect(result.current.open).toBe(true);
+ act(() => result.current.setOpen(false));
+ expect(result.current.open).toBe(false);
+ });
+
+ it("toggle flips state", () => {
+ const { result } = renderHook(() => useCommandPalette(), { wrapper });
+ act(() => result.current.toggle());
+ expect(result.current.open).toBe(true);
+ act(() => result.current.toggle());
+ expect(result.current.open).toBe(false);
+ });
+
+ it("register stores commands, snapshot returns them", () => {
+ const { result } = renderHook(() => useCommandPalette(), { wrapper });
+ const a = cmd({ id: "a" });
+ const b = cmd({ id: "b", group: "harness" });
+ act(() => result.current.register([a, b]));
+ const snap = result.current.snapshot();
+ expect(snap.map((c) => c.id).sort()).toEqual(["a", "b"]);
+ });
+
+ it("register overwrites by id (latest wins)", () => {
+ const { result } = renderHook(() => useCommandPalette(), { wrapper });
+ act(() => result.current.register([cmd({ id: "a", title: "first" })]));
+ act(() => result.current.register([cmd({ id: "a", title: "second" })]));
+ const snap = result.current.snapshot();
+ expect(snap).toHaveLength(1);
+ expect(snap[0].title).toBe("second");
+ });
+
+ it("unregister removes commands", () => {
+ const { result } = renderHook(() => useCommandPalette(), { wrapper });
+ act(() => result.current.register([cmd({ id: "a" }), cmd({ id: "b" })]));
+ act(() => result.current.unregister(["a"]));
+ const snap = result.current.snapshot();
+ expect(snap.map((c) => c.id)).toEqual(["b"]);
+ });
+
+ it("unregister on missing id is a no-op", () => {
+ const { result } = renderHook(() => useCommandPalette(), { wrapper });
+ act(() => result.current.register([cmd({ id: "a" })]));
+ act(() => result.current.unregister(["missing"]));
+ expect(result.current.snapshot()).toHaveLength(1);
+ });
+});
+
+describe("command group constants", () => {
+ it("order and labels align", () => {
+ for (const group of COMMAND_GROUP_ORDER) {
+ expect(COMMAND_GROUP_LABELS[group]).toBeTruthy();
+ }
+ });
+
+ it("order has no duplicates", () => {
+ expect(new Set(COMMAND_GROUP_ORDER).size).toBe(COMMAND_GROUP_ORDER.length);
+ });
+});
+
+describe("CommandPaletteProvider rendering", () => {
+ it("renders children", () => {
+ const { getByText } = render(
+
+ child-marker
+ ,
+ );
+ expect(getByText("child-marker")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/lib/command-palette/context.tsx b/apps/web/src/lib/command-palette/context.tsx
new file mode 100644
index 0000000..8b70fd3
--- /dev/null
+++ b/apps/web/src/lib/command-palette/context.tsx
@@ -0,0 +1,74 @@
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import type { Command } from "./types";
+
+interface CommandPaletteContextValue {
+ open: boolean;
+ setOpen: (value: boolean) => void;
+ toggle: () => void;
+ register: (commands: Command[]) => void;
+ unregister: (ids: string[]) => void;
+ /** Snapshot of all currently-registered commands. Stable while palette is closed. */
+ snapshot: () => Command[];
+}
+
+const CommandPaletteContext = createContext(
+ null,
+);
+
+export function CommandPaletteProvider({ children }: { children: ReactNode }) {
+ const commandsRef = useRef>(new Map());
+ const openRef = useRef(false);
+ const [open, setOpenState] = useState(false);
+
+ const setOpen = useCallback((value: boolean) => {
+ openRef.current = value;
+ setOpenState(value);
+ }, []);
+
+ const toggle = useCallback(() => {
+ setOpen(!openRef.current);
+ }, [setOpen]);
+
+ const register = useCallback((commands: Command[]) => {
+ for (const command of commands) {
+ commandsRef.current.set(command.id, command);
+ }
+ }, []);
+
+ const unregister = useCallback((ids: string[]) => {
+ for (const id of ids) commandsRef.current.delete(id);
+ }, []);
+
+ const snapshot = useCallback(() => {
+ return Array.from(commandsRef.current.values());
+ }, []);
+
+ const value = useMemo(
+ () => ({ open, setOpen, toggle, register, unregister, snapshot }),
+ [open, setOpen, toggle, register, unregister, snapshot],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useCommandPalette(): CommandPaletteContextValue {
+ const ctx = useContext(CommandPaletteContext);
+ if (!ctx) {
+ throw new Error(
+ "useCommandPalette must be used within a CommandPaletteProvider",
+ );
+ }
+ return ctx;
+}
diff --git a/apps/web/src/lib/command-palette/recent.test.ts b/apps/web/src/lib/command-palette/recent.test.ts
new file mode 100644
index 0000000..385f5af
--- /dev/null
+++ b/apps/web/src/lib/command-palette/recent.test.ts
@@ -0,0 +1,61 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { getRecentCommandIds, pushRecentCommand } from "./recent";
+
+beforeEach(() => {
+ window.localStorage.clear();
+});
+
+afterEach(() => {
+ window.localStorage.clear();
+});
+
+describe("getRecentCommandIds", () => {
+ it("returns empty array when nothing stored", () => {
+ expect(getRecentCommandIds()).toEqual([]);
+ });
+
+ it("returns stored ids", () => {
+ window.localStorage.setItem("cmdk:recent", JSON.stringify(["a", "b"]));
+ expect(getRecentCommandIds()).toEqual(["a", "b"]);
+ });
+
+ it("filters non-string entries", () => {
+ window.localStorage.setItem(
+ "cmdk:recent",
+ JSON.stringify(["a", 42, null, "b"]),
+ );
+ expect(getRecentCommandIds()).toEqual(["a", "b"]);
+ });
+
+ it("returns empty array if stored value is not an array", () => {
+ window.localStorage.setItem("cmdk:recent", JSON.stringify({ foo: "bar" }));
+ expect(getRecentCommandIds()).toEqual([]);
+ });
+
+ it("returns empty array if stored value is invalid JSON", () => {
+ window.localStorage.setItem("cmdk:recent", "{not json}");
+ expect(getRecentCommandIds()).toEqual([]);
+ });
+});
+
+describe("pushRecentCommand", () => {
+ it("stores the id as the head of the list", () => {
+ pushRecentCommand("one");
+ expect(getRecentCommandIds()).toEqual(["one"]);
+ });
+
+ it("moves an existing id to the front (no duplicates)", () => {
+ pushRecentCommand("a");
+ pushRecentCommand("b");
+ pushRecentCommand("a");
+ expect(getRecentCommandIds()).toEqual(["a", "b"]);
+ });
+
+ it("caps the list at 20 entries", () => {
+ for (let i = 0; i < 25; i++) pushRecentCommand(`id-${i}`);
+ const ids = getRecentCommandIds();
+ expect(ids).toHaveLength(20);
+ expect(ids[0]).toBe("id-24");
+ expect(ids[19]).toBe("id-5");
+ });
+});
diff --git a/apps/web/src/lib/command-palette/recent.ts b/apps/web/src/lib/command-palette/recent.ts
new file mode 100644
index 0000000..d03a3ca
--- /dev/null
+++ b/apps/web/src/lib/command-palette/recent.ts
@@ -0,0 +1,37 @@
+const STORAGE_KEY = "cmdk:recent";
+const MAX_RECENT = 20;
+
+function safeStorage(): Storage | null {
+ if (typeof window === "undefined") return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+export function getRecentCommandIds(): string[] {
+ const storage = safeStorage();
+ if (!storage) return [];
+ try {
+ const raw = storage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter((x): x is string => typeof x === "string");
+ } catch {
+ return [];
+ }
+}
+
+export function pushRecentCommand(id: string): void {
+ const storage = safeStorage();
+ if (!storage) return;
+ const current = getRecentCommandIds().filter((x) => x !== id);
+ const next = [id, ...current].slice(0, MAX_RECENT);
+ try {
+ storage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch {
+ // Quota exceeded or storage disabled — silent failure is fine.
+ }
+}
diff --git a/apps/web/src/lib/command-palette/types.ts b/apps/web/src/lib/command-palette/types.ts
new file mode 100644
index 0000000..25b992a
--- /dev/null
+++ b/apps/web/src/lib/command-palette/types.ts
@@ -0,0 +1,56 @@
+import type { LucideIcon } from "lucide-react";
+import type { ComponentType } from "react";
+
+export type CommandGroupId =
+ | "recent"
+ | "navigation"
+ | "workspace"
+ | "chat"
+ | "harness"
+ | "sandbox"
+ | "account";
+
+export const COMMAND_GROUP_LABELS: Record = {
+ recent: "Recently used",
+ navigation: "Navigate",
+ workspace: "Workspaces",
+ chat: "Chat",
+ harness: "Harnesses",
+ sandbox: "Sandboxes",
+ account: "Account",
+};
+
+export const COMMAND_GROUP_ORDER: CommandGroupId[] = [
+ "recent",
+ "navigation",
+ "workspace",
+ "chat",
+ "harness",
+ "sandbox",
+ "account",
+];
+
+export type CommandIcon = LucideIcon | ComponentType<{ className?: string }>;
+
+export interface Command {
+ /** Stable ID used for dedup and recent-commands tracking. */
+ id: string;
+ /** Primary label — the line users read and match against. */
+ title: string;
+ /** Secondary text rendered right-aligned in muted tone. */
+ subtitle?: string;
+ /** Which group this command renders under. */
+ group: CommandGroupId;
+ /** Extra terms that should match but aren't shown. */
+ keywords?: string[];
+ /** Leading icon. */
+ icon?: CommandIcon;
+ /** Hex color rendered as a 8px leading dot (e.g. workspace color). */
+ colorDot?: string;
+ /** Right-aligned keyboard hint like `⌘⌥1`. */
+ shortcut?: string;
+ /** Handler invoked when the command is activated. Return a promise to show loading. */
+ perform: () => void | Promise;
+ /** If false, the command is hidden. Evaluated at palette-open time. */
+ when?: () => boolean;
+}
diff --git a/apps/web/src/lib/mcp.test.ts b/apps/web/src/lib/mcp.test.ts
new file mode 100644
index 0000000..0931d61
--- /dev/null
+++ b/apps/web/src/lib/mcp.test.ts
@@ -0,0 +1,257 @@
+import type { UserResource } from "@clerk/types";
+import { describe, expect, it, vi } from "vitest";
+import {
+ fetchCommandsFromApi,
+ getPrincetonNetid,
+ hasTigerJunctionServers,
+ type McpServerEntry,
+ PRESET_MCPS,
+ presetIdsToServerEntries,
+ sanitizeServerName,
+ toMcpServerPayload,
+} from "./mcp";
+
+describe("PRESET_MCPS", () => {
+ it("exposes a non-empty preset catalog", () => {
+ expect(PRESET_MCPS.length).toBeGreaterThan(0);
+ });
+
+ it("ids are unique", () => {
+ const ids = PRESET_MCPS.map((p) => p.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("each preset has a server URL and auth type", () => {
+ for (const p of PRESET_MCPS) {
+ expect(p.server.url).toMatch(/^https?:\/\//);
+ expect(["none", "bearer", "oauth", "tiger_junction"]).toContain(
+ p.server.authType,
+ );
+ }
+ });
+});
+
+describe("sanitizeServerName", () => {
+ it("keeps alphanumerics, underscores, hyphens", () => {
+ expect(sanitizeServerName("github-copilot_v2")).toBe("github-copilot_v2");
+ });
+
+ it("replaces spaces and punctuation with underscores", () => {
+ expect(sanitizeServerName("Princeton Courses!")).toBe("Princeton_Courses_");
+ });
+
+ it("handles empty string", () => {
+ expect(sanitizeServerName("")).toBe("");
+ });
+});
+
+describe("toMcpServerPayload", () => {
+ it("snake-cases authType and omits missing tokens", () => {
+ const servers: McpServerEntry[] = [
+ { name: "A", url: "https://a", authType: "none" },
+ { name: "B", url: "https://b", authType: "bearer", authToken: "tok" },
+ ];
+ expect(toMcpServerPayload(servers)).toEqual([
+ { name: "A", url: "https://a", auth_type: "none" },
+ { name: "B", url: "https://b", auth_type: "bearer", auth_token: "tok" },
+ ]);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(toMcpServerPayload([])).toEqual([]);
+ });
+});
+
+describe("presetIdsToServerEntries", () => {
+ it("resolves known preset ids", () => {
+ const entries = presetIdsToServerEntries(["github", "notion"]);
+ expect(entries).toHaveLength(2);
+ expect(entries[0].name).toBe("GitHub");
+ expect(entries[1].name).toBe("Notion");
+ });
+
+ it("skips unknown ids silently", () => {
+ const entries = presetIdsToServerEntries(["github", "nonexistent-preset"]);
+ expect(entries).toHaveLength(1);
+ expect(entries[0].name).toBe("GitHub");
+ });
+
+ it("handles empty input", () => {
+ expect(presetIdsToServerEntries([])).toEqual([]);
+ });
+});
+
+describe("hasTigerJunctionServers", () => {
+ it("true when any server has tiger_junction auth", () => {
+ expect(
+ hasTigerJunctionServers([
+ { name: "x", url: "https://x", authType: "none" },
+ { name: "pc", url: "https://pc", authType: "tiger_junction" },
+ ]),
+ ).toBe(true);
+ });
+
+ it("false otherwise", () => {
+ expect(
+ hasTigerJunctionServers([
+ { name: "x", url: "https://x", authType: "none" },
+ { name: "y", url: "https://y", authType: "oauth" },
+ ]),
+ ).toBe(false);
+ });
+
+ it("false for empty list", () => {
+ expect(hasTigerJunctionServers([])).toBe(false);
+ });
+});
+
+describe("getPrincetonNetid", () => {
+ it("returns null for null/undefined user", () => {
+ expect(getPrincetonNetid(null)).toBeNull();
+ expect(getPrincetonNetid(undefined)).toBeNull();
+ });
+
+ it("extracts netid from primary email", () => {
+ const user = {
+ primaryEmailAddress: { emailAddress: "abc123@princeton.edu" },
+ emailAddresses: [],
+ externalAccounts: [],
+ } as unknown as UserResource;
+ expect(getPrincetonNetid(user)).toBe("abc123");
+ });
+
+ it("falls through to verified email addresses", () => {
+ const user = {
+ primaryEmailAddress: { emailAddress: "me@gmail.com" },
+ emailAddresses: [
+ {
+ emailAddress: "me@gmail.com",
+ verification: { status: "verified" },
+ },
+ {
+ emailAddress: "xyz789@princeton.edu",
+ verification: { status: "verified" },
+ },
+ ],
+ externalAccounts: [],
+ } as unknown as UserResource;
+ expect(getPrincetonNetid(user)).toBe("xyz789");
+ });
+
+ it("ignores unverified princeton emails", () => {
+ const user = {
+ primaryEmailAddress: { emailAddress: "me@gmail.com" },
+ emailAddresses: [
+ {
+ emailAddress: "xyz789@princeton.edu",
+ verification: { status: "unverified" },
+ },
+ ],
+ externalAccounts: [],
+ } as unknown as UserResource;
+ expect(getPrincetonNetid(user)).toBeNull();
+ });
+
+ it("checks external accounts as final fallback", () => {
+ const user = {
+ primaryEmailAddress: { emailAddress: "me@gmail.com" },
+ emailAddresses: [],
+ externalAccounts: [
+ {
+ emailAddress: "netid9@princeton.edu",
+ verification: { status: "verified" },
+ },
+ ],
+ } as unknown as UserResource;
+ expect(getPrincetonNetid(user)).toBe("netid9");
+ });
+
+ it("returns null when nothing matches", () => {
+ const user = {
+ primaryEmailAddress: { emailAddress: "me@gmail.com" },
+ emailAddresses: [],
+ externalAccounts: [],
+ } as unknown as UserResource;
+ expect(getPrincetonNetid(user)).toBeNull();
+ });
+});
+
+describe("fetchCommandsFromApi", () => {
+ it("returns commands array on 200", async () => {
+ const servers: McpServerEntry[] = [
+ { name: "x", url: "https://x", authType: "none" },
+ ];
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ commands: [
+ {
+ name: "foo",
+ server: "x",
+ tool: "bar",
+ description: "d",
+ parameters: { foo: "bar" },
+ },
+ ],
+ }),
+ { status: 200 },
+ ),
+ );
+ const result = await fetchCommandsFromApi(
+ "https://api.example",
+ servers,
+ "tok",
+ );
+ expect(result).toHaveLength(1);
+ expect(result?.[0].name).toBe("foo");
+ expect(fetchSpy).toHaveBeenCalledWith(
+ "https://api.example/api/commands/list",
+ expect.objectContaining({
+ method: "POST",
+ headers: expect.objectContaining({
+ Authorization: "Bearer tok",
+ }),
+ }),
+ );
+ fetchSpy.mockRestore();
+ });
+
+ it("omits Authorization header when no token", async () => {
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(
+ new Response(JSON.stringify({ commands: [] }), { status: 200 }),
+ );
+ await fetchCommandsFromApi("https://api.example", [], null);
+ const headers = (fetchSpy.mock.calls[0][1]?.headers ?? {}) as Record<
+ string,
+ string
+ >;
+ expect(headers.Authorization).toBeUndefined();
+ fetchSpy.mockRestore();
+ });
+
+ it("returns null on non-ok response", async () => {
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(new Response("nope", { status: 500 }));
+ expect(await fetchCommandsFromApi("https://api", [], null)).toBeNull();
+ fetchSpy.mockRestore();
+ });
+
+ it("returns null on thrown error", async () => {
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockRejectedValue(new Error("net"));
+ expect(await fetchCommandsFromApi("https://api", [], null)).toBeNull();
+ fetchSpy.mockRestore();
+ });
+
+ it("returns empty array when response has no commands key", async () => {
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
+ expect(await fetchCommandsFromApi("https://api", [], null)).toEqual([]);
+ fetchSpy.mockRestore();
+ });
+});
diff --git a/apps/web/src/lib/mcp.ts b/apps/web/src/lib/mcp.ts
index bf94a98..96ae459 100644
--- a/apps/web/src/lib/mcp.ts
+++ b/apps/web/src/lib/mcp.ts
@@ -2,11 +2,20 @@ import type { UserResource } from "@clerk/types";
export type McpAuthType = "none" | "bearer" | "oauth" | "tiger_junction";
+export interface McpServerCommand {
+ name: string;
+ server: string;
+ tool: string;
+ description: string;
+ parameters: Record;
+}
+
export interface McpServerEntry {
name: string;
url: string;
authType: McpAuthType;
authToken?: string;
+ commandIds?: string[];
}
export interface PresetMcpDefinition {
@@ -17,6 +26,7 @@ export interface PresetMcpDefinition {
server: McpServerEntry;
}
+// When adding/removing MCPs, also update _PRESET_MCP_CATALOG in packages/fastapi/app/routes/harness_suggest.py.
export const PRESET_MCPS: PresetMcpDefinition[] = [
{
id: "princetoncourses",
@@ -54,6 +64,18 @@ export const PRESET_MCPS: PresetMcpDefinition[] = [
authType: "tiger_junction",
},
},
+ {
+ id: "tigerpath",
+ description:
+ "Plan your 4-year course schedule, explore major requirements, and see when students typically take courses.",
+ iconName: "https://www.google.com/s2/favicons?domain=princeton.edu&sz=64",
+ category: "student",
+ server: {
+ name: "TigerPath",
+ url: "https://junction-engine.tigerapps.org/path/mcp",
+ authType: "tiger_junction",
+ },
+ },
{
id: "github",
description:
@@ -90,18 +112,6 @@ export const PRESET_MCPS: PresetMcpDefinition[] = [
authType: "oauth",
},
},
- {
- id: "slack",
- description:
- "(wait until deployed)Send messages, read channel history, and search conversations.",
- iconName: "slack",
- category: "comms",
- server: {
- name: "Slack",
- url: "https://mcp.slack.com/mcp",
- authType: "oauth",
- },
- },
// Not supported for none VIPs yet
// {
// id: "figma",
@@ -138,17 +148,6 @@ export const PRESET_MCPS: PresetMcpDefinition[] = [
// authType: "oauth",
// },
// },
- {
- id: "jira",
- description: "Create tickets, track sprints, and manage Agile releases.",
- iconName: "jira",
- category: "productivity",
- server: {
- name: "Jira",
- url: "https://mcp.atlassian.com/v1/mcp",
- authType: "oauth",
- },
- },
{
id: "awsknowledge",
description:
@@ -186,6 +185,66 @@ export const PRESET_MCPS: PresetMcpDefinition[] = [
},
];
+/** Build the API payload shape for MCP servers. */
+export function toMcpServerPayload(servers: McpServerEntry[]) {
+ return servers.map((s) => ({
+ name: s.name,
+ url: s.url,
+ auth_type: s.authType,
+ ...(s.authToken ? { auth_token: s.authToken } : {}),
+ }));
+}
+
+/**
+ * Fetch slash commands from the FastAPI backend.
+ * Returns the raw command list with $-prefixed keys stripped from parameters,
+ * or null if the fetch fails.
+ */
+export async function fetchCommandsFromApi(
+ apiUrl: string,
+ servers: McpServerEntry[],
+ token: string | null,
+): Promise {
+ try {
+ const res = await fetch(`${apiUrl}/api/commands/list`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({ mcp_servers: toMcpServerPayload(servers) }),
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ return (data.commands ?? []).map((c: McpServerCommand) => ({
+ name: c.name,
+ server: c.server,
+ tool: c.tool,
+ description: c.description,
+ parameters: c.parameters,
+ }));
+ } catch {
+ return null;
+ }
+}
+
+/** Sanitize a name the same way the backend does (non-alphanumeric → underscore). */
+export const sanitizeServerName = (n: string) =>
+ n.replace(/[^a-zA-Z0-9_-]/g, "_");
+
+/** Validate an MCP server URL. Returns an error string or null if valid. */
+export function validateMcpUrl(url: string): string | null {
+ if (/\s/.test(url)) return "URL must not contain spaces";
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
+ return "URL must start with http:// or https://";
+ } catch {
+ return "Please enter a valid URL";
+ }
+ return null;
+}
+
/** Converts an array of selected preset IDs into their McpServerEntry objects. */
export function presetIdsToServerEntries(ids: string[]): McpServerEntry[] {
return ids.flatMap((id) => {
diff --git a/apps/web/src/lib/models.test.ts b/apps/web/src/lib/models.test.ts
new file mode 100644
index 0000000..f8244ab
--- /dev/null
+++ b/apps/web/src/lib/models.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vitest";
+import {
+ acceptString,
+ allowedMimeTypes,
+ MODELS,
+ mimeToAudioFormat,
+ modelSupportsAudio,
+ modelSupportsMedia,
+} from "./models";
+
+describe("MODELS catalog", () => {
+ it("has unique values", () => {
+ const values = MODELS.map((m) => m.value);
+ expect(new Set(values).size).toBe(values.length);
+ });
+
+ it("every model has label and modalities array", () => {
+ for (const m of MODELS) {
+ expect(m.value).toBeTruthy();
+ expect(m.label).toBeTruthy();
+ expect(Array.isArray(m.modalities)).toBe(true);
+ }
+ });
+});
+
+describe("modelSupportsMedia", () => {
+ it("true for image-capable models", () => {
+ expect(modelSupportsMedia("gpt-5.4")).toBe(true);
+ expect(modelSupportsMedia("claude-opus-4.7")).toBe(true);
+ expect(modelSupportsMedia("gemini-3.1-pro")).toBe(true);
+ });
+
+ it("false for unknown models", () => {
+ expect(modelSupportsMedia("made-up-model-2")).toBe(false);
+ });
+
+ it("false for undefined / unknown models", () => {
+ expect(modelSupportsMedia(undefined)).toBe(false);
+ expect(modelSupportsMedia("made-up-model")).toBe(false);
+ });
+});
+
+describe("modelSupportsAudio", () => {
+ it("true for gemini audio-capable models", () => {
+ expect(modelSupportsAudio("gemini-3.1-pro")).toBe(true);
+ expect(modelSupportsAudio("gemini-3-flash")).toBe(true);
+ });
+
+ it("false for non-audio models", () => {
+ expect(modelSupportsAudio("gpt-5.4")).toBe(false);
+ expect(modelSupportsAudio("claude-sonnet-4.6")).toBe(false);
+ });
+
+ it("false for undefined", () => {
+ expect(modelSupportsAudio(undefined)).toBe(false);
+ });
+});
+
+describe("allowedMimeTypes", () => {
+ it("returns empty set for text-only model", () => {
+ expect(allowedMimeTypes("made-up-model").size).toBe(0);
+ });
+
+ it("returns images + pdf for gpt-5.4", () => {
+ const mimes = allowedMimeTypes("gpt-5.4");
+ expect(mimes.has("image/png")).toBe(true);
+ expect(mimes.has("image/jpeg")).toBe(true);
+ expect(mimes.has("application/pdf")).toBe(true);
+ expect(mimes.has("audio/wav")).toBe(false);
+ });
+
+ it("returns image + pdf + audio for gemini-3.1-pro", () => {
+ const mimes = allowedMimeTypes("gemini-3.1-pro");
+ expect(mimes.has("image/png")).toBe(true);
+ expect(mimes.has("application/pdf")).toBe(true);
+ expect(mimes.has("audio/wav")).toBe(true);
+ expect(mimes.has("audio/mpeg")).toBe(true);
+ });
+
+ it("returns empty set for undefined model", () => {
+ expect(allowedMimeTypes(undefined).size).toBe(0);
+ });
+});
+
+describe("acceptString", () => {
+ it("returns comma-separated mime list", () => {
+ const accept = acceptString("gpt-5.4");
+ expect(accept).toContain("image/png");
+ expect(accept).toContain("application/pdf");
+ expect(accept.split(",")).toHaveLength(5);
+ });
+
+ it("returns empty string when no modalities", () => {
+ expect(acceptString("made-up-model")).toBe("");
+ });
+});
+
+describe("mimeToAudioFormat", () => {
+ it("maps known audio mimes", () => {
+ expect(mimeToAudioFormat("audio/wav")).toBe("wav");
+ expect(mimeToAudioFormat("audio/mpeg")).toBe("mp3");
+ expect(mimeToAudioFormat("audio/mp3")).toBe("mp3");
+ expect(mimeToAudioFormat("audio/x-m4a")).toBe("m4a");
+ expect(mimeToAudioFormat("audio/webm")).toBe("webm");
+ });
+
+ it("falls back to wav for unknown mimes", () => {
+ expect(mimeToAudioFormat("audio/something-else")).toBe("wav");
+ expect(mimeToAudioFormat("")).toBe("wav");
+ });
+});
diff --git a/apps/web/src/lib/models.ts b/apps/web/src/lib/models.ts
index 0623cef..73295f2 100644
--- a/apps/web/src/lib/models.ts
+++ b/apps/web/src/lib/models.ts
@@ -7,55 +7,51 @@ export const MODELS: Array<{
label: string;
modalities: Modality[];
}> = [
- // Audio input: only Gemini models are confirmed on OpenRouter
- { value: "openai/gpt-5.4", label: "GPT-5.4", modalities: ["image", "pdf"] },
- { value: "gpt-4o", label: "GPT-4o", modalities: ["image", "pdf"] },
- { value: "gpt-4.1", label: "GPT-4.1", modalities: ["image", "pdf"] },
+ // OpenAI
+ { value: "gpt-5.5", label: "GPT-5.5", modalities: ["image", "pdf"] },
+ { value: "gpt-5.4", label: "GPT-5.4", modalities: ["image", "pdf"] },
+ // Anthropic
{
- value: "gpt-4.1-mini",
- label: "GPT-4.1 Mini",
+ value: "claude-sonnet-4.6",
+ label: "Claude Sonnet 4.6",
modalities: ["image", "pdf"],
},
{
- value: "claude-sonnet-4",
- label: "Claude Sonnet 4",
+ value: "claude-sonnet-4.6-thinking",
+ label: "Claude Sonnet 4.6 (Thinking)",
modalities: ["image", "pdf"],
},
{
- value: "claude-sonnet-4-thinking",
- label: "Claude Sonnet 4 (Thinking)",
+ value: "claude-opus-4.6-fast",
+ label: "Claude Opus 4.6 (Fast)",
modalities: ["image", "pdf"],
},
{
- value: "claude-opus-4",
- label: "Claude Opus 4",
+ value: "claude-opus-4.7",
+ label: "Claude Opus 4.7",
modalities: ["image", "pdf"],
},
{
- value: "claude-opus-4-thinking",
- label: "Claude Opus 4 (Thinking)",
+ value: "claude-opus-4.7-thinking",
+ label: "Claude Opus 4.7 (Thinking)",
modalities: ["image", "pdf"],
},
+ // Google — audio input confirmed on OpenRouter
{
- value: "google/gemini-3.1-flash-lite-preview",
- label: "Gemini 3.1 Flash Lite Preview",
+ value: "gemini-3.1-pro",
+ label: "Gemini 3.1 Pro Preview",
modalities: ["image", "pdf", "audio"],
},
{
- value: "gemini-2.5-pro",
- label: "Gemini 2.5 Pro",
+ value: "gemini-3-flash",
+ label: "Gemini 3 Flash Preview",
modalities: ["image", "pdf", "audio"],
},
{
- value: "gemini-2.5-flash",
- label: "Gemini 2.5 Flash",
+ value: "gemini-3.1-flash-lite",
+ label: "Gemini 3.1 Flash Lite Preview",
modalities: ["image", "pdf", "audio"],
},
- { value: "kimi-k2", label: "Kimi K2", modalities: ["image"] },
- { value: "deepseek-r1", label: "DeepSeek R1", modalities: [] },
- { value: "deepseek-v3", label: "DeepSeek V3", modalities: [] },
- { value: "grok-3", label: "Grok 3", modalities: ["image"] },
- { value: "grok-3-mini", label: "Grok 3 Mini", modalities: [] },
];
// Lookup index built once from the MODELS array
diff --git a/apps/web/src/lib/multimodal.test.ts b/apps/web/src/lib/multimodal.test.ts
new file mode 100644
index 0000000..a0529a9
--- /dev/null
+++ b/apps/web/src/lib/multimodal.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it, vi } from "vitest";
+import { buildMultimodalContent } from "./multimodal";
+
+function makeResolver(
+ signed: Array<{ url: string; mime_type: string; file_name: string }>,
+) {
+ return vi.fn().mockResolvedValue(signed);
+}
+
+describe("buildMultimodalContent", () => {
+ it("returns plain text when no attachments resolved", async () => {
+ const out = await buildMultimodalContent("hello", [], makeResolver([]));
+ expect(out).toBe("hello");
+ });
+
+ it("returns text-only parts when input text is empty but images exist", async () => {
+ const out = await buildMultimodalContent(
+ "",
+ [{ storageId: "a", mimeType: "image/png", fileName: "a.png" }],
+ makeResolver([
+ { url: "https://s/a", mime_type: "image/png", file_name: "a.png" },
+ ]),
+ );
+ expect(Array.isArray(out)).toBe(true);
+ expect(out).toEqual([
+ { type: "image_url", image_url: { url: "https://s/a" } },
+ ]);
+ });
+
+ it("builds text + image parts", async () => {
+ const out = await buildMultimodalContent(
+ "caption",
+ [{ storageId: "a", mimeType: "image/png", fileName: "a.png" }],
+ makeResolver([
+ { url: "https://s/a", mime_type: "image/png", file_name: "a.png" },
+ ]),
+ );
+ expect(out).toEqual([
+ { type: "text", text: "caption" },
+ { type: "image_url", image_url: { url: "https://s/a" } },
+ ]);
+ });
+
+ it("builds pdf file part", async () => {
+ const out = await buildMultimodalContent(
+ "",
+ [{ storageId: "p", mimeType: "application/pdf", fileName: "doc.pdf" }],
+ makeResolver([
+ {
+ url: "https://s/p",
+ mime_type: "application/pdf",
+ file_name: "doc.pdf",
+ },
+ ]),
+ );
+ expect(out).toEqual([
+ {
+ type: "file",
+ file: { filename: "doc.pdf", file_data: "https://s/p" },
+ },
+ ]);
+ });
+
+ it("encodes audio as base64", async () => {
+ // Base64 of "hi" is "aGk="; data URL prefix is "data:audio/wav;base64,"
+ const dataUrl = "data:audio/wav;base64,aGk=";
+ const mockReader = {
+ result: dataUrl,
+ onloadend: null as (() => void) | null,
+ onerror: null as (() => void) | null,
+ readAsDataURL() {
+ queueMicrotask(() => this.onloadend?.());
+ },
+ };
+ // @ts-expect-error jsdom stub
+ globalThis.FileReader = vi.fn(() => mockReader);
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(new Response("hi", { status: 200 }));
+
+ const out = await buildMultimodalContent(
+ "",
+ [{ storageId: "a", mimeType: "audio/wav", fileName: "clip.wav" }],
+ makeResolver([
+ { url: "https://s/a", mime_type: "audio/wav", file_name: "clip.wav" },
+ ]),
+ );
+ expect(out).toEqual([
+ {
+ type: "input_audio",
+ input_audio: { data: "aGk=", format: "wav" },
+ },
+ ]);
+ fetchSpy.mockRestore();
+ });
+
+ it("raises a descriptive error when audio fetch fails", async () => {
+ const fetchSpy = vi
+ .spyOn(globalThis, "fetch")
+ .mockResolvedValue(new Response("err", { status: 500 }));
+ await expect(
+ buildMultimodalContent(
+ "",
+ [{ storageId: "a", mimeType: "audio/wav", fileName: "boom.wav" }],
+ makeResolver([
+ { url: "https://s/a", mime_type: "audio/wav", file_name: "boom.wav" },
+ ]),
+ ),
+ ).rejects.toThrow(/Failed to encode audio "boom\.wav"/);
+ fetchSpy.mockRestore();
+ });
+});
diff --git a/apps/web/src/lib/platform.test.ts b/apps/web/src/lib/platform.test.ts
new file mode 100644
index 0000000..71031ff
--- /dev/null
+++ b/apps/web/src/lib/platform.test.ts
@@ -0,0 +1,120 @@
+import { renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ ariaKeyShortcut,
+ formatShortcut,
+ getIsMac,
+ useIsMac,
+} from "./platform";
+
+const origNavigator = globalThis.navigator;
+
+afterEach(() => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: origNavigator,
+ configurable: true,
+ });
+});
+
+describe("getIsMac", () => {
+ it("returns true when userAgentData.platform says mac", () => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: {
+ userAgentData: { platform: "macOS" },
+ platform: "",
+ userAgent: "",
+ },
+ configurable: true,
+ });
+ expect(getIsMac()).toBe(true);
+ });
+
+ it("returns false when userAgentData.platform is Windows", () => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: {
+ userAgentData: { platform: "Windows" },
+ platform: "",
+ userAgent: "",
+ },
+ configurable: true,
+ });
+ expect(getIsMac()).toBe(false);
+ });
+
+ it("falls back to navigator.platform", () => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: { platform: "MacIntel", userAgent: "" },
+ configurable: true,
+ });
+ expect(getIsMac()).toBe(true);
+ });
+
+ it("falls back to userAgent", () => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: { platform: "", userAgent: "Mozilla/5.0 (Macintosh)" },
+ configurable: true,
+ });
+ expect(getIsMac()).toBe(true);
+ });
+
+ it("returns false when navigator is undefined", () => {
+ const originalDescriptor = Object.getOwnPropertyDescriptor(
+ globalThis,
+ "navigator",
+ );
+ // @ts-expect-error — intentionally deleting for SSR simulation
+ delete globalThis.navigator;
+ expect(getIsMac()).toBe(false);
+ if (originalDescriptor) {
+ Object.defineProperty(globalThis, "navigator", originalDescriptor);
+ }
+ });
+});
+
+describe("formatShortcut", () => {
+ it("uses mac glyphs when isMac", () => {
+ expect(formatShortcut(1, true)).toBe("⌘⌥1");
+ });
+
+ it("uses Ctrl+Alt when not mac", () => {
+ expect(formatShortcut(3, false)).toBe("Ctrl+Alt+3");
+ });
+});
+
+describe("ariaKeyShortcut", () => {
+ it("returns Meta+Alt form on mac", () => {
+ expect(ariaKeyShortcut(1, true)).toBe("Meta+Alt+1");
+ });
+
+ it("returns Control+Alt form off mac", () => {
+ expect(ariaKeyShortcut(2, false)).toBe("Control+Alt+2");
+ });
+});
+
+describe("useIsMac", () => {
+ it("reports mac correctly after effect runs", () => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: {
+ userAgentData: { platform: "macOS" },
+ platform: "",
+ userAgent: "",
+ },
+ configurable: true,
+ });
+ const { result } = renderHook(() => useIsMac());
+ expect(result.current).toBe(true);
+ });
+
+ it("returns false on non-mac", () => {
+ Object.defineProperty(globalThis, "navigator", {
+ value: {
+ userAgentData: { platform: "Windows" },
+ platform: "",
+ userAgent: "",
+ },
+ configurable: true,
+ });
+ const { result } = renderHook(() => useIsMac());
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/apps/web/src/lib/platform.ts b/apps/web/src/lib/platform.ts
new file mode 100644
index 0000000..6b1edf8
--- /dev/null
+++ b/apps/web/src/lib/platform.ts
@@ -0,0 +1,29 @@
+import { useEffect, useState } from "react";
+
+export function getIsMac(): boolean {
+ if (typeof navigator === "undefined") return false;
+ const uaData = (
+ navigator as Navigator & {
+ userAgentData?: { platform?: string };
+ }
+ ).userAgentData;
+ if (uaData?.platform) return /mac/i.test(uaData.platform);
+ if (navigator.platform) return /mac/i.test(navigator.platform);
+ return /mac/i.test(navigator.userAgent);
+}
+
+export function useIsMac(): boolean {
+ const [isMac, setIsMac] = useState(false);
+ useEffect(() => {
+ setIsMac(getIsMac());
+ }, []);
+ return isMac;
+}
+
+export function formatShortcut(digit: number, isMac: boolean): string {
+ return isMac ? `⌘⌥${digit}` : `Ctrl+Alt+${digit}`;
+}
+
+export function ariaKeyShortcut(digit: number, isMac: boolean): string {
+ return isMac ? `Meta+Alt+${digit}` : `Control+Alt+${digit}`;
+}
diff --git a/apps/web/src/lib/sandbox-api.ts b/apps/web/src/lib/sandbox-api.ts
index 8fbbec3..84fb496 100644
--- a/apps/web/src/lib/sandbox-api.ts
+++ b/apps/web/src/lib/sandbox-api.ts
@@ -55,6 +55,28 @@ export interface GitCommit {
date: string;
}
+export interface SandboxLifecycleResponse {
+ success: boolean;
+ status: string;
+}
+
+export interface CreateSandboxRequest {
+ harnessId?: string;
+ name: string;
+ language: string;
+ resourceTier: "basic" | "standard" | "performance";
+ ephemeral: boolean;
+ gitRepo?: string;
+}
+
+export interface CreateSandboxResponse {
+ id: string;
+ status: string;
+ language: string;
+ resource_tier: string;
+ ephemeral: boolean;
+}
+
async function sandboxFetch(
path: string,
getToken: () => Promise,
@@ -82,6 +104,36 @@ async function sandboxFetch(
export function createSandboxApi(getToken: () => Promise) {
return {
+ createSandbox(request: CreateSandboxRequest) {
+ return sandboxFetch("/api/sandbox", getToken, {
+ method: "POST",
+ body: JSON.stringify({
+ harness_id: request.harnessId,
+ name: request.name,
+ language: request.language,
+ resource_tier: request.resourceTier,
+ ephemeral: request.ephemeral,
+ git_repo: request.gitRepo,
+ }),
+ });
+ },
+
+ startSandbox(sandboxId: string) {
+ return sandboxFetch(
+ `/api/sandbox/${sandboxId}/start`,
+ getToken,
+ { method: "POST" },
+ );
+ },
+
+ stopSandbox(sandboxId: string) {
+ return sandboxFetch(
+ `/api/sandbox/${sandboxId}/stop`,
+ getToken,
+ { method: "POST" },
+ );
+ },
+
listFiles(sandboxId: string, path = "/home/daytona") {
return sandboxFetch(
`/api/sandbox/${sandboxId}/files?path=${encodeURIComponent(path)}`,
diff --git a/apps/web/src/lib/sandbox-panel-context.tsx b/apps/web/src/lib/sandbox-panel-context.tsx
index 1e7431a..5db4821 100644
--- a/apps/web/src/lib/sandbox-panel-context.tsx
+++ b/apps/web/src/lib/sandbox-panel-context.tsx
@@ -2,7 +2,9 @@ import {
createContext,
useCallback,
useContext,
+ useEffect,
useMemo,
+ useRef,
useState,
} from "react";
@@ -15,6 +17,8 @@ interface SandboxPanelState {
activeTab: SandboxTab;
/** The Daytona sandbox ID for API calls. */
sandboxId: string | null;
+ /** Incremented whenever the sandbox changes so panel children can remount. */
+ reloadKey: number;
/** Currently open file paths (tabs in the file viewer). */
openFiles: string[];
/** Which open file is active. */
@@ -44,6 +48,18 @@ export function SandboxPanelProvider({
const [openFiles, setOpenFiles] = useState([]);
const [activeFile, setActiveFile] = useState(null);
const [currentDir, setCurrentDir] = useState("/home/daytona");
+ const [reloadKey, setReloadKey] = useState(0);
+ const previousSandboxIdRef = useRef(sandboxId);
+
+ useEffect(() => {
+ if (previousSandboxIdRef.current === sandboxId) return;
+ previousSandboxIdRef.current = sandboxId;
+ setActiveTab("files");
+ setOpenFiles([]);
+ setActiveFile(null);
+ setCurrentDir("/home/daytona");
+ setReloadKey((key) => key + 1);
+ }, [sandboxId]);
const togglePanel = useCallback(() => setPanelOpen((o) => !o), []);
@@ -78,6 +94,7 @@ export function SandboxPanelProvider({
panelOpen,
activeTab,
sandboxId,
+ reloadKey,
openFiles,
activeFile,
currentDir,
@@ -92,6 +109,7 @@ export function SandboxPanelProvider({
panelOpen,
activeTab,
sandboxId,
+ reloadKey,
openFiles,
activeFile,
currentDir,
diff --git a/apps/web/src/lib/sandbox.ts b/apps/web/src/lib/sandbox.ts
new file mode 100644
index 0000000..98433ba
--- /dev/null
+++ b/apps/web/src/lib/sandbox.ts
@@ -0,0 +1,88 @@
+import { convexQuery } from "@convex-dev/react-query";
+import { api } from "@harness/convex-backend/convex/_generated/api";
+import type {
+ Doc,
+ Id,
+} from "@harness/convex-backend/convex/_generated/dataModel";
+import type { QueryClient } from "@tanstack/react-query";
+
+export type Sandbox = Doc<"sandboxes">;
+
+export type SandboxConfig = {
+ persistent: boolean;
+ autoStart: boolean;
+ defaultLanguage: string;
+ resourceTier: "basic" | "standard" | "performance";
+};
+
+export type DefaultSandboxSelection = {
+ sandboxId: Id<"sandboxes">;
+ daytonaSandboxId: string;
+ config: SandboxConfig;
+};
+
+export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = {
+ persistent: false,
+ autoStart: true,
+ defaultLanguage: "python",
+ resourceTier: "basic",
+};
+
+// Per-user sandbox cap. Mirrored in Convex at
+// packages/convex-backend/convex/sandboxes.ts (MAX_SANDBOXES_PER_USER) —
+// Convex enforces the cap; this constant is for the UX banner only.
+export const MAX_SANDBOXES_PER_USER = 5;
+
+export function getDefaultSandboxSelection(
+ sandbox: Sandbox | undefined,
+): DefaultSandboxSelection | undefined {
+ if (!sandbox) return undefined;
+ return {
+ sandboxId: sandbox._id,
+ daytonaSandboxId: sandbox.daytonaSandboxId,
+ config: {
+ persistent: !sandbox.ephemeral,
+ autoStart: true,
+ defaultLanguage: sandbox.language ?? "python",
+ resourceTier: getResourceTierFromSandbox(sandbox),
+ },
+ };
+}
+
+export function getResourceTierFromSandbox(
+ sandbox: Sandbox,
+): SandboxConfig["resourceTier"] {
+ if (sandbox.resources.cpu >= 4 || sandbox.resources.memoryGB >= 8) {
+ return "performance";
+ }
+ if (sandbox.resources.cpu >= 2 || sandbox.resources.memoryGB >= 4) {
+ return "standard";
+ }
+ return "basic";
+}
+
+export function formatSandboxMeta(sandbox: Sandbox) {
+ const type = sandbox.ephemeral ? "Ephemeral" : "Persistent";
+ const language = sandbox.language
+ ? sandbox.language.charAt(0).toUpperCase() + sandbox.language.slice(1)
+ : "Default";
+ return `${type} - ${language} - ${sandbox.resources.cpu} CPU - ${sandbox.resources.memoryGB} GB RAM`;
+}
+
+export async function waitForSandboxRecord(
+ queryClient: QueryClient,
+ daytonaSandboxId: string,
+ attempts = 12,
+) {
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ const sandboxes = await queryClient.fetchQuery(
+ convexQuery(api.sandboxes.list, {}),
+ );
+ const sandbox = sandboxes.find(
+ (item) => item.daytonaSandboxId === daytonaSandboxId,
+ );
+ if (sandbox) return sandbox;
+ await new Promise((resolve) => setTimeout(resolve, 250));
+ }
+ return null;
+}
diff --git a/apps/web/src/lib/skills.test.ts b/apps/web/src/lib/skills.test.ts
new file mode 100644
index 0000000..9a894e3
--- /dev/null
+++ b/apps/web/src/lib/skills.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { RECOMMENDED_SKILLS } from "./skills";
+
+describe("RECOMMENDED_SKILLS", () => {
+ it("contains at least 5 curated skills", () => {
+ expect(RECOMMENDED_SKILLS.length).toBeGreaterThanOrEqual(5);
+ });
+
+ it("each entry has id + skill with consistent shape", () => {
+ for (const r of RECOMMENDED_SKILLS) {
+ expect(r.id).toBeTruthy();
+ expect(r.skill.skillId).toBeTruthy();
+ expect(r.skill.fullId.includes(r.skill.skillId)).toBe(true);
+ expect(r.skill.source).toBeTruthy();
+ expect(typeof r.skill.installs).toBe("number");
+ expect(r.skill.installs).toBeGreaterThan(0);
+ }
+ });
+
+ it("ids are unique", () => {
+ const ids = RECOMMENDED_SKILLS.map((r) => r.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
+
+ it("top id matches its skillId", () => {
+ for (const r of RECOMMENDED_SKILLS) {
+ expect(r.id).toBe(r.skill.skillId);
+ }
+ });
+});
diff --git a/apps/web/src/lib/system-prompt.ts b/apps/web/src/lib/system-prompt.ts
new file mode 100644
index 0000000..d8ab99b
--- /dev/null
+++ b/apps/web/src/lib/system-prompt.ts
@@ -0,0 +1,2 @@
+/** Keep in sync with FastAPI `HarnessConfig.system_prompt` and Convex `harnesses` mutations. */
+export const SYSTEM_PROMPT_MAX_LENGTH = 4000;
diff --git a/apps/web/src/lib/use-chat-stream.ts b/apps/web/src/lib/use-chat-stream.ts
index ece2b84..fa6b7af 100644
--- a/apps/web/src/lib/use-chat-stream.ts
+++ b/apps/web/src/lib/use-chat-stream.ts
@@ -1,6 +1,7 @@
-import { useAuth } from "@clerk/tanstack-react-start";
+import { useAuth, useUser } from "@clerk/tanstack-react-start";
import { useCallback, useRef, useState } from "react";
import { env } from "../env";
+import { checkChatRateLimit } from "./chat-ratelimit";
const FASTAPI_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000";
@@ -37,6 +38,13 @@ export interface ConvoStreamState {
model: string | null;
}
+export interface BudgetExceededInfo {
+ dailyPct: number;
+ weeklyPct: number;
+ dailyReset: string;
+ weeklyReset: string;
+}
+
interface UseChatStreamCallbacks {
onToken: (conversationId: string, content: string) => void;
onThinking: (conversationId: string, content: string) => void;
@@ -60,6 +68,7 @@ interface UseChatStreamCallbacks {
event: { sandbox_id: string; status: string },
) => void;
onError: (conversationId: string, error: string) => void;
+ onBudgetExceeded?: (conversationId: string, info: BudgetExceededInfo) => void;
onAbort?: (conversationId: string) => void;
}
@@ -78,6 +87,7 @@ export interface ChatStreamRequest {
skills: Array<{ name: string; description: string }>;
name: string;
harness_id?: string;
+ system_prompt?: string;
sandbox_enabled?: boolean;
sandbox_id?: string;
sandbox_config?: {
@@ -88,6 +98,7 @@ export interface ChatStreamRequest {
};
};
conversation_id: string;
+ forced_tool?: string;
}
export function useChatStream(callbacks: UseChatStreamCallbacks) {
@@ -96,6 +107,7 @@ export function useChatStream(callbacks: UseChatStreamCallbacks) {
);
const abortControllers = useRef>(new Map());
const { getToken } = useAuth();
+ const { user } = useUser();
const cbRef = useRef(callbacks);
cbRef.current = callbacks;
@@ -111,6 +123,25 @@ export function useChatStream(callbacks: UseChatStreamCallbacks) {
setStreamingConvoIds((prev) => new Set(prev).add(convoId));
try {
+ // Arcjet pre-flight request rate check (fail-open: Arcjet outage must not block chat)
+ if (user?.id) {
+ try {
+ const rateCheck = await checkChatRateLimit({
+ data: { userId: user.id },
+ });
+ if (!rateCheck.allowed) {
+ cbRef.current.onError(
+ convoId,
+ `Too many requests. Please wait ${rateCheck.retryAfter ?? "a few"} seconds.`,
+ );
+ return;
+ }
+ } catch {
+ // Arcjet unreachable — allow the request through.
+ // Budget enforcement in FastAPI/Convex is the hard gate.
+ }
+ }
+
const token = await getToken({ template: "convex" });
const response = await fetch(`${FASTAPI_URL}/api/chat/stream`, {
method: "POST",
@@ -181,7 +212,18 @@ export function useChatStream(callbacks: UseChatStreamCallbacks) {
);
break;
case "error":
- cbRef.current.onError(convoId, data.message);
+ if (
+ data.code === "BUDGET_EXCEEDED" &&
+ data.usage &&
+ cbRef.current.onBudgetExceeded
+ ) {
+ cbRef.current.onBudgetExceeded(
+ convoId,
+ data.usage as BudgetExceededInfo,
+ );
+ } else {
+ cbRef.current.onError(convoId, data.message);
+ }
break;
}
} catch {
@@ -206,7 +248,7 @@ export function useChatStream(callbacks: UseChatStreamCallbacks) {
});
}
},
- [getToken],
+ [getToken, user?.id],
);
const cancel = useCallback((conversationId: string) => {
diff --git a/apps/web/src/lib/utils.test.ts b/apps/web/src/lib/utils.test.ts
new file mode 100644
index 0000000..743025b
--- /dev/null
+++ b/apps/web/src/lib/utils.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+import { cn } from "./utils";
+
+describe("cn", () => {
+ it("joins plain class names", () => {
+ expect(cn("a", "b")).toBe("a b");
+ });
+
+ it("drops falsy values", () => {
+ expect(cn("a", false, null, undefined, "", "b")).toBe("a b");
+ });
+
+ it("merges conflicting tailwind classes (last wins)", () => {
+ expect(cn("p-2", "p-4")).toBe("p-4");
+ });
+
+ it("flattens nested arrays and object syntax", () => {
+ expect(cn(["a", { b: true, c: false }], "d")).toBe("a b d");
+ });
+
+ it("returns empty string for no inputs", () => {
+ expect(cn()).toBe("");
+ });
+});
diff --git a/apps/web/src/lib/workspace-colors.test.ts b/apps/web/src/lib/workspace-colors.test.ts
new file mode 100644
index 0000000..589d23c
--- /dev/null
+++ b/apps/web/src/lib/workspace-colors.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+import { getWorkspaceColorHex, WORKSPACE_COLORS } from "./workspace-colors";
+
+describe("WORKSPACE_COLORS", () => {
+ it("exposes 8 color entries", () => {
+ expect(WORKSPACE_COLORS).toHaveLength(8);
+ });
+
+ it("each entry has key, label, hex", () => {
+ for (const color of WORKSPACE_COLORS) {
+ expect(color.key).toMatch(/^[a-z]+$/);
+ expect(color.label).toMatch(/^[A-Z]/);
+ expect(color.hex).toMatch(/^#[0-9A-F]{6}$/);
+ }
+ });
+
+ it("keys are unique", () => {
+ const keys = WORKSPACE_COLORS.map((c) => c.key);
+ expect(new Set(keys).size).toBe(keys.length);
+ });
+});
+
+describe("getWorkspaceColorHex", () => {
+ it("returns hex for known key", () => {
+ expect(getWorkspaceColorHex("rose")).toBe("#FFD9DE");
+ expect(getWorkspaceColorHex("mint")).toBe("#D4EEDB");
+ });
+
+ it("returns null for unknown key", () => {
+ expect(getWorkspaceColorHex("fuchsia")).toBeNull();
+ });
+
+ it("returns null for null / undefined / empty", () => {
+ expect(getWorkspaceColorHex(null)).toBeNull();
+ expect(getWorkspaceColorHex(undefined)).toBeNull();
+ expect(getWorkspaceColorHex("")).toBeNull();
+ });
+});
diff --git a/apps/web/src/lib/workspace-colors.ts b/apps/web/src/lib/workspace-colors.ts
new file mode 100644
index 0000000..eeff399
--- /dev/null
+++ b/apps/web/src/lib/workspace-colors.ts
@@ -0,0 +1,19 @@
+export const WORKSPACE_COLORS = [
+ { key: "rose", label: "Rose", hex: "#FFD9DE" },
+ { key: "peach", label: "Peach", hex: "#FFE4C9" },
+ { key: "butter", label: "Butter", hex: "#FFF3C2" },
+ { key: "mint", label: "Mint", hex: "#D4EEDB" },
+ { key: "sky", label: "Sky", hex: "#D1E7F7" },
+ { key: "lilac", label: "Lilac", hex: "#E3D5F2" },
+ { key: "blush", label: "Blush", hex: "#F5DCE6" },
+ { key: "sand", label: "Sand", hex: "#EDE1CB" },
+] as const;
+
+export type WorkspaceColorKey = (typeof WORKSPACE_COLORS)[number]["key"];
+
+export function getWorkspaceColorHex(
+ key: string | null | undefined,
+): string | null {
+ if (!key) return null;
+ return WORKSPACE_COLORS.find((c) => c.key === key)?.hex ?? null;
+}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index 6f2e5e6..26d7076 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -9,13 +9,24 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as SignUpRouteImport } from './routes/sign-up'
import { Route as SignInRouteImport } from './routes/sign-in'
import { Route as OnboardingRouteImport } from './routes/onboarding'
+import { Route as AppRouteImport } from './routes/app'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as WorkspacesIndexRouteImport } from './routes/workspaces/index'
+import { Route as SandboxesIndexRouteImport } from './routes/sandboxes/index'
import { Route as HarnessesIndexRouteImport } from './routes/harnesses/index'
import { Route as ChatIndexRouteImport } from './routes/chat/index'
+import { Route as SandboxesCreate_sandboxRouteImport } from './routes/sandboxes/create_sandbox'
+import { Route as SandboxesSandboxIdRouteImport } from './routes/sandboxes/$sandboxId'
import { Route as HarnessesHarnessIdRouteImport } from './routes/harnesses/$harnessId'
+const SignUpRoute = SignUpRouteImport.update({
+ id: '/sign-up',
+ path: '/sign-up',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SignInRoute = SignInRouteImport.update({
id: '/sign-in',
path: '/sign-in',
@@ -26,11 +37,26 @@ const OnboardingRoute = OnboardingRouteImport.update({
path: '/onboarding',
getParentRoute: () => rootRouteImport,
} as any)
+const AppRoute = AppRouteImport.update({
+ id: '/app',
+ path: '/app',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const WorkspacesIndexRoute = WorkspacesIndexRouteImport.update({
+ id: '/workspaces/',
+ path: '/workspaces/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const SandboxesIndexRoute = SandboxesIndexRouteImport.update({
+ id: '/sandboxes/',
+ path: '/sandboxes/',
+ getParentRoute: () => rootRouteImport,
+} as any)
const HarnessesIndexRoute = HarnessesIndexRouteImport.update({
id: '/harnesses/',
path: '/harnesses/',
@@ -41,6 +67,16 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
path: '/chat/',
getParentRoute: () => rootRouteImport,
} as any)
+const SandboxesCreate_sandboxRoute = SandboxesCreate_sandboxRouteImport.update({
+ id: '/sandboxes/create_sandbox',
+ path: '/sandboxes/create_sandbox',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const SandboxesSandboxIdRoute = SandboxesSandboxIdRouteImport.update({
+ id: '/sandboxes/$sandboxId',
+ path: '/sandboxes/$sandboxId',
+ getParentRoute: () => rootRouteImport,
+} as any)
const HarnessesHarnessIdRoute = HarnessesHarnessIdRouteImport.update({
id: '/harnesses/$harnessId',
path: '/harnesses/$harnessId',
@@ -49,67 +85,116 @@ const HarnessesHarnessIdRoute = HarnessesHarnessIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/app': typeof AppRoute
'/onboarding': typeof OnboardingRoute
'/sign-in': typeof SignInRoute
+ '/sign-up': typeof SignUpRoute
'/harnesses/$harnessId': typeof HarnessesHarnessIdRoute
+ '/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute
+ '/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute
'/chat/': typeof ChatIndexRoute
'/harnesses/': typeof HarnessesIndexRoute
+ '/sandboxes/': typeof SandboxesIndexRoute
+ '/workspaces/': typeof WorkspacesIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/app': typeof AppRoute
'/onboarding': typeof OnboardingRoute
'/sign-in': typeof SignInRoute
+ '/sign-up': typeof SignUpRoute
'/harnesses/$harnessId': typeof HarnessesHarnessIdRoute
+ '/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute
+ '/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute
'/chat': typeof ChatIndexRoute
'/harnesses': typeof HarnessesIndexRoute
+ '/sandboxes': typeof SandboxesIndexRoute
+ '/workspaces': typeof WorkspacesIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/app': typeof AppRoute
'/onboarding': typeof OnboardingRoute
'/sign-in': typeof SignInRoute
+ '/sign-up': typeof SignUpRoute
'/harnesses/$harnessId': typeof HarnessesHarnessIdRoute
+ '/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute
+ '/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute
'/chat/': typeof ChatIndexRoute
'/harnesses/': typeof HarnessesIndexRoute
+ '/sandboxes/': typeof SandboxesIndexRoute
+ '/workspaces/': typeof WorkspacesIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/app'
| '/onboarding'
| '/sign-in'
+ | '/sign-up'
| '/harnesses/$harnessId'
+ | '/sandboxes/$sandboxId'
+ | '/sandboxes/create_sandbox'
| '/chat/'
| '/harnesses/'
+ | '/sandboxes/'
+ | '/workspaces/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
+ | '/app'
| '/onboarding'
| '/sign-in'
+ | '/sign-up'
| '/harnesses/$harnessId'
+ | '/sandboxes/$sandboxId'
+ | '/sandboxes/create_sandbox'
| '/chat'
| '/harnesses'
+ | '/sandboxes'
+ | '/workspaces'
id:
| '__root__'
| '/'
+ | '/app'
| '/onboarding'
| '/sign-in'
+ | '/sign-up'
| '/harnesses/$harnessId'
+ | '/sandboxes/$sandboxId'
+ | '/sandboxes/create_sandbox'
| '/chat/'
| '/harnesses/'
+ | '/sandboxes/'
+ | '/workspaces/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ AppRoute: typeof AppRoute
OnboardingRoute: typeof OnboardingRoute
SignInRoute: typeof SignInRoute
+ SignUpRoute: typeof SignUpRoute
HarnessesHarnessIdRoute: typeof HarnessesHarnessIdRoute
+ SandboxesSandboxIdRoute: typeof SandboxesSandboxIdRoute
+ SandboxesCreate_sandboxRoute: typeof SandboxesCreate_sandboxRoute
ChatIndexRoute: typeof ChatIndexRoute
HarnessesIndexRoute: typeof HarnessesIndexRoute
+ SandboxesIndexRoute: typeof SandboxesIndexRoute
+ WorkspacesIndexRoute: typeof WorkspacesIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/sign-up': {
+ id: '/sign-up'
+ path: '/sign-up'
+ fullPath: '/sign-up'
+ preLoaderRoute: typeof SignUpRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/sign-in': {
id: '/sign-in'
path: '/sign-in'
@@ -124,6 +209,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof OnboardingRouteImport
parentRoute: typeof rootRouteImport
}
+ '/app': {
+ id: '/app'
+ path: '/app'
+ fullPath: '/app'
+ preLoaderRoute: typeof AppRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -131,6 +223,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/workspaces/': {
+ id: '/workspaces/'
+ path: '/workspaces'
+ fullPath: '/workspaces/'
+ preLoaderRoute: typeof WorkspacesIndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/sandboxes/': {
+ id: '/sandboxes/'
+ path: '/sandboxes'
+ fullPath: '/sandboxes/'
+ preLoaderRoute: typeof SandboxesIndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/harnesses/': {
id: '/harnesses/'
path: '/harnesses'
@@ -145,6 +251,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ChatIndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/sandboxes/create_sandbox': {
+ id: '/sandboxes/create_sandbox'
+ path: '/sandboxes/create_sandbox'
+ fullPath: '/sandboxes/create_sandbox'
+ preLoaderRoute: typeof SandboxesCreate_sandboxRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/sandboxes/$sandboxId': {
+ id: '/sandboxes/$sandboxId'
+ path: '/sandboxes/$sandboxId'
+ fullPath: '/sandboxes/$sandboxId'
+ preLoaderRoute: typeof SandboxesSandboxIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/harnesses/$harnessId': {
id: '/harnesses/$harnessId'
path: '/harnesses/$harnessId'
@@ -157,11 +277,17 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ AppRoute: AppRoute,
OnboardingRoute: OnboardingRoute,
SignInRoute: SignInRoute,
+ SignUpRoute: SignUpRoute,
HarnessesHarnessIdRoute: HarnessesHarnessIdRoute,
+ SandboxesSandboxIdRoute: SandboxesSandboxIdRoute,
+ SandboxesCreate_sandboxRoute: SandboxesCreate_sandboxRoute,
ChatIndexRoute: ChatIndexRoute,
HarnessesIndexRoute: HarnessesIndexRoute,
+ SandboxesIndexRoute: SandboxesIndexRoute,
+ WorkspacesIndexRoute: WorkspacesIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
index b203859..cfe968e 100644
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -17,7 +17,11 @@ import type { ConvexReactClient } from "convex/react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { Toaster } from "react-hot-toast";
+import { CommandPalette } from "../components/command-palette/command-palette";
+import { GlobalCommands } from "../components/command-palette/commands/global-commands";
import { TooltipProvider } from "../components/ui/tooltip";
+import { ChatStreamProvider } from "../lib/chat-stream-context";
+import { CommandPaletteProvider } from "../lib/command-palette/context";
import appCss from "../styles.css?url";
const CHROMELESS_ROUTES = ["/", "/sign-in", "/onboarding"];
@@ -121,24 +125,30 @@ function RootComponent() {
>
- {isChromeless ? (
-
- ) : (
-
-
+
+
+ {isChromeless ? (
-
-
- )}
-
+ ) : (
+
+ )}
+
+
+
+
+
diff --git a/apps/web/src/routes/app.tsx b/apps/web/src/routes/app.tsx
new file mode 100644
index 0000000..90bfc5a
--- /dev/null
+++ b/apps/web/src/routes/app.tsx
@@ -0,0 +1,33 @@
+// redirect to either workspaces or chat based on workspacesMode setting
+import { convexQuery } from "@convex-dev/react-query";
+import { api } from "@harness/convex-backend/convex/_generated/api";
+import { createFileRoute, redirect } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/app")({
+ validateSearch: (
+ search: Record,
+ ): { harnessId?: string; workspaceId?: string } => ({
+ harnessId: (search.harnessId as string) ?? undefined,
+ workspaceId: (search.workspaceId as string) ?? undefined,
+ }),
+ beforeLoad: async ({ context, search }) => {
+ if (!context.userId) {
+ throw redirect({ to: "/sign-in" });
+ }
+ const settings = await context.queryClient.ensureQueryData(
+ convexQuery(api.userSettings.get, {}),
+ );
+
+ if (settings.workspacesMode === "workspaces") {
+ throw redirect({
+ to: "/workspaces",
+ search: {},
+ });
+ } else {
+ throw redirect({
+ to: "/chat",
+ search: { harnessId: search.harnessId },
+ });
+ }
+ },
+});
diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx
index 450083c..1c89ee0 100644
--- a/apps/web/src/routes/chat/index.tsx
+++ b/apps/web/src/routes/chat/index.tsx
@@ -9,16 +9,16 @@ import {
redirect,
useNavigate,
} from "@tanstack/react-router";
-import { usePaginatedQuery } from "convex/react";
+import { useConvexAuth, usePaginatedQuery } from "convex/react";
import {
AlertTriangle,
ArrowUp,
+ Box,
Brain,
Check,
ChevronDown,
ChevronRight,
Cpu,
- Loader2,
LogOut,
MessageSquare,
Mic,
@@ -26,6 +26,7 @@ import {
PanelLeftOpen,
Paperclip,
Plus,
+ RotateCcw,
Search, // Icon for search
Settings,
SlidersHorizontal,
@@ -35,7 +36,6 @@ import {
User,
Wrench,
X,
- Zap,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import React, {
@@ -49,7 +49,9 @@ import React, {
} from "react";
import toast from "react-hot-toast";
import { AttachmentChip } from "../../components/attachment-chip";
+import { useChatPaletteCommands } from "../../components/command-palette/commands/chat-commands";
import { HarnessMark } from "../../components/harness-mark";
+import { HeaderSkillsMenu } from "../../components/header-skills-menu";
import { MarkdownMessage } from "../../components/markdown-message";
import {
type HealthStatus,
@@ -62,8 +64,15 @@ import {
MessageActions,
} from "../../components/message-actions";
import { MessageAttachments } from "../../components/message-attachments";
+import { PendingResponseIndicator } from "../../components/pending-response-indicator";
+import { RoseCurveSpinner } from "../../components/rose-curve-spinner";
import { SandboxPanel } from "../../components/sandbox/sandbox-panel";
import { SandboxResult } from "../../components/sandbox-result";
+import {
+ SlashCommandMenu,
+ useSlashCommandInput,
+} from "../../components/slash-commands";
+import { ThinkingFiveSpinner } from "../../components/thinking-five-spinner";
import {
Avatar,
AvatarFallback,
@@ -102,9 +111,21 @@ import {
TooltipContent,
TooltipTrigger,
} from "../../components/ui/tooltip";
+import { UsageDialog } from "../../components/usage-dialog";
+import { formatResetTime, UsageBadge } from "../../components/usage-display";
import { env } from "../../env";
import { useFileAttachments } from "../../hooks/use-file-attachments";
-import type { McpAuthType } from "../../lib/mcp";
+import {
+ CHAT_INPUT_COUNTER_THRESHOLD,
+ CHAT_INPUT_MAX_LENGTH,
+} from "../../lib/chat-input";
+import {
+ EMPTY_STREAM_STATE,
+ useChatStreamContext,
+ useChatStreamSideEffects,
+} from "../../lib/chat-stream-context";
+import type { McpAuthType, McpServerCommand } from "../../lib/mcp";
+import { fetchCommandsFromApi, sanitizeServerName } from "../../lib/mcp";
import {
acceptString,
allowedMimeTypes,
@@ -118,23 +139,32 @@ import {
useSandboxPanel,
} from "../../lib/sandbox-panel-context";
import type { SkillEntry } from "../../lib/skills";
-import {
- type ConvoStreamState,
- type StreamPart,
- type ToolCallEvent,
- type UsageData,
- useChatStream,
+import type {
+ BudgetExceededInfo,
+ StreamPart,
+ ToolCallEvent,
+ UsageData,
} from "../../lib/use-chat-stream";
import { cn } from "../../lib/utils";
export const Route = createFileRoute("/chat/")({
- validateSearch: (search: Record) => ({
+ validateSearch: (
+ search: Record,
+ ): { harnessId?: string } => ({
harnessId: (search.harnessId as string) ?? undefined,
}),
- beforeLoad: ({ context }) => {
+ beforeLoad: async ({ context }) => {
if (!context.userId) {
throw redirect({ to: "/sign-in" });
}
+ const settings = await context.queryClient.ensureQueryData(
+ convexQuery(api.userSettings.get, {}),
+ );
+ if (settings.workspacesMode === "workspaces") {
+ throw redirect({
+ to: "/workspaces",
+ });
+ }
},
component: ChatPage,
});
@@ -148,15 +178,7 @@ const SUGGESTED_PROMPTS = [
"Create a deployment checklist for production",
];
-const EMPTY_STREAM_STATE: ConvoStreamState = {
- content: null,
- reasoning: null,
- toolCalls: [],
- parts: [],
- pendingDoneContent: null,
- usage: null,
- model: null,
-};
+type SandboxSelection = "harness" | "none" | Id<"sandboxes">;
function ChatPage() {
const navigate = useNavigate();
@@ -169,12 +191,15 @@ function ChatPage() {
const { data: conversations } = useQuery(
convexQuery(api.conversations.list, {}),
);
+ const { data: sandboxes } = useQuery(convexQuery(api.sandboxes.list, {}));
const { data: userSettings } = useQuery(
convexQuery(api.userSettings.get, {}),
);
const [activeHarnessId, setActiveHarnessId] =
useState | null>(null);
+ const [activeSandboxSelection, setActiveSandboxSelection] =
+ useState("harness");
const [activeConvoId, setActiveConvoId] =
useState | null>(null);
// Session-only model override — does not persist to the harness
@@ -185,14 +210,15 @@ function ChatPage() {
useState | null>(null);
const [editingContent, setEditingContent] = useState("");
- // Per-conversation streaming state
- const [streamStates, setStreamStates] = useState<
- Record
- >({});
- const streamStatesRef = useRef(streamStates);
- useEffect(() => {
- streamStatesRef.current = streamStates;
- }, [streamStates]);
+ // Budget exceeded state
+ const [budgetExceeded, setBudgetExceeded] =
+ useState(null);
+
+ // Streaming state lives in the global provider so an in-flight stream
+ // survives navigation away from /chat (e.g. to /harnesses) and back.
+ const chatStream = useChatStreamContext();
+ const { streamStates, streamStatesRef, clearStreamState, setStreamState } =
+ chatStream;
// MCP server failures reported during stream start
type McpFailure = {
@@ -209,6 +235,13 @@ function ChatPage() {
Record
>({});
+ // Bump to force a slash-command refetch (after OAuth, harness edit, etc.)
+ const [commandRefreshKey, setCommandRefreshKey] = useState(0);
+ const refreshCommands = useCallback(
+ () => setCommandRefreshKey((k) => k + 1),
+ [],
+ );
+
// Track conversations that just finished streaming (show green checkmark briefly)
const [doneConvoIds, setDoneConvoIds] = useState>(new Set());
const prevStreamingRef = useRef>(new Set());
@@ -267,6 +300,9 @@ function ChatPage() {
const updateHarness = useMutation({
mutationFn: useConvexMutation(api.harnesses.update),
});
+ const upsertCommandsMut = useMutation({
+ mutationFn: useConvexMutation(api.commands.upsert),
+ });
// Save interrupted assistant message from frontend
const saveInterruptedMsg = useMutation({
@@ -278,95 +314,7 @@ function ChatPage() {
mutationFn: useConvexMutation(api.messages.send),
});
- const chatStream = useChatStream({
- onToken: (convoId, content) => {
- setStreamStates((prev) => {
- const state = prev[convoId] ?? EMPTY_STREAM_STATE;
- const parts = [...state.parts];
- const last = parts[parts.length - 1];
- if (last?.type === "text") {
- parts[parts.length - 1] = {
- ...last,
- content: (last.content ?? "") + content,
- };
- } else {
- parts.push({ type: "text", content });
- }
- return {
- ...prev,
- [convoId]: {
- ...state,
- content: (state.content ?? "") + content,
- parts,
- },
- };
- });
- },
- onThinking: (convoId, content) => {
- setStreamStates((prev) => {
- const state = prev[convoId] ?? EMPTY_STREAM_STATE;
- const parts = [...state.parts];
- const last = parts[parts.length - 1];
- if (last?.type === "reasoning") {
- parts[parts.length - 1] = {
- ...last,
- content: (last.content ?? "") + content,
- };
- } else {
- parts.push({ type: "reasoning", content });
- }
- return {
- ...prev,
- [convoId]: {
- ...state,
- reasoning: (state.reasoning ?? "") + content,
- parts,
- },
- };
- });
- },
- onToolCall: (convoId, event) => {
- setStreamStates((prev) => {
- const state = prev[convoId] ?? EMPTY_STREAM_STATE;
- return {
- ...prev,
- [convoId]: {
- ...state,
- toolCalls: [...state.toolCalls, event],
- parts: [
- ...state.parts,
- {
- type: "tool_call" as const,
- tool: event.tool,
- arguments: event.arguments,
- call_id: event.call_id,
- },
- ],
- },
- };
- });
- },
- onToolResult: (convoId, event) => {
- setStreamStates((prev) => {
- const state = prev[convoId] ?? EMPTY_STREAM_STATE;
- return {
- ...prev,
- [convoId]: {
- ...state,
- toolCalls: state.toolCalls.map((tc) =>
- tc.call_id === event.call_id
- ? { ...tc, result: event.result }
- : tc,
- ),
- parts: state.parts.map((p) =>
- p.type === "tool_call" && p.call_id === event.call_id
- ? { ...p, result: event.result }
- : p,
- ),
- },
- };
- });
- },
+ useChatStreamSideEffects({
onMcpError: (_convoId, event) => {
setMcpFailures((prev) => [
...prev,
@@ -378,27 +326,15 @@ function ChatPage() {
},
]);
},
- onDone: (convoId, fullContent, usage, model) => {
- setStreamStates((prev) => ({
- ...prev,
- [convoId]: {
- content: prev[convoId]?.content ?? fullContent,
- reasoning: prev[convoId]?.reasoning ?? null,
- toolCalls: prev[convoId]?.toolCalls ?? [],
- parts: prev[convoId]?.parts ?? [],
- pendingDoneContent: fullContent,
- usage: usage ?? prev[convoId]?.usage ?? null,
- model: model ?? prev[convoId]?.model ?? null,
- },
- }));
+ onBudgetExceeded: (_convoId, info) => {
+ setBudgetExceeded(info);
+ const which = info.dailyPct >= 100 ? "daily" : "weekly";
+ toast.error(
+ `${which.charAt(0).toUpperCase() + which.slice(1)} usage limit reached`,
+ );
},
- onError: (convoId, error) => {
+ onError: (_convoId, error) => {
toast.error(error);
- setStreamStates((prev) => {
- const next = { ...prev };
- delete next[convoId];
- return next;
- });
},
onAbort: (convoId) => {
const state = streamStatesRef.current[convoId];
@@ -427,11 +363,7 @@ function ChatPage() {
(!state.content && !state.reasoning && state.toolCalls.length === 0)
) {
// Nothing accumulated — just clear state
- setStreamStates((prev) => {
- const next = { ...prev };
- delete next[convoId];
- return next;
- });
+ clearStreamState(convoId);
} else {
// Filter: only keep completed tool calls (those with results)
const completedToolCalls = state.toolCalls.filter(
@@ -449,7 +381,8 @@ function ChatPage() {
const partialContent = state.content ?? "";
// model is only sent in the "done" event which doesn't fire on abort,
// so fall back to the session model, then the harness model
- const model = state.model ?? sessionModel ?? activeHarness?.model ?? null;
+ const model =
+ state.model ?? sessionModel ?? activeHarness?.model ?? null;
saveInterruptedMsg.mutate({
conversationId: convoId as Id<"conversations">,
@@ -465,15 +398,12 @@ function ChatPage() {
// Keep streaming bubble visible until Convex syncs the interrupted message
// (same pattern as onDone — set pendingDoneContent so convexHasMessage can match)
- setStreamStates((prev) => ({
- ...prev,
- [convoId]: {
- ...state,
- toolCalls: completedToolCalls,
- parts: cleanedParts,
- pendingDoneContent: partialContent,
- model,
- },
+ setStreamState(convoId, () => ({
+ ...state,
+ toolCalls: completedToolCalls,
+ parts: cleanedParts,
+ pendingDoneContent: partialContent,
+ model,
}));
}
@@ -513,10 +443,25 @@ function ChatPage() {
}, [activeHarnessId, activeConvoId]);
useEffect(() => {
- if (harnesses && harnesses.length === 0) {
+ if (
+ activeSandboxSelection === "harness" ||
+ activeSandboxSelection === "none" ||
+ !sandboxes
+ ) {
+ return;
+ }
+
+ if (!sandboxes.some((sandbox) => sandbox._id === activeSandboxSelection)) {
+ setActiveSandboxSelection("harness");
+ }
+ }, [activeSandboxSelection, sandboxes]);
+
+ const { isAuthenticated: convexAuthReady } = useConvexAuth();
+ useEffect(() => {
+ if (convexAuthReady && harnesses && harnesses.length === 0) {
navigate({ to: "/onboarding" });
}
- }, [harnesses, navigate]);
+ }, [convexAuthReady, harnesses, navigate]);
useEffect(() => {
const prev = prevStreamingRef.current;
@@ -540,11 +485,7 @@ function ChatPage() {
const handleStreamSynced = useCallback(
(convoId: string) => {
- setStreamStates((prev) => {
- const next = { ...prev };
- delete next[convoId];
- return next;
- });
+ clearStreamState(convoId);
// Process next queued message now that Convex has synced
if (messageQueueRef.current.length > 0) {
@@ -554,88 +495,263 @@ function ChatPage() {
}
}
},
- [shiftQueue],
+ [clearStreamState, shiftQueue],
);
const activeHarness = harnesses?.find((h) => h._id === activeHarnessId);
+ const selectedSandbox =
+ activeSandboxSelection !== "harness" && activeSandboxSelection !== "none"
+ ? sandboxes?.find((sandbox) => sandbox._id === activeSandboxSelection)
+ : undefined;
+ const effectiveSandboxDaytonaId =
+ activeSandboxSelection === "none"
+ ? null
+ : (selectedSandbox?.daytonaSandboxId ??
+ activeHarness?.daytonaSandboxId ??
+ null);
+ const effectiveSandboxEnabled =
+ activeSandboxSelection === "none"
+ ? false
+ : Boolean(
+ selectedSandbox?.daytonaSandboxId ?? activeHarness?.daytonaSandboxId,
+ );
+
+ const handleAddSkill = useCallback(
+ (skill: SkillEntry) => {
+ if (!activeHarness) return;
+ const existing = activeHarness.skills ?? [];
+ if (existing.some((s) => s.name === skill.name)) return;
+ updateHarness.mutate({
+ id: activeHarness._id,
+ skills: [...existing, skill],
+ });
+ },
+ [activeHarness, updateHarness],
+ );
+
+ const handleRemoveSkill = useCallback(
+ (skill: SkillEntry) => {
+ if (!activeHarness) return;
+ const filtered = (activeHarness.skills ?? []).filter(
+ (s) => s.name !== skill.name,
+ );
+ updateHarness.mutate({ id: activeHarness._id, skills: filtered });
+ },
+ [activeHarness, updateHarness],
+ );
+
+ const buildHarnessConfig = useCallback(() => {
+ if (!activeHarness) return null;
+
+ return {
+ model: sessionModel ?? activeHarness.model,
+ mcp_servers: activeHarness.mcpServers.map((s) => ({
+ name: s.name,
+ url: s.url,
+ auth_type: s.authType as "none" | "bearer" | "oauth" | "tiger_junction",
+ auth_token: s.authToken,
+ })),
+ skills: activeHarness.skills ?? [],
+ name: activeHarness.name,
+ harness_id: activeHarness._id,
+ system_prompt: activeHarness.systemPrompt ?? undefined,
+ sandbox_enabled: effectiveSandboxEnabled,
+ sandbox_id: effectiveSandboxDaytonaId ?? undefined,
+ sandbox_config: activeHarness.sandboxConfig
+ ? {
+ persistent: activeHarness.sandboxConfig.persistent,
+ auto_start: activeHarness.sandboxConfig.autoStart,
+ default_language: activeHarness.sandboxConfig.defaultLanguage,
+ resource_tier: activeHarness.sandboxConfig.resourceTier,
+ }
+ : undefined,
+ };
+ }, [
+ activeHarness,
+ effectiveSandboxDaytonaId,
+ effectiveSandboxEnabled,
+ sessionModel,
+ ]);
- // Health-check MCP servers when harness changes
- // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes
+ // Collect all command IDs across the active harness's MCP servers
+ const allCommandIds = useMemo(
+ () => (activeHarness?.mcpServers ?? []).flatMap((s) => s.commandIds ?? []),
+ [activeHarness?.mcpServers],
+ );
+ const { data: storedCommands } = useQuery(
+ convexQuery(
+ api.commands.getByIds,
+ allCommandIds.length > 0 ? { ids: allCommandIds } : "skip",
+ ),
+ );
+
+ // Health-check MCP servers when harness changes, or on-demand via refreshHealth.
+ const healthCheckRunRef = useRef<{ cancel: () => void } | null>(null);
+ const runHealthCheck = useCallback(
+ (
+ servers: Array<{
+ name: string;
+ url: string;
+ authType: McpAuthType;
+ authToken?: string;
+ }>,
+ ) => {
+ healthCheckRunRef.current?.cancel();
+
+ if (servers.length === 0) {
+ setMcpHealthStatuses({});
+ return;
+ }
+
+ // Mark unknown URLs as checking; preserve already-known statuses so
+ // previously-healthy servers don't flash to "Checking…" during a
+ // refresh triggered by adding/removing a server.
+ setMcpHealthStatuses((prev) => {
+ const next: Record = {};
+ for (const s of servers) {
+ next[s.url] = prev[s.url] ?? "checking";
+ }
+ return next;
+ });
+
+ let cancelled = false;
+ const run = async () => {
+ try {
+ const token = await getToken();
+ const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({
+ mcp_servers: servers.map((s) => ({
+ name: s.name,
+ url: s.url,
+ auth_type: s.authType,
+ ...(s.authToken ? { auth_token: s.authToken } : {}),
+ })),
+ force: true,
+ }),
+ });
+ if (cancelled) return;
+ if (!res.ok) {
+ const fallback: Record = {};
+ for (const s of servers) fallback[s.url] = "unreachable";
+ setMcpHealthStatuses(fallback);
+ return;
+ }
+ const data = await res.json();
+ if (cancelled) return;
+ const statuses: Record = {};
+ for (const server of data.servers) {
+ if (server.status === "ok") statuses[server.url] = "reachable";
+ else if (server.status === "auth_required")
+ statuses[server.url] = "auth_required";
+ else statuses[server.url] = "unreachable";
+ }
+ setMcpHealthStatuses(statuses);
+ } catch {
+ if (cancelled) return;
+ const fallback: Record = {};
+ for (const s of servers) fallback[s.url] = "unreachable";
+ setMcpHealthStatuses(fallback);
+ }
+ };
+
+ run();
+ healthCheckRunRef.current = {
+ cancel: () => {
+ cancelled = true;
+ },
+ };
+ },
+ [getToken],
+ );
+
+ const refreshHealth = useCallback(() => {
+ if (activeHarness) runHealthCheck(activeHarness.mcpServers);
+ }, [activeHarness, runHealthCheck]);
+
+ // Re-run when the harness or its set of MCP server URLs changes. The URL
+ // key catches inline adds/removes from the header tooltip without making
+ // every harness-doc edit (name, model, etc.) trigger a health re-check.
+ const mcpUrlKey = activeHarness?.mcpServers.map((s) => s.url).join("|") ?? "";
+ // biome-ignore lint/correctness/useExhaustiveDependencies: deps are id + url-set; runHealthCheck is stable
useEffect(() => {
- if (!activeHarness || activeHarness.mcpServers.length === 0) {
+ if (!activeHarness) {
setMcpHealthStatuses({});
return;
}
+ runHealthCheck(activeHarness.mcpServers);
+ return () => {
+ healthCheckRunRef.current?.cancel();
+ };
+ }, [activeHarness?._id, mcpUrlKey]);
- // Set all servers to "checking"
- const checking: Record = {};
- for (const s of activeHarness.mcpServers) {
- checking[s.url] = "checking";
- }
- setMcpHealthStatuses(checking);
+ // Sync slash commands: fetch from MCP servers, upsert into commands table,
+ // and store the resulting IDs on the harness's mcpServers.
+ // Only runs on explicit triggers (OAuth reconnect, etc.) — NOT on harness
+ // switch or page load, since connecting to each MCP server is expensive.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only fires on commandRefreshKey
+ useEffect(() => {
+ if (commandRefreshKey === 0) return; // skip initial mount
+ if (!activeHarness || activeHarness.mcpServers.length === 0) return;
let cancelled = false;
-
- const runCheck = async () => {
+ (async () => {
try {
const token = await getToken();
- const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
- },
- body: JSON.stringify({
- mcp_servers: activeHarness.mcpServers.map((s) => ({
- name: s.name,
- url: s.url,
- auth_type: s.authType,
- ...(s.authToken ? { auth_token: s.authToken } : {}),
- })),
- force: true,
- }),
+ const cmds = await fetchCommandsFromApi(
+ FASTAPI_URL,
+ activeHarness.mcpServers,
+ token,
+ );
+ if (cancelled || !cmds || cmds.length === 0) return;
+
+ // Upsert all commands into the commands table (stringify parameters)
+ const ids: string[] = await upsertCommandsMut.mutateAsync({
+ commands: cmds.map((c) => ({
+ name: c.name,
+ server: c.server,
+ tool: c.tool,
+ description: c.description,
+ parametersJson: JSON.stringify(c.parameters),
+ })),
});
if (cancelled) return;
- if (!res.ok) {
- const fallback: Record = {};
- for (const s of activeHarness.mcpServers) {
- fallback[s.url] = "unreachable";
- }
- setMcpHealthStatuses(fallback);
- return;
- }
-
- const data = await res.json();
- if (cancelled) return;
+ // Build a name→id map, then assign IDs to each mcpServer
+ const idByName = new Map(
+ cmds.map((c, i) => [c.name, ids[i] as Id<"commands">]),
+ );
+ const enriched = activeHarness.mcpServers.map((s) => ({
+ name: s.name,
+ url: s.url,
+ authType: s.authType,
+ ...(s.authToken ? { authToken: s.authToken } : {}),
+ commandIds: [...idByName.entries()]
+ .filter(([name]) =>
+ name.startsWith(`${sanitizeServerName(s.name)}__`),
+ )
+ .map(([, id]) => id),
+ }));
- const statuses: Record = {};
- for (const server of data.servers) {
- if (server.status === "ok") {
- statuses[server.url] = "reachable";
- } else if (server.status === "auth_required") {
- statuses[server.url] = "auth_required";
- } else {
- statuses[server.url] = "unreachable";
- }
+ if (!cancelled) {
+ updateHarness.mutate({
+ id: activeHarness._id,
+ mcpServers: enriched,
+ });
}
- setMcpHealthStatuses(statuses);
} catch {
- if (cancelled) return;
- const fallback: Record = {};
- for (const s of activeHarness.mcpServers) {
- fallback[s.url] = "unreachable";
- }
- setMcpHealthStatuses(fallback);
+ // Non-blocking — commands are optional
}
- };
-
- runCheck();
+ })();
return () => {
cancelled = true;
};
- }, [activeHarness?._id, getToken]);
+ }, [commandRefreshKey]);
const handleInterrupt = useCallback(
(convoId: string) => {
@@ -690,37 +806,12 @@ function ChatPage() {
{ role: "user", content: pending.content },
];
+ const harnessConfig = buildHarnessConfig();
+ if (!harnessConfig) return;
+
chatStream.stream({
messages: history,
- harness: {
- model: sessionModel ?? activeHarness.model,
- mcp_servers: activeHarness.mcpServers.map((s) => ({
- name: s.name,
- url: s.url,
- auth_type: s.authType as
- | "none"
- | "bearer"
- | "oauth"
- | "tiger_junction",
- auth_token: s.authToken,
- })),
- skills: activeHarness.skills ?? [],
- name: activeHarness.name,
- harness_id: activeHarness._id,
-
- sandbox_enabled: (activeHarness as any).sandboxEnabled ?? false,
- sandbox_id: (activeHarness as any).daytonaSandboxId ?? undefined,
- sandbox_config: (activeHarness as any).sandboxConfig
- ? {
- persistent: (activeHarness as any).sandboxConfig.persistent,
- auto_start: (activeHarness as any).sandboxConfig.autoStart,
- default_language: (activeHarness as any).sandboxConfig
- .defaultLanguage,
- resource_tier: (activeHarness as any).sandboxConfig
- .resourceTier,
- }
- : undefined,
- },
+ harness: harnessConfig,
conversation_id: convoId,
});
};
@@ -731,7 +822,7 @@ function ChatPage() {
activeHarness,
chatStream,
sendMessageFromQueue,
- sessionModel,
+ buildHarnessConfig,
]);
const handleSelectConversation = useCallback(
@@ -780,33 +871,8 @@ function ChatPage() {
await removeMessage.mutateAsync({ id: messageId });
- const harnessConfig = {
- model: sessionModel ?? activeHarness.model,
- mcp_servers: activeHarness.mcpServers.map((s) => ({
- name: s.name,
- url: s.url,
- auth_type: s.authType as
- | "none"
- | "bearer"
- | "oauth"
- | "tiger_junction",
- auth_token: s.authToken,
- })),
- skills: activeHarness.skills ?? [],
- name: activeHarness.name,
- harness_id: activeHarness._id,
- sandbox_enabled: (activeHarness as any).sandboxEnabled ?? false,
- sandbox_id: (activeHarness as any).daytonaSandboxId ?? undefined,
- sandbox_config: (activeHarness as any).sandboxConfig
- ? {
- persistent: (activeHarness as any).sandboxConfig.persistent,
- auto_start: (activeHarness as any).sandboxConfig.autoStart,
- default_language: (activeHarness as any).sandboxConfig
- .defaultLanguage,
- resource_tier: (activeHarness as any).sandboxConfig.resourceTier,
- }
- : undefined,
- };
+ const harnessConfig = buildHarnessConfig();
+ if (!harnessConfig) return;
chatStream.stream({
messages: history,
@@ -814,7 +880,13 @@ function ChatPage() {
conversation_id: activeConvoId,
});
},
- [activeHarness, activeConvoId, chatStream, removeMessage, sessionModel],
+ [
+ activeHarness,
+ activeConvoId,
+ chatStream,
+ removeMessage,
+ buildHarnessConfig,
+ ],
);
const forkConversation = useMutation({
@@ -877,23 +949,12 @@ function ChatPage() {
}));
history.push({ role: "user", content: newContent });
+ const harnessConfig = buildHarnessConfig();
+ if (!harnessConfig) return;
+
chatStream.stream({
messages: history,
- harness: {
- model: sessionModel ?? activeHarness.model,
- mcp_servers: activeHarness.mcpServers.map((s) => ({
- name: s.name,
- url: s.url,
- auth_type: s.authType as
- | "none"
- | "bearer"
- | "oauth"
- | "tiger_junction",
- auth_token: s.authToken,
- })),
- skills: activeHarness.skills ?? [],
- name: activeHarness.name,
- },
+ harness: harnessConfig,
conversation_id: newConvoId,
});
@@ -910,10 +971,23 @@ function ChatPage() {
editForkAndSend,
handleSelectConversation,
chatStream,
- sessionModel,
+ buildHarnessConfig,
],
);
+ useChatPaletteCommands({
+ isStreaming: activeConvoId
+ ? chatStream.streamingConvoIds.has(activeConvoId)
+ : false,
+ canStartNewConversation: Boolean(activeHarnessId),
+ sidebarOpen,
+ onNewConversation: () => setActiveConvoId(null),
+ onCancelStream: () => {
+ if (activeConvoId) handleInterrupt(activeConvoId);
+ },
+ onToggleSidebar: () => setSidebarOpen((v) => !v),
+ });
+
if (harnessesLoading || !harnesses || harnesses.length === 0) {
return ;
}
@@ -927,11 +1001,10 @@ function ChatPage() {
? chatStream.streamingConvoIds.has(activeConvoId)
: false;
- const sandboxEnabled = (activeHarness as any)?.sandboxEnabled ?? false;
- const daytonaSandboxId = (activeHarness as any)?.daytonaSandboxId ?? null;
-
return (
-
+
{sidebarOpen && (
@@ -964,10 +1037,18 @@ function ChatPage() {
harness={activeHarness}
harnesses={harnesses ?? []}
onSwitchHarness={setActiveHarnessId}
+ sandboxes={sandboxes ?? []}
+ activeSandboxSelection={activeSandboxSelection}
+ onSwitchSandbox={setActiveSandboxSelection}
+ effectiveSandboxEnabled={effectiveSandboxEnabled}
sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
isStreaming={isActiveConvoStreaming}
mcpHealthStatuses={mcpHealthStatuses}
+ onRefreshCommands={refreshCommands}
+ onRefreshHealth={refreshHealth}
+ onAddSkill={handleAddSkill}
+ onRemoveSkill={handleRemoveSkill}
/>
setMcpFailures([])}
/>
+ {budgetExceeded && (
+
+
+
+ {budgetExceeded.dailyPct >= 100 ? "Daily" : "Weekly"} usage
+ limit reached
+
+
+ Resets in{" "}
+ {budgetExceeded.dailyPct >= 100
+ ? formatResetTime(budgetExceeded.dailyReset)
+ : formatResetTime(budgetExceeded.weeklyReset)}
+
+
+
setBudgetExceeded(null)}
+ >
+
+
+
+ )}
+
{activeConvoId ? (
({
+ name: c?.name,
+ server: c?.server,
+ tool: c?.tool,
+ description: c?.description,
+ parameters: JSON.parse(c?.parametersJson),
+ }))}
sessionModel={
- userSettings?.modelSelectorMode === "harness" ? null : sessionModel
+ userSettings?.modelSelectorMode === "harness"
+ ? null
+ : sessionModel
}
modelSelectorMode={
(userSettings?.modelSelectorMode as "session" | "harness") ??
@@ -1050,6 +1165,8 @@ function ChatPage() {
}
}}
onConvoCreated={handleSelectConversation}
+ sandboxEnabled={effectiveSandboxEnabled}
+ sandboxId={effectiveSandboxDaytonaId ?? undefined}
isStreaming={isActiveConvoStreaming}
onStream={chatStream.stream}
onInterrupt={handleInterrupt}
@@ -1060,11 +1177,12 @@ function ChatPage() {
onSendNow={handleSendNow}
pendingPrompt={pendingPrompt}
onPendingPromptConsumed={() => setPendingPrompt(null)}
+ budgetExceeded={!!budgetExceeded}
/>
- {sandboxEnabled && }
+ {effectiveSandboxEnabled && }