diff --git a/apps/web/package.json b/apps/web/package.json index 3fc9a8d..c354e7c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -40,6 +40,7 @@ "arcjet": "^1.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "convex": "^1.31.7", "geist": "^1.7.0", "highlight.js": "^11.11.1", diff --git a/apps/web/src/components/command-palette/command-item.tsx b/apps/web/src/components/command-palette/command-item.tsx new file mode 100644 index 0000000..70e324d --- /dev/null +++ b/apps/web/src/components/command-palette/command-item.tsx @@ -0,0 +1,52 @@ +import { Command as Cmdk } from "cmdk"; +import type { Command } from "../../lib/command-palette/types"; +import { cn } from "../../lib/utils"; +import { CommandKbd } from "./command-kbd"; + +interface CommandItemProps { + command: Command; + onRun: (command: Command) => void; +} + +export function CommandItem({ command, onRun }: CommandItemProps) { + const Icon = command.icon; + return ( + onRun(command)} + className={cn( + "group/cmd-item relative flex h-9 cursor-pointer select-none items-center gap-2.5 rounded-sm px-2 text-sm", + "text-foreground/85 outline-none transition-colors", + "aria-selected:bg-muted aria-selected:text-foreground", + "data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-40", + )} + > + + {command.colorDot ? ( + + + {command.title} + {command.subtitle && ( + + {command.subtitle} + + )} + + {command.shortcut && ( + + )} + + ); +} diff --git a/apps/web/src/components/command-palette/command-kbd.tsx b/apps/web/src/components/command-palette/command-kbd.tsx new file mode 100644 index 0000000..b5460c0 --- /dev/null +++ b/apps/web/src/components/command-palette/command-kbd.tsx @@ -0,0 +1,36 @@ +import { cn } from "../../lib/utils"; + +/** Split a shortcut label like "⌘⌥1" or "Ctrl+Alt+1" into individual key tokens. */ +function splitKeys(shortcut: string): string[] { + if (shortcut.includes("+")) return shortcut.split("+").filter(Boolean); + return Array.from(shortcut); +} + +export function CommandKbd({ + shortcut, + className, +}: { + shortcut: string; + className?: string; +}) { + const keys = splitKeys(shortcut); + return ( + + {keys.map((key, i) => ( + + {key} + + ))} + + ); +} diff --git a/apps/web/src/components/command-palette/command-palette.tsx b/apps/web/src/components/command-palette/command-palette.tsx new file mode 100644 index 0000000..02a8cfb --- /dev/null +++ b/apps/web/src/components/command-palette/command-palette.tsx @@ -0,0 +1,219 @@ +import { Command as Cmdk } from "cmdk"; +import { Search } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useCommandPaletteHotkey } from "../../hooks/use-command-palette-hotkey"; +import { useCommandPalette } from "../../lib/command-palette/context"; +import { + getRecentCommandIds, + pushRecentCommand, +} from "../../lib/command-palette/recent"; +import { + COMMAND_GROUP_LABELS, + COMMAND_GROUP_ORDER, + type Command, + type CommandGroupId, +} from "../../lib/command-palette/types"; +import { cn } from "../../lib/utils"; +import { CommandItem } from "./command-item"; + +export function CommandPalette() { + useCommandPaletteHotkey(); + + const { open, setOpen, snapshot } = useCommandPalette(); + const [search, setSearch] = useState(""); + const [recentIds, setRecentIds] = useState([]); + const [snapshotCommands, setSnapshotCommands] = useState([]); + const wasOpenRef = useRef(false); + + useEffect(() => { + if (!open) { + wasOpenRef.current = false; + return; + } + const justOpened = !wasOpenRef.current; + wasOpenRef.current = true; + if (justOpened) { + setSearch(""); + setRecentIds(getRecentCommandIds()); + } + setSnapshotCommands(snapshot().filter((c) => !c.when || c.when())); + }, [open, snapshot]); + + const { groups, recentCommands } = useMemo(() => { + const byGroup = new Map(); + for (const cmd of snapshotCommands) { + const list = byGroup.get(cmd.group) ?? []; + list.push(cmd); + byGroup.set(cmd.group, list); + } + + const byId = new Map(snapshotCommands.map((c) => [c.id, c])); + const recent: Command[] = []; + if (search.trim().length === 0) { + for (const id of recentIds) { + const cmd = byId.get(id); + if (cmd) recent.push(cmd); + if (recent.length >= 5) break; + } + } + + const ordered: Array<{ id: CommandGroupId; commands: Command[] }> = []; + for (const groupId of COMMAND_GROUP_ORDER) { + if (groupId === "recent") continue; + const commands = byGroup.get(groupId); + if (commands && commands.length > 0) { + ordered.push({ id: groupId, commands }); + } + } + return { groups: ordered, recentCommands: recent }; + }, [snapshotCommands, recentIds, search]); + + const runCommand = (command: Command) => { + setOpen(false); + pushRecentCommand(command.id); + // Defer to let the dialog close animation start before the handler fires + // (e.g., navigation). Keeps the UI feeling snappy and avoids focus fights. + queueMicrotask(() => { + try { + command.perform(); + } catch (err) { + console.error("[command-palette] command failed", command.id, err); + } + }); + }; + + return ( + +
+
+ + + + + No results found + + + Try a different keyword + + + + {recentCommands.length > 0 && ( + Recently used} + className="mb-1" + > + {recentCommands.map((command) => ( + + ))} + + )} + + {groups.map(({ id, commands }) => ( + {COMMAND_GROUP_LABELS[id]}} + className="mb-1" + > + {commands.map((command) => ( + + ))} + + ))} + + +
+
+ + + + + + + + + esc + +
+ + Harness + +
+
+ ); +} + +function GroupHeading({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function FooterHint({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( + + {children} + {label} + + ); +} + +function FooterKey({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/command-palette/commands/chat-commands.ts b/apps/web/src/components/command-palette/commands/chat-commands.ts new file mode 100644 index 0000000..e2067c5 --- /dev/null +++ b/apps/web/src/components/command-palette/commands/chat-commands.ts @@ -0,0 +1,75 @@ +import { + MessageSquarePlus, + PanelLeftClose, + PanelLeftOpen, + Square, +} from "lucide-react"; +import { useMemo } from "react"; +import { useRegisterCommands } from "../../../hooks/use-register-commands"; +import type { Command } from "../../../lib/command-palette/types"; + +interface ChatPaletteCommandsInput { + isStreaming: boolean; + canStartNewConversation: boolean; + sidebarOpen: boolean; + onNewConversation: () => void; + onCancelStream: () => void; + onToggleSidebar: () => void; +} + +/** + * Registers the chat-scoped commands: new conversation, cancel streaming, + * and sidebar toggle. Call from whichever route hosts the chat surface + * (both `/chat` and `/workspaces` use this — they're mutually exclusive + * at runtime, so the command IDs don't collide). + */ +export function useChatPaletteCommands({ + isStreaming, + canStartNewConversation, + sidebarOpen, + onNewConversation, + onCancelStream, + onToggleSidebar, +}: ChatPaletteCommandsInput): void { + const commands = useMemo(() => { + const list: Command[] = [ + { + id: "chat:new-conversation", + title: "New conversation", + group: "chat", + icon: MessageSquarePlus, + keywords: ["new", "chat", "convo", "message"], + when: () => canStartNewConversation, + perform: onNewConversation, + }, + { + id: "chat:toggle-sidebar", + title: sidebarOpen ? "Hide sidebar" : "Show sidebar", + group: "chat", + icon: sidebarOpen ? PanelLeftClose : PanelLeftOpen, + keywords: ["sidebar", "panel", "toggle"], + perform: onToggleSidebar, + }, + ]; + if (isStreaming) { + list.push({ + id: "chat:cancel-stream", + title: "Cancel streaming response", + group: "chat", + icon: Square, + keywords: ["stop", "interrupt", "abort"], + perform: onCancelStream, + }); + } + return list; + }, [ + isStreaming, + canStartNewConversation, + sidebarOpen, + onNewConversation, + onCancelStream, + onToggleSidebar, + ]); + + useRegisterCommands(commands); +} diff --git a/apps/web/src/components/command-palette/commands/global-commands.tsx b/apps/web/src/components/command-palette/commands/global-commands.tsx new file mode 100644 index 0000000..2268ab1 --- /dev/null +++ b/apps/web/src/components/command-palette/commands/global-commands.tsx @@ -0,0 +1,132 @@ +import { useAuth, useClerk } from "@clerk/tanstack-react-start"; +import { convexQuery } from "@convex-dev/react-query"; +import { api } from "@harness/convex-backend/convex/_generated/api"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { + Box, + BoxIcon, + FolderKanban, + LogOut, + MessageSquare, + Package, + SlidersHorizontal, +} from "lucide-react"; +import { useMemo } from "react"; +import { useRegisterCommands } from "../../../hooks/use-register-commands"; +import type { Command } from "../../../lib/command-palette/types"; + +/** + * Always-available navigation + account commands. + * Must be rendered inside `CommandPaletteProvider` — mount once at the root. + */ +export function GlobalCommands() { + const navigate = useNavigate(); + const { signOut } = useClerk(); + const { isSignedIn } = useAuth(); + + const { data: harnesses } = useQuery({ + ...convexQuery(api.harnesses.list, {}), + enabled: !!isSignedIn, + }); + const { data: sandboxes } = useQuery({ + ...convexQuery(api.sandboxes.list, {}), + enabled: !!isSignedIn, + }); + + const commands = useMemo(() => { + if (!isSignedIn) return []; + + const list: Command[] = [ + { + id: "nav:chat", + title: "Go to Chat", + group: "navigation", + icon: MessageSquare, + keywords: ["conversation", "message"], + perform: () => navigate({ to: "/chat" }), + }, + { + id: "nav:workspaces", + title: "Go to Workspaces", + group: "navigation", + icon: FolderKanban, + keywords: ["projects"], + perform: () => navigate({ to: "/workspaces" }), + }, + { + id: "nav:harnesses", + title: "Manage Harnesses", + group: "navigation", + icon: SlidersHorizontal, + keywords: ["agents", "configurations"], + perform: () => navigate({ to: "/harnesses" }), + }, + { + id: "nav:sandboxes", + title: "Manage Sandboxes", + group: "navigation", + icon: Box, + keywords: ["environments", "daytona"], + perform: () => navigate({ to: "/sandboxes" }), + }, + { + id: "sandbox:create", + title: "Create sandbox…", + group: "sandbox", + icon: BoxIcon, + keywords: ["new", "sandbox", "add", "daytona"], + perform: () => navigate({ to: "/sandboxes/create_sandbox" }), + }, + ]; + + for (const harness of harnesses ?? []) { + list.push({ + id: `harness:open:${harness._id}`, + title: `Open harness: ${harness.name}`, + subtitle: harness.status, + group: "harness", + icon: Package, + keywords: ["harness", "agent", harness.name, harness.status], + perform: () => + navigate({ + to: "/harnesses/$harnessId", + params: { harnessId: harness._id }, + }), + }); + } + + for (const sandbox of sandboxes ?? []) { + list.push({ + id: `sandbox:open:${sandbox._id}`, + title: `Open sandbox: ${sandbox.name}`, + subtitle: sandbox.status, + group: "sandbox", + icon: Package, + keywords: ["sandbox", "environment", sandbox.name, sandbox.status], + perform: () => + navigate({ + to: "/sandboxes/$sandboxId", + params: { sandboxId: sandbox._id }, + }), + }); + } + + list.push({ + id: "account:sign-out", + title: "Sign out", + group: "account", + icon: LogOut, + keywords: ["logout", "leave"], + perform: async () => { + await signOut(); + navigate({ to: "/sign-in" }); + }, + }); + + return list; + }, [isSignedIn, navigate, signOut, harnesses, sandboxes]); + + useRegisterCommands(commands); + return null; +} diff --git a/apps/web/src/components/command-palette/commands/workspace-action-commands.ts b/apps/web/src/components/command-palette/commands/workspace-action-commands.ts new file mode 100644 index 0000000..0dfe44c --- /dev/null +++ b/apps/web/src/components/command-palette/commands/workspace-action-commands.ts @@ -0,0 +1,90 @@ +import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; +import { Edit3, FolderPlus } from "lucide-react"; +import { useEffect, useMemo, useRef } from "react"; +import { useRegisterCommands } from "../../../hooks/use-register-commands"; +import type { Command } from "../../../lib/command-palette/types"; +import { getWorkspaceColorHex } from "../../../lib/workspace-colors"; + +interface ActiveWorkspace { + _id: Id<"workspaces">; + name: string; + harnessId?: Id<"harnesses">; + sandboxId?: Id<"sandboxes">; + color?: string; +} + +interface WorkspaceActionCommandsInput { + activeWorkspace: ActiveWorkspace | undefined; + canCreateWorkspace: boolean; + onCreateWorkspace: () => void; + onRenameActiveWorkspace: () => void; +} + +export function useWorkspaceActionCommands({ + activeWorkspace, + canCreateWorkspace, + onCreateWorkspace, + onRenameActiveWorkspace, +}: WorkspaceActionCommandsInput): void { + const activeColor = activeWorkspace + ? (getWorkspaceColorHex(activeWorkspace.color) ?? undefined) + : undefined; + + const commands = useMemo(() => { + const list: Command[] = [ + { + id: "workspace:create", + title: "Create workspace…", + subtitle: "Open the new workspace dialog", + group: "workspace", + icon: FolderPlus, + keywords: ["new", "workspace", "add"], + when: () => canCreateWorkspace, + perform: onCreateWorkspace, + }, + ]; + + if (activeWorkspace) { + list.push({ + id: "workspace:rename-active", + title: `Rename workspace: ${activeWorkspace.name}`, + group: "workspace", + icon: Edit3, + colorDot: activeColor, + shortcut: "⇧⌘R", + keywords: ["rename", "edit", "workspace", activeWorkspace.name], + perform: onRenameActiveWorkspace, + }); + } + + return list; + }, [ + activeWorkspace, + activeColor, + canCreateWorkspace, + onCreateWorkspace, + onRenameActiveWorkspace, + ]); + + useRegisterCommands(commands); + + // Latest callback in a ref so the keydown effect doesn't rebind on every + // render. We need activeWorkspace's presence though, so the effect still + // resubscribes when that flips. + const renameRef = useRef(onRenameActiveWorkspace); + renameRef.current = onRenameActiveWorkspace; + const hasActive = !!activeWorkspace; + + useEffect(() => { + if (!hasActive) return; + const handler = (e: KeyboardEvent) => { + if (e.repeat) return; + if (!(e.metaKey || e.ctrlKey) || !e.shiftKey) return; + if (e.key !== "r" && e.key !== "R") return; + e.preventDefault(); + renameRef.current(); + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [hasActive]); +} diff --git a/apps/web/src/components/command-palette/commands/workspace-switch-commands.ts b/apps/web/src/components/command-palette/commands/workspace-switch-commands.ts new file mode 100644 index 0000000..37a8e7a --- /dev/null +++ b/apps/web/src/components/command-palette/commands/workspace-switch-commands.ts @@ -0,0 +1,42 @@ +import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; +import { useMemo } from "react"; +import { useRegisterCommands } from "../../../hooks/use-register-commands"; +import type { Command } from "../../../lib/command-palette/types"; +import { formatShortcut } from "../../../lib/platform"; +import { getWorkspaceColorHex } from "../../../lib/workspace-colors"; + +interface WorkspaceLike { + _id: Id<"workspaces">; + name: string; + color?: string; +} + +/** + * Registers one switch-to- command per workspace. + * Render from inside the /workspaces route where `onSelect` can directly call + * `setActiveWorkspaceId`. + */ +export function useWorkspaceSwitchCommands( + workspaces: ReadonlyArray | undefined, + onSelect: (id: Id<"workspaces">) => void, + isMac: boolean, +): void { + const commands = useMemo(() => { + if (!workspaces || workspaces.length === 0) return []; + return workspaces.map((workspace, index) => { + const colorDot = getWorkspaceColorHex(workspace.color) ?? undefined; + const shortcut = index < 9 ? formatShortcut(index + 1, isMac) : undefined; + return { + id: `workspace:switch:${workspace._id}`, + title: `Switch to ${workspace.name}`, + group: "workspace", + keywords: ["workspace", "switch", workspace.name], + colorDot, + shortcut, + perform: () => onSelect(workspace._id), + }; + }); + }, [workspaces, onSelect, isMac]); + + useRegisterCommands(commands); +} diff --git a/apps/web/src/components/header-skills-menu.tsx b/apps/web/src/components/header-skills-menu.tsx new file mode 100644 index 0000000..dbaf994 --- /dev/null +++ b/apps/web/src/components/header-skills-menu.tsx @@ -0,0 +1,146 @@ +import { Eye, Plus, X, Zap } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef, useState } from "react"; +import type { SkillEntry } from "../lib/skills"; +import { SkillViewerDialog } from "./skill-viewer-dialog"; +import { SkillsBrowser } from "./skills-browser"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; +import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; + +interface HeaderSkillsMenuProps { + skills: SkillEntry[]; + onAdd: (skill: SkillEntry) => void; + onRemove: (skill: SkillEntry) => void; +} + +export function HeaderSkillsMenu({ + skills, + onAdd, + onRemove, +}: HeaderSkillsMenuProps) { + const [open, setOpen] = useState(false); + const [viewingSkillId, setViewingSkillId] = useState(null); + const [browseOpen, setBrowseOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (viewingSkillId || browseOpen) return; + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open, viewingSkillId, browseOpen]); + + const hasSkills = skills.length > 0; + + return ( +
+ + + + + + {hasSkills ? "Manage skills" : "Add skills to this harness"} + + + + + {open && ( + +
+ + Skills + +
+
+ {hasSkills ? ( + skills.map((skill) => ( +
+ + + {skill.name.split("/").pop() ?? skill.name} + + + +
+ )) + ) : ( +

+ No skills attached yet. +

+ )} +
+ +
+ )} +
+ + setViewingSkillId(null)} + /> + + + + + Manage skills + + { + const exists = skills.some((s) => s.name === skill.name); + if (exists) onRemove(skill); + else onAdd(skill); + }} + /> + + +
+ ); +} diff --git a/apps/web/src/components/rose-curve-spinner.tsx b/apps/web/src/components/rose-curve-spinner.tsx index 82c527e..88495e2 100644 --- a/apps/web/src/components/rose-curve-spinner.tsx +++ b/apps/web/src/components/rose-curve-spinner.tsx @@ -16,6 +16,23 @@ const ROSE_SCALE = 3.25; const PATH_STEPS = 480; const PARTICLE_IDS = Array.from({ length: PARTICLE_COUNT }, (_, i) => `p-${i}`); +const INITIAL_DETAIL_SCALE = getDetailScale(0); +const INITIAL_PATH_D = buildPathD(INITIAL_DETAIL_SCALE); +const INITIAL_PARTICLES = PARTICLE_IDS.map((_, i) => { + const tailOffset = i / (PARTICLE_COUNT - 1); + const point = computePoint( + normalizeProgress(-tailOffset * TRAIL_SPAN), + INITIAL_DETAIL_SCALE, + ); + const fade = (1 - tailOffset) ** 0.56; + return { + cx: point.x.toFixed(2), + cy: point.y.toFixed(2), + r: (0.9 + fade * 2.7).toFixed(2), + opacity: (0.04 + fade * 0.96).toFixed(3), + }; +}); + function normalizeProgress(value: number) { return ((value % 1) + 1) % 1; } @@ -142,22 +159,29 @@ export function RoseCurveSpinner({ - {PARTICLE_IDS.map((id, i) => ( - { - particlesRef.current[i] = el; - }} - fill="currentColor" - r={0} - /> - ))} + {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/sandbox-panel.tsx b/apps/web/src/components/sandbox/sandbox-panel.tsx index 7ce2a4a..6d5735e 100644 --- a/apps/web/src/components/sandbox/sandbox-panel.tsx +++ b/apps/web/src/components/sandbox/sandbox-panel.tsx @@ -90,6 +90,7 @@ export function SandboxPanel() { const { activeTab, setActiveTab, + reloadKey, activeFile, openFiles, togglePanel, @@ -182,7 +183,10 @@ export function SandboxPanel() { {/* Content */} -
+
{/* Files tab */} {activeTab === "files" && ( 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/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 ( +
+ + {WORKSPACE_COLORS.map((color) => ( + + ))} +
+ ); +} 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-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.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/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.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/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/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..e3641d4 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,9 +11,14 @@ import { Route as rootRouteImport } from './routes/__root' 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 SignInRoute = SignInRouteImport.update({ @@ -26,11 +31,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 +61,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,63 +79,98 @@ const HarnessesHarnessIdRoute = HarnessesHarnessIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/app': typeof AppRoute '/onboarding': typeof OnboardingRoute '/sign-in': typeof SignInRoute '/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 '/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 '/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' | '/harnesses/$harnessId' + | '/sandboxes/$sandboxId' + | '/sandboxes/create_sandbox' | '/chat/' | '/harnesses/' + | '/sandboxes/' + | '/workspaces/' fileRoutesByTo: FileRoutesByTo to: | '/' + | '/app' | '/onboarding' | '/sign-in' | '/harnesses/$harnessId' + | '/sandboxes/$sandboxId' + | '/sandboxes/create_sandbox' | '/chat' | '/harnesses' + | '/sandboxes' + | '/workspaces' id: | '__root__' | '/' + | '/app' | '/onboarding' | '/sign-in' | '/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 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' { @@ -124,6 +189,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 +203,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 +231,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 +257,16 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + AppRoute: AppRoute, OnboardingRoute: OnboardingRoute, SignInRoute: SignInRoute, 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..68a1d4e 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,7 +17,10 @@ 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 { CommandPaletteProvider } from "../lib/command-palette/context"; import appCss from "../styles.css?url"; const CHROMELESS_ROUTES = ["/", "/sign-in", "/onboarding"]; @@ -121,24 +124,28 @@ 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 55765be..8234082 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, - Eye, LogOut, MessageSquare, Mic, @@ -36,7 +36,6 @@ import { User, Wrench, X, - Zap, } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import React, { @@ -50,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, @@ -66,11 +67,11 @@ import { MessageAttachments } from "../../components/message-attachments"; import { RoseCurveSpinner } from "../../components/rose-curve-spinner"; import { SandboxPanel } from "../../components/sandbox/sandbox-panel"; import { SandboxResult } from "../../components/sandbox-result"; -import { SkillViewerDialog } from "../../components/skill-viewer-dialog"; import { SlashCommandMenu, useSlashCommandInput, } from "../../components/slash-commands"; +import { ThinkingFiveSpinner } from "../../components/thinking-five-spinner"; import { Avatar, AvatarFallback, @@ -139,13 +140,23 @@ import { 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, }); @@ -169,6 +180,8 @@ const EMPTY_STREAM_STATE: ConvoStreamState = { model: null, }; +type SandboxSelection = "harness" | "none" | Id<"sandboxes">; + function ChatPage() { const navigate = useNavigate(); const { getToken } = useAuth(); @@ -180,12 +193,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 @@ -546,10 +562,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; @@ -591,6 +622,79 @@ function ChatPage() { ); 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, + ]); // Collect all command IDs across the active harness's MCP servers const allCommandIds = useMemo( @@ -799,36 +903,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, - system_prompt: activeHarness.systemPrompt ?? undefined, - - sandbox_enabled: activeHarness.sandboxEnabled ?? false, - sandbox_id: activeHarness.daytonaSandboxId ?? 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, - }, + harness: harnessConfig, conversation_id: convoId, }); }; @@ -839,7 +919,7 @@ function ChatPage() { activeHarness, chatStream, sendMessageFromQueue, - sessionModel, + buildHarnessConfig, ]); const handleSelectConversation = useCallback( @@ -888,33 +968,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, - system_prompt: activeHarness.systemPrompt ?? undefined, - sandbox_enabled: activeHarness.sandboxEnabled ?? false, - sandbox_id: activeHarness.daytonaSandboxId ?? 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, - }; + const harnessConfig = buildHarnessConfig(); + if (!harnessConfig) return; chatStream.stream({ messages: history, @@ -922,7 +977,13 @@ function ChatPage() { conversation_id: activeConvoId, }); }, - [activeHarness, activeConvoId, chatStream, removeMessage, sessionModel], + [ + activeHarness, + activeConvoId, + chatStream, + removeMessage, + buildHarnessConfig, + ], ); const forkConversation = useMutation({ @@ -985,24 +1046,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, - system_prompt: activeHarness.systemPrompt ?? undefined, - }, + harness: harnessConfig, conversation_id: newConvoId, }); @@ -1019,10 +1068,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 ; } @@ -1036,11 +1098,10 @@ function ChatPage() { ? chatStream.streamingConvoIds.has(activeConvoId) : false; - const sandboxEnabled = activeHarness?.sandboxEnabled ?? false; - const daytonaSandboxId = activeHarness?.daytonaSandboxId ?? null; - return ( - +
{sidebarOpen && ( @@ -1073,11 +1134,17 @@ 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} + onAddSkill={handleAddSkill} + onRemoveSkill={handleRemoveSkill} /> - {sandboxEnabled && } + {effectiveSandboxEnabled && }
@@ -1605,6 +1674,17 @@ function ChatSidebar({
+ - - Active skills - - - - {open && ( - -
- - Skills - -
-
- {skills.map((skill) => ( -
- - - {skill.name.split("/").pop() ?? skill.name} - - -
- ))} -
-
- )} -
- - setViewingSkillId(null)} - /> -
- ); -} - function ChatHeader({ harness, harnesses, onSwitchHarness, + sandboxes, + activeSandboxSelection, + onSwitchSandbox, + effectiveSandboxEnabled, sidebarOpen, onToggleSidebar, isStreaming, mcpHealthStatuses, onRefreshCommands, + onAddSkill, + onRemoveSkill, }: { harness?: { _id: Id<"harnesses">; @@ -1989,6 +2035,7 @@ function ChatHeader({ }>; skills: SkillEntry[]; sandboxEnabled?: boolean; + daytonaSandboxId?: string; }; harnesses: Array<{ _id: Id<"harnesses">; @@ -1997,11 +2044,23 @@ function ChatHeader({ status: string; }>; onSwitchHarness: (id: Id<"harnesses">) => void; + sandboxes: Array<{ + _id: Id<"sandboxes">; + name: string; + daytonaSandboxId: string; + status: string; + ephemeral: boolean; + }>; + activeSandboxSelection: SandboxSelection; + onSwitchSandbox: (selection: SandboxSelection) => void; + effectiveSandboxEnabled: boolean; sidebarOpen: boolean; onToggleSidebar: () => void; isStreaming: boolean; mcpHealthStatuses?: Record; onRefreshCommands: () => void; + onAddSkill: (skill: SkillEntry) => void; + onRemoveSkill: (skill: SkillEntry) => void; }) { return (
@@ -2065,44 +2124,165 @@ function ChatHeader({ /> )} - {harness && harness.skills.length > 0 && ( - + {harness && ( + )} - {harness?.sandboxEnabled && } +
); } -/** Clickable sandbox badge in the header — toggles the sandbox panel. */ -function SandboxBadge() { +function SandboxSelector({ + harness, + sandboxes, + activeSandboxSelection, + onSwitchSandbox, + isStreaming, + panelAvailable, +}: { + harness?: { + name: string; + sandboxEnabled?: boolean; + daytonaSandboxId?: string; + }; + sandboxes: Array<{ + _id: Id<"sandboxes">; + name: string; + daytonaSandboxId: string; + status: string; + ephemeral: boolean; + }>; + activeSandboxSelection: SandboxSelection; + onSwitchSandbox: (selection: SandboxSelection) => void; + isStreaming: boolean; + panelAvailable: boolean; +}) { const panel = useSandboxPanel(); + const panelOpen = !!panel?.panelOpen; + const selectedSandbox = + activeSandboxSelection !== "harness" && activeSandboxSelection !== "none" + ? sandboxes.find((sandbox) => sandbox._id === activeSandboxSelection) + : undefined; + const defaultSandbox = harness?.daytonaSandboxId + ? sandboxes.find( + (sandbox) => sandbox.daytonaSandboxId === harness.daytonaSandboxId, + ) + : undefined; + const defaultSandboxName = + defaultSandbox?.name ?? harness?.daytonaSandboxId ?? "None"; + const label = + activeSandboxSelection === "none" + ? "No sandbox" + : (selectedSandbox?.name ?? `Default: ${defaultSandboxName}`); + return ( - - - - - -

- {panel?.panelOpen - ? "Close sandbox panel" - : "Open sandbox panel — browse files and interact directly"} -

-
-
+ + + {label} + + {panelOpen && ( +