From 9f5ec11f8dc8d38b47c308b5b96055860b22b03e Mon Sep 17 00:00:00 2001 From: jon3350 Date: Sat, 18 Apr 2026 10:40:22 -0400 Subject: [PATCH 1/9] decoupled harness from sandbox. There is a manage harness and manage sandbox setting on the chat page. --- apps/web/src/lib/sandbox-api.ts | 52 + apps/web/src/routeTree.gen.ts | 105 + apps/web/src/routes/app.tsx | 33 + apps/web/src/routes/chat/index.tsx | 451 +- apps/web/src/routes/index.tsx | 2 +- apps/web/src/routes/onboarding.tsx | 35 +- apps/web/src/routes/sandboxes/$sandboxId.tsx | 423 ++ .../src/routes/sandboxes/create_sandbox.tsx | 268 ++ apps/web/src/routes/sandboxes/index.tsx | 556 +++ apps/web/src/routes/sign-in.tsx | 2 +- apps/web/src/routes/workspaces/index.tsx | 3814 +++++++++++++++++ packages/convex-backend/convex/sandboxes.ts | 26 +- packages/convex-backend/convex/schema.ts | 7 + .../convex-backend/convex/userSettings.ts | 5 + packages/fastapi/app/routes/sandbox.py | 26 +- 15 files changed, 5701 insertions(+), 104 deletions(-) create mode 100644 apps/web/src/routes/app.tsx create mode 100644 apps/web/src/routes/sandboxes/$sandboxId.tsx create mode 100644 apps/web/src/routes/sandboxes/create_sandbox.tsx create mode 100644 apps/web/src/routes/sandboxes/index.tsx create mode 100644 apps/web/src/routes/workspaces/index.tsx 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/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/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 55dbf65..becbb80 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -13,6 +13,7 @@ import { usePaginatedQuery } from "convex/react"; import { AlertTriangle, ArrowUp, + Box, Brain, Check, ChevronDown, @@ -118,6 +119,7 @@ import { modelSupportsMedia, } from "../../lib/models"; import { buildMultimodalContent } from "../../lib/multimodal"; +import { createSandboxApi } from "../../lib/sandbox-api"; import { SandboxPanelProvider, useSandboxPanel, @@ -134,13 +136,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, }); @@ -164,6 +176,8 @@ const EMPTY_STREAM_STATE: ConvoStreamState = { model: null, }; +type SandboxSelection = "harness" | "none" | Id<"sandboxes">; + function ChatPage() { const navigate = useNavigate(); const { getToken } = useAuth(); @@ -175,12 +189,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 @@ -530,6 +547,20 @@ function ChatPage() { setSessionModel(null); }, [activeHarnessId, activeConvoId]); + useEffect(() => { + if ( + activeSandboxSelection === "harness" || + activeSandboxSelection === "none" || + !sandboxes + ) { + return; + } + + if (!sandboxes.some((sandbox) => sandbox._id === activeSandboxSelection)) { + setActiveSandboxSelection("harness"); + } + }, [activeSandboxSelection, sandboxes]); + useEffect(() => { if (harnesses && harnesses.length === 0) { navigate({ to: "/onboarding" }); @@ -576,6 +607,53 @@ 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) || (activeHarness?.sandboxEnabled ?? false); + + 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 @@ -708,36 +786,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, }); }; @@ -748,7 +802,7 @@ function ChatPage() { activeHarness, chatStream, sendMessageFromQueue, - sessionModel, + buildHarnessConfig, ]); const handleSelectConversation = useCallback( @@ -797,33 +851,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, @@ -831,7 +860,13 @@ function ChatPage() { conversation_id: activeConvoId, }); }, - [activeHarness, activeConvoId, chatStream, removeMessage, sessionModel], + [ + activeHarness, + activeConvoId, + chatStream, + removeMessage, + buildHarnessConfig, + ], ); const forkConversation = useMutation({ @@ -894,24 +929,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, }); @@ -928,7 +951,7 @@ function ChatPage() { editForkAndSend, handleSelectConversation, chatStream, - sessionModel, + buildHarnessConfig, ], ); @@ -945,11 +968,10 @@ function ChatPage() { ? chatStream.streamingConvoIds.has(activeConvoId) : false; - const sandboxEnabled = activeHarness?.sandboxEnabled ?? false; - const daytonaSandboxId = activeHarness?.daytonaSandboxId ?? null; - return ( - +
{sidebarOpen && ( @@ -982,6 +1004,10 @@ function ChatPage() { harness={activeHarness} harnesses={harnesses ?? []} onSwitchHarness={setActiveHarnessId} + sandboxes={sandboxes ?? []} + activeSandboxSelection={activeSandboxSelection} + onSwitchSandbox={setActiveSandboxSelection} + effectiveSandboxEnabled={effectiveSandboxEnabled} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} isStreaming={isActiveConvoStreaming} @@ -1095,6 +1121,8 @@ function ChatPage() { } }} onConvoCreated={handleSelectConversation} + sandboxEnabled={effectiveSandboxEnabled} + sandboxId={effectiveSandboxDaytonaId ?? undefined} isStreaming={isActiveConvoStreaming} onStream={chatStream.stream} onInterrupt={handleInterrupt} @@ -1110,7 +1138,7 @@ function ChatPage() {
- {sandboxEnabled && } + {effectiveSandboxEnabled && }
@@ -1506,6 +1534,17 @@ function ChatSidebar({
+ + + + onSwitchSandbox("harness")}> + {activeSandboxSelection === "harness" ? ( + + ) : ( + + )} +
+

+ Use default: {defaultSandboxName} +

+

+ {harness?.sandboxEnabled + ? (harness.daytonaSandboxId ?? "Auto-provision on chat") + : "Sandbox disabled on this harness"} +

+
+
+ onSwitchSandbox("none")}> + {activeSandboxSelection === "none" ? ( + + ) : ( + + )} +
+

No sandbox

+

+ Chat without sandbox tools +

+
+
+ {sandboxes.length > 0 && } + {sandboxes.map((sandbox) => ( + onSwitchSandbox(sandbox._id)} + > + {activeSandboxSelection === sandbox._id ? ( + + ) : ( + + )} +
+

{sandbox.name}

+

+ {sandbox.status} + {sandbox.ephemeral ? " ephemeral" : " persistent"} +

+
+
+ ))} +
+ + ); +} + /** Clickable sandbox badge in the header — toggles the sandbox panel. */ function SandboxBadge() { const panel = useSandboxPanel(); @@ -3026,6 +3243,8 @@ function ChatInput({ modelSelectorMode = "session", onSessionModelChange, onConvoCreated, + sandboxEnabled, + sandboxId, isStreaming, onStream, onInterrupt, @@ -3057,10 +3276,13 @@ function ChatInput({ persistent: boolean; autoStart: boolean; defaultLanguage: string; - resourceTier: string; + resourceTier: "basic" | "standard" | "performance"; + gitRepo?: string; }; }; onConvoCreated: (id: Id<"conversations">) => void; + sandboxEnabled: boolean; + sandboxId?: string; isStreaming: boolean; onStream: (body: { messages: Array<{ @@ -3077,7 +3299,16 @@ function ChatInput({ }>; skills: SkillEntry[]; name: string; + harness_id?: string; system_prompt?: string; + sandbox_enabled?: boolean; + sandbox_id?: string; + sandbox_config?: { + persistent: boolean; + auto_start: boolean; + default_language: string; + resource_tier: string; + }; }; conversation_id: string; }) => Promise; @@ -3103,10 +3334,13 @@ function ChatInput({ onPendingPromptConsumed?: () => void; budgetExceeded?: boolean; }) { + const { getToken } = useAuth(); const [text, setText] = useState(""); const textareaRef = useRef(null); const fileInputRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); + const [isPreparingSandbox, setIsPreparingSandbox] = useState(false); + const sandboxApi = useMemo(() => createSandboxApi(getToken), [getToken]); const effectiveModel = sessionModel ?? activeHarness?.model; const currentModelLabel = @@ -3219,12 +3453,9 @@ function ChatInput({ const handleSend = async () => { const content = text.trim(); - if (!content || !activeHarness || budgetExceeded) return; - - setText(""); - setHistoryIndex(-1); - setDraft(""); - clearAttachments(); + if (!content || !activeHarness || budgetExceeded || isPreparingSandbox) { + return; + } // If streaming, just enqueue — don't interrupt if (isStreaming && conversationId) { @@ -3232,6 +3463,35 @@ function ChatInput({ return; } + let resolvedSandboxId = sandboxId; + if (sandboxEnabled && !resolvedSandboxId) { + setIsPreparingSandbox(true); + try { + const config = activeHarness.sandboxConfig; + const sandbox = await sandboxApi.createSandbox({ + harnessId: activeHarness._id, + name: `${activeHarness.name} sandbox`, + language: config?.defaultLanguage ?? "python", + resourceTier: config?.resourceTier ?? "basic", + ephemeral: !(config?.persistent ?? false), + gitRepo: config?.gitRepo, + }); + resolvedSandboxId = sandbox.id; + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create sandbox", + ); + return; + } finally { + setIsPreparingSandbox(false); + } + } + + setText(""); + setHistoryIndex(-1); + setDraft(""); + clearAttachments(); + // Snapshot harness config at send time (convert to snake_case for FastAPI) const harnessConfig = { model: effectiveModel ?? activeHarness.model, @@ -3245,8 +3505,8 @@ function ChatInput({ name: activeHarness.name, harness_id: activeHarness._id, system_prompt: activeHarness.systemPrompt ?? undefined, - sandbox_enabled: activeHarness.sandboxEnabled ?? false, - sandbox_id: activeHarness.daytonaSandboxId ?? undefined, + sandbox_enabled: sandboxEnabled, + sandbox_id: resolvedSandboxId, sandbox_config: activeHarness.sandboxConfig ? { persistent: activeHarness.sandboxConfig.persistent, @@ -3645,12 +3905,19 @@ function ChatInput({ (budgetExceeded || !text.trim() || hasUploading || + isPreparingSandbox || sendMessage.isPending || createConvo.isPending) } variant={showStopButton ? "destructive" : "default"} > - {showStopButton ? : } + {isPreparingSandbox ? ( + + ) : showStopButton ? ( + + ) : ( + + )} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index c0fcf87..7af172c 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -524,7 +524,7 @@ function LandingNav() {
{isSignedIn ? ( +
+ ); + } + + return ( + + + + ); +} + +function SandboxDetailContent({ sandbox }: { sandbox: Sandbox }) { + const { getToken } = useAuth(); + const panel = useSandboxPanel(); + const [name, setName] = useState(sandbox.name); + const [workingDir, setWorkingDir] = useState("/home/daytona"); + + const sandboxApi = useMemo(() => createSandboxApi(getToken), [getToken]); + const updateSandboxFn = useConvexMutation(api.sandboxes.update); + const updateSandbox = useMutation({ + mutationFn: updateSandboxFn, + onSuccess: () => toast.success("Sandbox saved"), + onError: () => toast.error("Failed to save sandbox"), + }); + const updateSandboxStatus = useMutation({ + mutationFn: updateSandboxFn, + }); + const lifecycle = useMutation({ + mutationFn: async (next: "start" | "stop") => { + if (next === "start") { + return sandboxApi.startSandbox(sandbox.daytonaSandboxId); + } + return sandboxApi.stopSandbox(sandbox.daytonaSandboxId); + }, + onSuccess: (_, next) => { + updateSandboxStatus.mutate({ + id: sandbox._id, + status: next === "start" ? "running" : "stopped", + }); + toast.success(next === "start" ? "Sandbox started" : "Sandbox stopped"); + }, + onError: () => toast.error("Sandbox lifecycle action failed"), + }); + + const hasNameChanges = name.trim() !== "" && name !== sandbox.name; + const isRunning = sandbox.status === "running"; + + const openSandboxTool = (tab: SandboxTab) => { + panel?.setActiveTab(tab); + if (!panel?.panelOpen) panel?.togglePanel(); + }; + + const navigateToWorkingDir = () => { + if (!workingDir.trim()) return; + panel?.navigateTo(workingDir.trim()); + }; + + const saveMetadata = () => { + if (!hasNameChanges) return; + updateSandbox.mutate({ id: sandbox._id, name: name.trim() }); + }; + + return ( +
+
+
+
+ +
+
+

+ {name || sandbox.name} +

+ +
+

+ {sandbox.daytonaSandboxId} +

+
+
+
+ + +
+
+ +
+
+ +
+

+ Edit Sandbox +

+

+ Rename the sandbox here. Use the workspace tools to edit + files, create folders, run commands, inspect diffs, and commit + changes. +

+
+ +
+
+
+ + setName(e.target.value)} + placeholder="Sandbox name" + className="max-w-md" + /> +
+ +
+ +
+ setWorkingDir(e.target.value)} + placeholder="/home/daytona" + className="font-mono text-xs" + /> + +
+
+
+ + +
+
+ + + + +
+

+ Supported Edits +

+

+ These actions are supported directly because the backend + already exposes authenticated Daytona filesystem, command, and + git APIs. +

+
+ +
+ openSandboxTool("files")} + /> + openSandboxTool("terminal")} + /> + openSandboxTool("git")} + /> +
+
+
+
+
+ + {panel?.panelOpen && } +
+ ); +} + +function SandboxFacts({ sandbox }: { sandbox: Sandbox }) { + return ( +
+ + + + + + + {sandbox.gitRepo && ( + + )} +
+ ); +} + +function Fact({ + icon: Icon, + label, + value, +}: { + icon: typeof Archive; + label: string; + value: string; +}) { + return ( +
+ + {label} + {value} +
+ ); +} + +function EditCapability({ + icon: Icon, + title, + description, + action, + onClick, +}: { + icon: typeof Files; + title: string; + description: string; + action: string; + onClick: () => void; +}) { + return ( +
+
+ +

{title}

+
+

+ {description} +

+ +
+ ); +} + +function StatusBadge({ status }: { status: Sandbox["status"] }) { + const variant = status === "running" ? "default" : "secondary"; + return ( + + {status === "running" && } + {status} + + ); +} + +function DetailSkeleton() { + return ( +
+
+
+ +
+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/routes/sandboxes/create_sandbox.tsx b/apps/web/src/routes/sandboxes/create_sandbox.tsx new file mode 100644 index 0000000..cdc0bcd --- /dev/null +++ b/apps/web/src/routes/sandboxes/create_sandbox.tsx @@ -0,0 +1,268 @@ +import { useAuth } from "@clerk/tanstack-react-start"; +import { + createFileRoute, + Link, + redirect, + useNavigate, +} from "@tanstack/react-router"; +import { ArrowLeft, Cpu, HardDrive, Loader2, Play } from "lucide-react"; +import { motion } from "motion/react"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { Button } from "../../components/ui/button"; +import { Input } from "../../components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../components/ui/select"; +import { env } from "../../env"; + +const API_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000"; + +export const Route = createFileRoute("/sandboxes/create_sandbox")({ + beforeLoad: ({ context }) => { + if (!context.userId) { + throw redirect({ to: "/sign-in" }); + } + }, + component: RouteComponent, +}); + +function RouteComponent() { + const navigate = useNavigate(); + const { getToken } = useAuth(); + const [name, setName] = useState("New sandbox"); + const [isCreating, setIsCreating] = useState(false); + const [sandboxConfig, setSandboxConfig] = useState({ + persistent: false, + autoStart: true, + defaultLanguage: "python", + resourceTier: "basic" as "basic" | "standard" | "performance", + }); + + const handleCreate = async () => { + setIsCreating(true); + try { + const token = await getToken(); + const res = await fetch(`${API_URL}/api/sandbox`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + name: name.trim() || "New sandbox", + language: sandboxConfig.defaultLanguage, + resource_tier: sandboxConfig.resourceTier, + ephemeral: !sandboxConfig.persistent, + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Sandbox API error ${res.status}`); + } + + toast.success("Sandbox created"); + navigate({ to: "/sandboxes" }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create sandbox", + ); + } finally { + setIsCreating(false); + } + }; + + return ( +
+
+ +
+

+ Create Sandbox +

+

+ Configure your sandbox +

+
+
+ +
+
+
+ + setName(event.target.value)} + className="max-w-sm" + /> +
+ + + +
+ + +
+
+
+
+ ); +} + +function StepSandbox({ + config, + setConfig, +}: { + config: { + persistent: boolean; + autoStart: boolean; + defaultLanguage: string; + resourceTier: "basic" | "standard" | "performance"; + }; + setConfig: (v: { + persistent: boolean; + autoStart: boolean; + defaultLanguage: string; + resourceTier: "basic" | "standard" | "performance"; + }) => void; +}) { + return ( +
+

+ Create a sandbox for code execution, file management, terminal commands, + and git operations. +

+ + + {/* Sandbox type */} +
+ + Sandbox Type + +
+ + +
+
+ + {/* Resource tier */} +
+ + Resource Tier + + +
+ + {/* Default language */} +
+ + Default Language + + +
+
+
+ ); +} diff --git a/apps/web/src/routes/sandboxes/index.tsx b/apps/web/src/routes/sandboxes/index.tsx new file mode 100644 index 0000000..0be7312 --- /dev/null +++ b/apps/web/src/routes/sandboxes/index.tsx @@ -0,0 +1,556 @@ +import { convexQuery, useConvexMutation } 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 { useMutation, useQuery } from "@tanstack/react-query"; +import { + createFileRoute, + Link, + redirect, + useNavigate, +} from "@tanstack/react-router"; +import { + AlertTriangle, + Archive, + ArrowLeft, + Calendar, + Clock, + Code2, + Cpu, + Database, + Edit, + GitBranch, + HardDrive, + Hash, + MemoryStick, + MoreHorizontal, + Play, + Plus, + Square, + Trash2, +} from "lucide-react"; +import { motion } from "motion/react"; +import type { ComponentType } from "react"; +import { useState } from "react"; +import { HarnessMark } from "../../components/harness-mark"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { Card, CardContent } from "../../components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../../components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../../components/ui/dropdown-menu"; +import { Skeleton } from "../../components/ui/skeleton"; + +type Sandbox = Doc<"sandboxes">; +type SandboxStatus = Sandbox["status"]; + +export const Route = createFileRoute("/sandboxes/")({ + beforeLoad: ({ context }) => { + if (!context.userId) { + throw redirect({ to: "/sign-in" }); + } + }, + component: SandboxesPage, +}); + +function SandboxesPage() { + const navigate = useNavigate(); + const { data: sandboxes, isLoading } = useQuery( + convexQuery(api.sandboxes.list, {}), + ); + + const updateSandbox = useMutation({ + mutationFn: useConvexMutation(api.sandboxes.update), + }); + const removeSandbox = useMutation({ + mutationFn: useConvexMutation(api.sandboxes.remove), + }); + // const duplicateHarness = useMutation({ + // mutationFn: useConvexMutation(api.harnesses.duplicate), + // }) + + const [deleteTarget, setDeleteTarget] = useState | null>( + null, + ); + + if (isLoading) { + return ; + } + + // const handleDuplicate = (id: Id<"harnesses">) => { + // duplicateHarness.mutate( + // { id }, + // { onSuccess: () => toast.success("Harness duplicated") }, + // ) + // } + + const ephemeralSandboxes = sandboxes?.filter((s) => s.ephemeral) ?? []; + const persistentSandboxes = sandboxes?.filter((s) => !s.ephemeral) ?? []; + + const handleToggleStatus = (id: Id<"sandboxes">, current: SandboxStatus) => { + const newStatus = current === "stopped" ? "running" : "stopped"; + updateSandbox.mutate({ id, status: newStatus }); + }; + + const handleDelete = () => { + if (deleteTarget) { + removeSandbox.mutate({ id: deleteTarget }); + setDeleteTarget(null); + } + }; + + return ( +
+
+
+ +
+

+ Your Sandboxes +

+

+ {sandboxes?.length ?? 0} total +

+
+
+ +
+ +
+ {sandboxes?.length === 0 ? ( + + ) : ( +
+ {persistentSandboxes.length > 0 && ( + + navigate({ + to: "/sandboxes/$sandboxId", + params: { sandboxId: id }, + }) + } + /> + )} + {ephemeralSandboxes.length > 0 && ( + + navigate({ + to: "/sandboxes/$sandboxId", + params: { sandboxId: id }, + }) + } + /> + )} +
+ )} +
+ + setDeleteTarget(null)}> + + + Delete Sandbox + + This action cannot be undone. This will permanently delete the + sandbox record. + + + + + + + + + + +
+ ); +} + +function SandboxGroup({ + title, + sandboxes, + onToggle, + onDelete, + // onDuplicate, + onEdit, +}: { + title: string; + sandboxes: Array; + onToggle: (id: Id<"sandboxes">, status: SandboxStatus) => void; + onDelete: (id: Id<"sandboxes">) => void; + // onDuplicate: (id: Id<"sandboxes">) => void; + onEdit: (id: Id<"sandboxes">) => void; +}) { + return ( +
+

+ {title} +

+
+ {sandboxes.map((s, i) => ( + + + + ))} +
+
+ ); +} + +function SandboxCard({ + sandbox, + onToggle, + onDelete, + // onDuplicate, + onEdit, +}: { + sandbox: Sandbox; + onToggle: ( + id: Id<"sandboxes">, + status: + | "creating" + | "starting" + | "running" + | "stopping" + | "stopped" + | "archived" + | "error", + ) => void; + onDelete: (id: Id<"sandboxes">) => void; + // onDuplicate: (id: Id<"sandboxes">) => void; + onEdit: (id: Id<"sandboxes">) => void; +}) { + const isDraft = false; + const statusMeta = getStatusMeta(sandbox.status); + const sandboxType = sandbox.ephemeral ? "Ephemeral" : "Persistent"; + const language = formatLanguage(sandbox.language); + const createdAt = formatDate(sandbox.createdAt); + const lastAccessedAt = sandbox.lastAccessedAt + ? formatDate(sandbox.lastAccessedAt) + : "Never"; + const shortDaytonaId = shortenId(sandbox.daytonaSandboxId); + + return ( + + +
+
+
+
+

+ {sandbox.name} +

+
+
+ + + {statusMeta.label} + + + + {sandboxType} + +
+
+ + + + + + onEdit(sandbox._id)}> + + Edit + + {/* onDuplicate(sandbox._id)}> + + Duplicate + */} + {!isDraft && ( + onToggle(sandbox._id, sandbox.status)} + > + {sandbox.status === "running" ? ( + <> + + Stop + + ) : ( + <> + + Start + + )} + + )} + + onDelete(sandbox._id)} + > + + Delete + + + +
+ +
+ + + + +
+ +
+
+ + + Created + + {createdAt} +
+
+ + + Last used + + {lastAccessedAt} +
+ {sandbox.gitRepo && ( +
+ + + Repo + + + {formatRepoName(sandbox.gitRepo)} + +
+ )} +
+ + + Daytona ID + + + {shortDaytonaId} + +
+
+ + + ); +} + +function SandboxInfoItem({ + icon: Icon, + label, + value, +}: { + icon: ComponentType<{ size?: number; className?: string }>; + label: string; + value: string; +}) { + return ( +
+ +
+

{label}

+

{value}

+
+
+ ); +} + +function getStatusMeta(status: SandboxStatus): { + label: string; + dotClass: string; + badgeVariant: "secondary" | "outline" | "destructive"; + icon: ComponentType<{ size?: number }>; +} { + switch (status) { + case "running": + return { + label: "Running", + dotClass: "bg-emerald-500", + badgeVariant: "secondary", + icon: Play, + }; + case "stopped": + return { + label: "Stopped", + dotClass: "bg-muted-foreground/40", + badgeVariant: "outline", + icon: Square, + }; + case "archived": + return { + label: "Archived", + dotClass: "bg-muted-foreground/40", + badgeVariant: "outline", + icon: Archive, + }; + case "error": + return { + label: "Error", + dotClass: "bg-destructive", + badgeVariant: "destructive", + icon: AlertTriangle, + }; + case "creating": + return { + label: "Creating", + dotClass: "bg-amber-400", + badgeVariant: "secondary", + icon: Database, + }; + case "starting": + return { + label: "Starting", + dotClass: "bg-amber-400", + badgeVariant: "secondary", + icon: Play, + }; + case "stopping": + return { + label: "Stopping", + dotClass: "bg-amber-400", + badgeVariant: "secondary", + icon: Square, + }; + } +} + +function formatLanguage(language?: string) { + if (!language) return "Not set"; + return language.charAt(0).toUpperCase() + language.slice(1); +} + +function formatDate(timestamp: number) { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }).format(new Date(timestamp)); +} + +function shortenId(id: string) { + if (id.length <= 12) return id; + return `${id.slice(0, 6)}...${id.slice(-4)}`; +} + +function formatRepoName(repo: string) { + const trimmed = repo.replace(/\.git$/, "").replace(/\/$/, ""); + const parts = trimmed.split("/"); + return parts.slice(-2).join("/"); +} + +function EmptyState() { + return ( +
+
+ +
+

+ No sandboxes yet +

+

+ Create your first sandbox to equip your AI agent with sandboxes to + execute code and a local filesystem. +

+ +
+ ); +} + +function LoadingSkeleton() { + return ( +
+
+ + +
+
+
+ +
+ {["sk1", "sk2", "sk3"].map((key) => ( + + ))} +
+
+
+
+ ); +} diff --git a/apps/web/src/routes/sign-in.tsx b/apps/web/src/routes/sign-in.tsx index f81927e..0fb0ae2 100644 --- a/apps/web/src/routes/sign-in.tsx +++ b/apps/web/src/routes/sign-in.tsx @@ -64,7 +64,7 @@ function SignInPage() {
, + ): { workspaceId?: string } => ({ + workspaceId: (search.harnessId as string) ?? undefined, + }), + 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: "/chat", + }); + } + }, + component: ChatPage, +}); + +const FASTAPI_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000"; + +const SUGGESTED_PROMPTS = [ + "Help me write a Python script to process CSV files", + "Explain how WebSockets work in simple terms", + "Review my API design and suggest improvements", + "Create a deployment checklist for production", +]; + +const EMPTY_STREAM_STATE: ConvoStreamState = { + content: null, + reasoning: null, + toolCalls: [], + parts: [], + pendingDoneContent: null, + usage: null, + model: null, +}; + +function ChatPage() { + const navigate = useNavigate(); + const { getToken } = useAuth(); + const { harnessId: initialHarnessId } = Route.useSearch(); + + const { data: harnesses, isLoading: harnessesLoading } = useQuery( + convexQuery(api.harnesses.list, {}), + ); + const { data: conversations } = useQuery( + convexQuery(api.conversations.list, {}), + ); + const { data: userSettings } = useQuery( + convexQuery(api.userSettings.get, {}), + ); + + const [activeHarnessId, setActiveHarnessId] = + useState | null>(null); + const [activeConvoId, setActiveConvoId] = + useState | null>(null); + // Session-only model override — does not persist to the harness + const [sessionModel, setSessionModel] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(true); + const [pendingPrompt, setPendingPrompt] = useState(null); + const [editingMessageId, setEditingMessageId] = + useState | null>(null); + const [editingContent, setEditingContent] = useState(""); + + // Budget exceeded state + const [budgetExceeded, setBudgetExceeded] = + useState(null); + + // Per-conversation streaming state + const [streamStates, setStreamStates] = useState< + Record + >({}); + const streamStatesRef = useRef(streamStates); + useEffect(() => { + streamStatesRef.current = streamStates; + }, [streamStates]); + + // MCP server failures reported during stream start + type McpFailure = { + id: number; + serverName: string; + serverUrl: string; + reason: string; + }; + const [mcpFailures, setMcpFailures] = useState([]); + const mcpFailureIdRef = useRef(0); + + // MCP server health check statuses (keyed by server URL) + const [mcpHealthStatuses, setMcpHealthStatuses] = useState< + Record + >({}); + + // Track conversations that just finished streaming (show green checkmark briefly) + const [doneConvoIds, setDoneConvoIds] = useState>(new Set()); + const prevStreamingRef = useRef>(new Set()); + + // Lift messages query to ChatPage for queue processing + const { data: activeMessages } = useQuery( + convexQuery( + api.messages.list, + activeConvoId ? { conversationId: activeConvoId } : "skip", + ), + ); + const activeMessagesRef = useRef(activeMessages); + useEffect(() => { + activeMessagesRef.current = activeMessages; + }, [activeMessages]); + + // Message queue state + type QueueItem = { id: number; content: string }; + const [messageQueue, setMessageQueue] = useState([]); + const messageQueueRef = useRef([]); + const queueIdCounter = useRef(0); + const pendingQueueSendRef = useRef<{ + convoId: string; + content: string; + } | null>(null); + + const enqueueMessage = useCallback((content: string) => { + const item: QueueItem = { id: ++queueIdCounter.current, content }; + messageQueueRef.current = [...messageQueueRef.current, item]; + setMessageQueue([...messageQueueRef.current]); + }, []); + + const dequeueMessage = useCallback((index: number) => { + messageQueueRef.current = messageQueueRef.current.filter( + (_, i) => i !== index, + ); + setMessageQueue([...messageQueueRef.current]); + }, []); + + const shiftQueue = useCallback(() => { + const [next, ...rest] = messageQueueRef.current; + messageQueueRef.current = rest; + setMessageQueue(rest); + return next?.content; + }, []); + + // Clear queue and MCP failures on conversation switch + // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally resets queue when active conversation changes + useEffect(() => { + messageQueueRef.current = []; + setMessageQueue([]); + pendingQueueSendRef.current = null; + setMcpFailures([]); + }, [activeConvoId]); + + const updateHarness = useMutation({ + mutationFn: useConvexMutation(api.harnesses.update), + }); + + // Save interrupted assistant message from frontend + const saveInterruptedMsg = useMutation({ + mutationFn: useConvexMutation(api.messages.saveInterruptedMessage), + }); + + // Save user message (used for queue processing) + const sendMessageFromQueue = useMutation({ + 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, + ), + }, + }; + }); + }, + onMcpError: (_convoId, event) => { + setMcpFailures((prev) => [ + ...prev, + { + id: ++mcpFailureIdRef.current, + serverName: event.server_name, + serverUrl: event.server_url, + reason: event.reason, + }, + ]); + }, + 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) => { + toast.error(error); + setStreamStates((prev) => { + const next = { ...prev }; + delete next[convoId]; + return next; + }); + }, + onAbort: (convoId) => { + const state = streamStatesRef.current[convoId]; + + // If onDone already fired (pendingDoneContent is set), the backend already + // saved the message — don't save a duplicate interrupted copy. + if ( + state?.pendingDoneContent !== null && + state?.pendingDoneContent !== undefined + ) { + // Just process queued messages if any + if ( + !pendingQueueSendRef.current && + messageQueueRef.current.length > 0 + ) { + const next = shiftQueue(); + if (next) { + pendingQueueSendRef.current = { convoId, content: next }; + } + } + return; + } + + if ( + !state || + (!state.content && !state.reasoning && state.toolCalls.length === 0) + ) { + // Nothing accumulated — just clear state + setStreamStates((prev) => { + const next = { ...prev }; + delete next[convoId]; + return next; + }); + } else { + // Filter: only keep completed tool calls (those with results) + const completedToolCalls = state.toolCalls.filter( + (tc) => tc.result, + ) as Array<{ + tool: string; + arguments: Record; + call_id: string; + result: string; + }>; + const cleanedParts = state.parts.filter( + (p) => p.type !== "tool_call" || p.result, + ); + + 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; + + saveInterruptedMsg.mutate({ + conversationId: convoId as Id<"conversations">, + content: partialContent, + ...(state.reasoning ? { reasoning: state.reasoning } : {}), + ...(completedToolCalls.length > 0 + ? { toolCalls: completedToolCalls } + : {}), + ...(cleanedParts.length > 0 ? { parts: cleanedParts } : {}), + ...(state.usage ? { usage: state.usage } : {}), + ...(model ? { model } : {}), + }); + + // 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, + }, + })); + } + + // Process next queued message if any (skip if handleSendNow already set one) + if (!pendingQueueSendRef.current && messageQueueRef.current.length > 0) { + const next = shiftQueue(); + if (next) { + pendingQueueSendRef.current = { convoId, content: next }; + } + } + }, + }); + + useEffect(() => { + if (!harnesses || harnesses.length === 0) return; + + // If current selection is valid, keep it + if (activeHarnessId && harnesses.some((h) => h._id === activeHarnessId)) { + return; + } + + // Prefer the harness ID from the URL search param (e.g. after creating one) + if (initialHarnessId && harnesses.some((h) => h._id === initialHarnessId)) { + setActiveHarnessId(initialHarnessId as Id<"harnesses">); + return; + } + + // Fall back to a started harness, then the first one + const started = harnesses.find((h) => h.status === "started"); + setActiveHarnessId(started?._id ?? harnesses[0]._id); + }, [harnesses, activeHarnessId, initialHarnessId]); + + // Reset session model whenever the active harness or conversation changes + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional reset on harness/conversation switch + useEffect(() => { + setSessionModel(null); + }, [activeHarnessId, activeConvoId]); + + useEffect(() => { + if (harnesses && harnesses.length === 0) { + navigate({ to: "/onboarding" }); + } + }, [harnesses, navigate]); + + useEffect(() => { + const prev = prevStreamingRef.current; + const curr = chatStream.streamingConvoIds; + + for (const id of prev) { + if (!curr.has(id)) { + setDoneConvoIds((s) => new Set(s).add(id)); + setTimeout(() => { + setDoneConvoIds((s) => { + const next = new Set(s); + next.delete(id); + return next; + }); + }, 800); + } + } + + prevStreamingRef.current = new Set(curr); + }, [chatStream.streamingConvoIds]); + + const handleStreamSynced = useCallback( + (convoId: string) => { + setStreamStates((prev) => { + const next = { ...prev }; + delete next[convoId]; + return next; + }); + + // Process next queued message now that Convex has synced + if (messageQueueRef.current.length > 0) { + const next = shiftQueue(); + if (next) { + pendingQueueSendRef.current = { convoId, content: next }; + } + } + }, + [shiftQueue], + ); + + const activeHarness = harnesses?.find((h) => h._id === activeHarnessId); + + // Health-check MCP servers when harness changes + // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes + useEffect(() => { + if (!activeHarness || activeHarness.mcpServers.length === 0) { + setMcpHealthStatuses({}); + return; + } + + // Set all servers to "checking" + const checking: Record = {}; + for (const s of activeHarness.mcpServers) { + checking[s.url] = "checking"; + } + setMcpHealthStatuses(checking); + + let cancelled = false; + + const runCheck = 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, + }), + }); + + 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; + + 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 activeHarness.mcpServers) { + fallback[s.url] = "unreachable"; + } + setMcpHealthStatuses(fallback); + } + }; + + runCheck(); + return () => { + cancelled = true; + }; + }, [activeHarness?._id, getToken]); + + const handleInterrupt = useCallback( + (convoId: string) => { + chatStream.cancel(convoId); + }, + [chatStream], + ); + + const handleSendNow = useCallback( + (index: number) => { + if (!activeConvoId) return; + const item = messageQueueRef.current[index]; + if (!item) return; + // Remove this message from queue + messageQueueRef.current = messageQueueRef.current.filter( + (_, i) => i !== index, + ); + setMessageQueue([...messageQueueRef.current]); + // Set it as the pending send and interrupt + pendingQueueSendRef.current = { + convoId: activeConvoId, + content: item.content, + }; + chatStream.cancel(activeConvoId); + }, + [activeConvoId, chatStream], + ); + + // Process pending queued messages after stream ends + useEffect(() => { + const pending = pendingQueueSendRef.current; + if (!pending || !activeHarness) return; + + const convoId = pending.convoId; + // Wait until the conversation is no longer streaming + if (chatStream.streamingConvoIds.has(convoId)) return; + + pendingQueueSendRef.current = null; + + const run = async () => { + await sendMessageFromQueue.mutateAsync({ + conversationId: convoId as Id<"conversations">, + role: "user", + content: pending.content, + harnessId: activeHarness._id, + }); + + // Build history from current messages + the new user message + const msgs = activeMessagesRef.current ?? []; + const history = [ + ...msgs.map((m) => ({ role: m.role, content: m.content })), + { role: "user", content: pending.content }, + ]; + + 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, + }, + conversation_id: convoId, + }); + }; + + run(); + }, [ + chatStream.streamingConvoIds, + activeHarness, + chatStream, + sendMessageFromQueue, + sessionModel, + ]); + + const handleSelectConversation = useCallback( + (convoId: Id<"conversations"> | null) => { + setActiveConvoId(convoId); + + if ( + convoId && + userSettings?.autoSwitchHarness && + conversations && + harnesses + ) { + const convo = conversations.find((c) => c._id === convoId); + if ( + convo?.lastHarnessId && + harnesses.some((h) => h._id === convo.lastHarnessId) + ) { + setActiveHarnessId(convo.lastHarnessId); + } + } + }, + [userSettings, conversations, harnesses], + ); + + // State handlers for searching + const [scrollToMessageId, setScrollToMessageId] = + useState | null>(null); + const handleSelectMessage = useCallback( + (convoId: Id<"conversations">, messageId: Id<"messages">) => { + handleSelectConversation(convoId); + setScrollToMessageId(messageId); + }, + [handleSelectConversation], + ); + + const removeMessage = useMutation({ + mutationFn: useConvexMutation(api.messages.remove), + }); + + const handleRegenerate = useCallback( + async ( + messageId: Id<"messages">, + history: Array<{ role: string; content: string }>, + ) => { + if (!activeHarness || !activeConvoId) return; + + 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, + }; + + chatStream.stream({ + messages: history, + harness: harnessConfig, + conversation_id: activeConvoId, + }); + }, + [activeHarness, activeConvoId, chatStream, removeMessage, sessionModel], + ); + + const forkConversation = useMutation({ + mutationFn: useConvexMutation(api.conversations.fork), + }); + + const handleFork = useCallback( + async (messageId: Id<"messages">) => { + if (!activeConvoId) return; + const newConvoId = await forkConversation.mutateAsync({ + conversationId: activeConvoId, + upToMessageId: messageId, + }); + handleSelectConversation(newConvoId); + }, + [activeConvoId, forkConversation, handleSelectConversation], + ); + + const editForkAndSend = useMutation({ + mutationFn: useConvexMutation(api.conversations.editForkAndSend), + }); + const isEditSaving = useRef(false); + + const handleStartEditPrompt = useCallback( + (messageId: Id<"messages">, content: string) => { + setEditingMessageId(messageId); + setEditingContent(content); + }, + [], + ); + + const handleCancelEditPrompt = useCallback(() => { + setEditingMessageId(null); + setEditingContent(""); + }, []); + + const handleSaveEditPrompt = useCallback( + async (messageId: Id<"messages">, newContent: string) => { + if (!activeConvoId || !activeHarness || !activeMessages) return; + if (isEditSaving.current) return; + isEditSaving.current = true; + + try { + const idx = activeMessages.findIndex((m) => m._id === messageId); + if (idx === -1) return; + + // Atomic fork + message insert — no flicker + const newConvoId = await editForkAndSend.mutateAsync({ + conversationId: activeConvoId, + upToMessageCount: idx, + newContent, + harnessId: activeHarness._id, + }); + + handleSelectConversation(newConvoId); + + const history = activeMessages.slice(0, idx).map((m) => ({ + role: m.role, + content: m.content, + })); + history.push({ role: "user", content: newContent }); + + 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, + }, + conversation_id: newConvoId, + }); + + setEditingMessageId(null); + setEditingContent(""); + } finally { + isEditSaving.current = false; + } + }, + [ + activeConvoId, + activeHarness, + activeMessages, + editForkAndSend, + handleSelectConversation, + chatStream, + sessionModel, + ], + ); + + if (harnessesLoading || !harnesses || harnesses.length === 0) { + return ; + } + const activeConversation = conversations?.find( + (c) => c._id === activeConvoId, + ); + const activeStreamState = activeConvoId + ? (streamStates[activeConvoId] ?? EMPTY_STREAM_STATE) + : EMPTY_STREAM_STATE; + const isActiveConvoStreaming = activeConvoId + ? chatStream.streamingConvoIds.has(activeConvoId) + : false; + + const sandboxEnabled = activeHarness?.sandboxEnabled ?? false; + const daytonaSandboxId = activeHarness?.daytonaSandboxId ?? null; + + return ( + +
+ + {sidebarOpen && ( + + + !(c as Record).editParentConversationId, + )} + activeConvoId={activeConvoId} + onSelect={handleSelectConversation} + onSelectMessage={handleSelectMessage} + harnessId={activeHarnessId} + onClose={() => setSidebarOpen(false)} + streamingConvoIds={chatStream.streamingConvoIds} + doneConvoIds={doneConvoIds} + /> + + )} + + +
+ setSidebarOpen(!sidebarOpen)} + isStreaming={isActiveConvoStreaming} + mcpHealthStatuses={mcpHealthStatuses} + /> + + + setMcpFailures((prev) => prev.filter((f) => f.id !== id)) + } + onDismissAll={() => setMcpFailures([])} + /> + + {budgetExceeded && ( +
+
+

+ {budgetExceeded.dailyPct >= 100 ? "Daily" : "Weekly"} usage + limit reached +

+

+ Resets in{" "} + {budgetExceeded.dailyPct >= 100 + ? formatResetTime(budgetExceeded.dailyReset) + : formatResetTime(budgetExceeded.weeklyReset)} +

+
+ +
+ )} + + {activeConvoId ? ( + + c._id === activeConversation.forkedFromConversationId, + )?.title ?? "Original conversation") + : undefined + } + forkedAtMessageCount={activeConversation?.forkedAtMessageCount} + onNavigateToConversation={handleSelectConversation} + isStreaming={isActiveConvoStreaming} + scrollToMessageId={scrollToMessageId} + onClearScrollTarget={() => setScrollToMessageId(null)} + /> + ) : ( + setPendingPrompt(text)} + /> + )} + + { + if ( + userSettings?.modelSelectorMode === "harness" && + model !== null && + activeHarnessId && + model !== activeHarness?.model + ) { + updateHarness.mutate({ id: activeHarnessId, model }); + } else { + setSessionModel(model); + } + }} + onConvoCreated={handleSelectConversation} + isStreaming={isActiveConvoStreaming} + onStream={chatStream.stream} + onInterrupt={handleInterrupt} + onEnqueue={enqueueMessage} + messages={activeMessages} + messageQueue={messageQueue} + onDequeue={dequeueMessage} + onSendNow={handleSendNow} + pendingPrompt={pendingPrompt} + onPendingPromptConsumed={() => setPendingPrompt(null)} + budgetExceeded={!!budgetExceeded} + /> +
+ + + {sandboxEnabled && } + +
+
+ ); +} + +function HighlightText({ text, query }: { text: string; query: string }) { + if (!query) return <>{text}; + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + + let index = lowerText.indexOf(lowerQuery, lastIndex); + while (index !== -1) { + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)); + } + parts.push( + + {text.slice(index, index + query.length)} + , + ); + lastIndex = index + query.length; + index = lowerText.indexOf(lowerQuery, lastIndex); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return <>{parts}; +} + +function ChatSidebar({ + conversations, + activeConvoId, + onSelect, + onSelectMessage, // called when user clicks a content match + harnessId, + onClose, + streamingConvoIds, + doneConvoIds, +}: { + conversations: Array<{ + _id: Id<"conversations">; + title: string; + lastMessageAt: number; + lastHarnessId?: Id<"harnesses">; + }>; + activeConvoId: Id<"conversations"> | null; + onSelect: (id: Id<"conversations"> | null) => void; + onSelectMessage: ( + convoId: Id<"conversations">, + messageId: Id<"messages">, + ) => void; + harnessId: Id<"harnesses"> | null; + onClose: () => void; + streamingConvoIds: Set; + doneConvoIds: Set; +}) { + const removeConvo = useMutation({ + mutationFn: useConvexMutation(api.conversations.remove), + onSuccess: () => { + if (activeConvoId) onSelect(null); + }, + }); + + const handleNew = () => { + if (!harnessId) return; + onSelect(null); + }; + + const [searchQuery, setSearchQuery] = useState(""); + const [titlesExpanded, setTitlesExpanded] = useState(false); + const [contentExpanded, setContentExpanded] = useState(false); + + // consts to set initial amounts for how many search hits we show + // as well as max amounts for how many results we show after + // show more is pressed + const INITIAL_TITLE_COUNT = 10; + const INITIAL_CONTENT_COUNT = 15; + const LOAD_MORE_TITLE_COUNT = 100; + const LOAD_MORE_CONTENT_COUNT = 250; + + const titleSearch = usePaginatedQuery( + api.conversations.searchTitles, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + { initialNumItems: INITIAL_TITLE_COUNT }, + ); + + const contentSearch = usePaginatedQuery( + api.conversations.searchContent, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + { initialNumItems: INITIAL_CONTENT_COUNT }, + ); + + const { data: titleCount } = useQuery({ + ...convexQuery( + api.conversations.searchTitlesCount, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + ), + }); + + const { data: contentCount } = useQuery({ + ...convexQuery( + api.conversations.searchContentCount, + searchQuery.length > 0 ? { query: searchQuery } : "skip", + ), + }); + + const grouped = groupByDate(conversations); + + const [settingsOpen, setSettingsOpen] = useState(false); + const [usageOpen, setUsageOpen] = useState(false); + + return ( +
+
+ + + + WORKSPACES!!! + + +
+ + + + + New chat + + + + + + Close sidebar + +
+
+ + + + {/* Add input component connected to searchQuery state */} +
+
+ + { + setSearchQuery(e.target.value); + setTitlesExpanded(false); + setContentExpanded(false); + }} + className="h-8 pl-8 text-xs" + /> +
+
+ + + {/* BRANCH 1: Active search — show search results */} + {searchQuery && + titleSearch.status !== "LoadingFirstPage" && + contentSearch.status !== "LoadingFirstPage" ? ( +
+ {/* --- TITLE MATCHES SECTION --- */} + {titleSearch.results.length > 0 && ( +
+
+

+ Conversations +

+ {titlesExpanded ? ( + + ) : (titleCount ?? 0) > INITIAL_TITLE_COUNT ? ( + + ) : null} +
+ {(titlesExpanded + ? titleSearch.results + : titleSearch.results.slice(0, INITIAL_TITLE_COUNT) + ).map((convo) => ( + + ))} +
+ )} + + {/* --- CONTENT MATCHES SECTION --- */} + {contentSearch.results.length > 0 && ( +
+
+

+ Messages +

+ {contentExpanded ? ( + + ) : (contentCount ?? 0) > INITIAL_CONTENT_COUNT ? ( + + ) : null} +
+
+ {(contentExpanded + ? contentSearch.results + : contentSearch.results.slice(0, INITIAL_CONTENT_COUNT) + ).map((match) => ( + + ))} +
+
+ )} + + {/* --- NO RESULTS --- */} + {titleSearch.results.length === 0 && + contentSearch.results.length === 0 && ( +

+ No results found +

+ )} +
+ ) : /* BRANCH 2 & 3: Normal mode */ + conversations.length === 0 ? ( +

+ No conversations yet +

+ ) : ( +
+ {grouped.map((group) => ( +
+

+ {group.label} +

+ {group.items.map((convo) => ( +
+ + +
+ ))} +
+ ))} +
+ )} +
+ + +
+ setUsageOpen(true)} /> +
+ +
+ + +
+ + + +
+ ); +} + +function SettingsDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const { signOut, openUserProfile } = useClerk(); + const { user } = useUser(); + const navigate = useNavigate(); + const { data: userSettings } = useQuery( + convexQuery(api.userSettings.get, {}), + ); + const updateSettings = useMutation({ + mutationFn: useConvexMutation(api.userSettings.update), + }); + + const handleSignOut = async () => { + await signOut(); + navigate({ to: "/sign-in" }); + }; + + return ( + + + + Settings + Manage your preferences. + + +
+
+

+ Profile +

+
+ + + + {user?.firstName?.[0]} + {user?.lastName?.[0]} + + +
+

+ {user?.fullName} +

+

+ {user?.primaryEmailAddress?.emailAddress} +

+
+
+ +
+ + + +
+

+ Behavior +

+ +
+
+

+ Model selector +

+

+ Whether switching models in chat updates the session or the + harness. +

+
+ +
+
+ + + +
+

+ Display +

+
+
+

+ Message actions +

+

+ Controls which buttons appear on messages. +

+
+ +
+
+ + + + {/* workspaces selector option */} +
+

+ Advanced Layout: Workspaces +

+
+
+

+ Advanced Layout: Workspaces +

+

+ Controls whether the basic layout or the workspaces advanced + layout is in use +

+
+ +
+
+ + + +
+

+ Account +

+ +
+
+
+
+ ); +} + +function McpFailureBanner({ + failures, + onDismiss, + onDismissAll, +}: { + failures: Array<{ + id: number; + serverName: string; + serverUrl: string; + reason: string; + }>; + onDismiss: (id: number) => void; + onDismissAll: () => void; +}) { + if (failures.length === 0) return null; + + return ( + + +
+ +
+

+ {failures.length === 1 + ? "An MCP server failed to connect" + : `${failures.length} MCP servers failed to connect`} +

+
+ {failures.map((f) => ( + + + {f.serverName} + {f.reason === "auth_required" && ( + — OAuth required + )} + + + ))} +
+

+ Tools from these servers won't be available. Reconnect from the + MCP status menu above. +

+
+ +
+
+
+ ); +} + +function SkillsStatus({ skills }: { skills: SkillEntry[] }) { + const [open, setOpen] = useState(false); + const [viewingSkillId, setViewingSkillId] = useState(null); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (viewingSkillId) return; + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open, viewingSkillId]); + + if (skills.length === 0) return null; + + return ( +
+ + + + + Active skills + + + + {open && ( + +
+ + Skills + +
+
+ {skills.map((skill) => ( +
+ + + {skill.name.split("/").pop() ?? skill.name} + + +
+ ))} +
+
+ )} +
+ + setViewingSkillId(null)} + /> +
+ ); +} + +function ChatHeader({ + harness, + harnesses, + onSwitchHarness, + sidebarOpen, + onToggleSidebar, + isStreaming, + mcpHealthStatuses, +}: { + harness?: { + _id: Id<"harnesses">; + name: string; + model: string; + status: string; + mcpServers: Array<{ + name: string; + url: string; + authType: McpAuthType; + authToken?: string; + }>; + skills: SkillEntry[]; + sandboxEnabled?: boolean; + }; + harnesses: Array<{ + _id: Id<"harnesses">; + name: string; + model: string; + status: string; + }>; + onSwitchHarness: (id: Id<"harnesses">) => void; + sidebarOpen: boolean; + onToggleSidebar: () => void; + isStreaming: boolean; + mcpHealthStatuses?: Record; +}) { + return ( +
+
+ {!sidebarOpen && ( + + + + + Open sidebar + + )} + + + + + + + {harnesses + .filter((h) => h.status !== "draft") + .map((h) => ( + onSwitchHarness(h._id)} + > +
+ {h.name} + + {h.model} + + + ))} + + + + {harness && harness.mcpServers.length > 0 && ( + + )} + + {harness && harness.skills.length > 0 && ( + + )} + + {harness?.sandboxEnabled && } +
+
+ ); +} + +/** Clickable sandbox badge in the header — toggles the sandbox panel. */ +function SandboxBadge() { + const panel = useSandboxPanel(); + return ( + + + + + +

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

+
+
+ ); +} + +/** Renders the sandbox panel (animated) when open. */ +function SandboxPanelToggle() { + const panel = useSandboxPanel(); + if (!panel?.panelOpen) return null; + return ; +} + +function ChatMessages({ + conversationId, + messages, + streamingContent, + streamingReasoning, + activeToolCalls, + streamParts, + pendingDoneContent, + streamUsage, + streamModel, + onStreamSynced, + displayMode, + onRegenerate, + onFork, + onStartEditPrompt, + onCancelEditPrompt, + onSaveEditPrompt, + editingMessageId, + editingContent, + onEditContentChange, + allConversations, + activeConversation, + forkedFromConversationId, + forkedFromConversationTitle, + forkedAtMessageCount, + onNavigateToConversation, + isStreaming, + scrollToMessageId, + onClearScrollTarget, +}: { + conversationId: Id<"conversations">; + messages: Array<{ + _id: Id<"messages">; + role: "user" | "assistant"; + content: string; + reasoning?: string; + toolCalls?: Array<{ + tool: string; + arguments: unknown; + call_id: string; + result: string; + }>; + parts?: Array<{ + type: "text" | "reasoning" | "tool_call"; + content?: string; + tool?: string; + arguments?: unknown; + call_id?: string; + result?: string; + }>; + usage?: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + cost?: number; + }; + model?: string; + interrupted?: boolean; + attachments?: Array<{ + storageId: Id<"_storage">; + mimeType: string; + fileName: string; + fileSize: number; + }>; + }>; + streamingContent: string | null; + streamingReasoning: string | null; + activeToolCalls: ToolCallEvent[]; + streamParts: StreamPart[]; + pendingDoneContent: string | null; + streamUsage: UsageData | null; + streamModel: string | null; + onStreamSynced: (convoId: string) => void; + displayMode: DisplayMode; + onRegenerate: ( + messageId: Id<"messages">, + history: Array<{ role: string; content: string }>, + ) => void; + onFork: (messageId: Id<"messages">) => void; + onStartEditPrompt: (messageId: Id<"messages">, content: string) => void; + onCancelEditPrompt: () => void; + onSaveEditPrompt: (messageId: Id<"messages">, newContent: string) => void; + editingMessageId: Id<"messages"> | null; + editingContent: string; + onEditContentChange: (content: string) => void; + allConversations: Array<{ + _id: Id<"conversations">; + _creationTime: number; + editParentConversationId?: Id<"conversations">; + editParentMessageCount?: number; + }>; + activeConversation: + | { + _id: Id<"conversations">; + editParentConversationId?: Id<"conversations">; + editParentMessageCount?: number; + } + | undefined; + forkedFromConversationId?: Id<"conversations">; + forkedFromConversationTitle?: string; + forkedAtMessageCount?: number; + onNavigateToConversation: (convoId: Id<"conversations"> | null) => void; + isStreaming: boolean; + scrollToMessageId: Id<"messages"> | null; + onClearScrollTarget: () => void; +}) { + const scrollRef = useRef(null); + const highlightTimeoutRef = useRef>(null); + // "Pinned to bottom" — when true, auto-scroll follows new content. + // Unpins when the user scrolls up past a threshold; re-pins when they + // scroll back near the bottom or click the scroll-to-bottom button. + const [isPinned, setIsPinned] = useState(true); + const isPinnedRef = useRef(true); + // When true, suppress entrance animations and auto-scroll (set on conversation switches) + const skipNextTransition = useRef(false); + // Skip entry animation on conversation switches (but not the initial mount) + const prevConversationId = useRef(conversationId); + if (prevConversationId.current !== conversationId) { + prevConversationId.current = conversationId; + skipNextTransition.current = true; + // Re-pin when switching conversations + isPinnedRef.current = true; + setIsPinned(true); + } + // Snapshot captured at render time so animations can read it before the effect resets it + const skipEntryAnimation = skipNextTransition.current; + + // Unpin when the user scrolls up (wheel or touch). + // We detect intent directly via input events — not scroll position — + // because scroll-position checks race against programmatic auto-scroll. + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + let lastTouchY = 0; + + const unpin = () => { + isPinnedRef.current = false; + setIsPinned(false); + }; + const handleWheel = (e: WheelEvent) => { + if (e.deltaY < 0 && isPinnedRef.current && el.scrollTop > 0) { + unpin(); + } + }; + const handleTouchStart = (e: TouchEvent) => { + lastTouchY = e.touches[0].clientY; + }; + const handleTouchMove = (e: TouchEvent) => { + if ( + e.touches[0].clientY > lastTouchY && + isPinnedRef.current && + el.scrollTop > 0 + ) { + unpin(); + } + lastTouchY = e.touches[0].clientY; + }; + + el.addEventListener("wheel", handleWheel, { passive: true }); + el.addEventListener("touchstart", handleTouchStart, { passive: true }); + el.addEventListener("touchmove", handleTouchMove, { passive: true }); + return () => { + el.removeEventListener("wheel", handleWheel); + el.removeEventListener("touchstart", handleTouchStart); + el.removeEventListener("touchmove", handleTouchMove); + }; + }, []); + + const scrollToBottom = useCallback(() => { + if (!scrollRef.current) return; + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + isPinnedRef.current = true; + setIsPinned(true); + }, []); + + // Build a lookup map for O(1) ancestor traversal + const convoMap = useMemo(() => { + const map = new Map< + Id<"conversations">, + { + _id: Id<"conversations">; + _creationTime: number; + editParentConversationId?: Id<"conversations">; + editParentMessageCount?: number; + } + >(); + for (const c of allConversations) { + map.set(c._id, c); + } + return map; + }, [allConversations]); + + // Walk the ancestor chain to find, for a given message position i, + // the root conversation (base of the edit tree at that position) and + // the "version conversation" (which copy of message i the active + // conversation is showing — used to determine current page index). + const findEditAncestor = useCallback( + ( + convId: Id<"conversations">, + pos: number, + ): { rootId: Id<"conversations">; versionId: Id<"conversations"> } => { + let currentId = convId; + for (;;) { + const c = convoMap.get(currentId); + if (!c?.editParentConversationId) { + // No parent — this conversation is the root at this position + return { rootId: currentId, versionId: currentId }; + } + if (c.editParentMessageCount === pos) { + // Fork is exactly at this position — parent is the root + return { + rootId: c.editParentConversationId, + versionId: currentId, + }; + } + if ((c.editParentMessageCount ?? 0) > pos) { + // Fork is at a later position — content at pos came from parent + currentId = c.editParentConversationId; + } else { + // Fork is at an earlier position — content at pos is + // original to this conversation, so it is the root here + return { rootId: currentId, versionId: currentId }; + } + } + }, + [convoMap], + ); + + // Detect whether Convex has synced the assistant message (computed during render) + const lastMsg = messages?.[messages.length - 1]; + const convexHasMessage = + pendingDoneContent !== null && + lastMsg?.role === "assistant" && + lastMsg.content === pendingDoneContent; + const isActivelyStreaming = + streamingContent !== null || streamingReasoning !== null; + // Show the streaming bubble when we have content, reasoning, or tool calls, but Convex hasn't synced yet + const showStreamingBubble = + (streamingContent !== null || + streamingReasoning !== null || + activeToolCalls.length > 0) && + !convexHasMessage; + + // Clear streaming state once Convex has synced — fire in effect to avoid setState during render + useEffect(() => { + if (convexHasMessage) { + onStreamSynced(conversationId); + } + }, [convexHasMessage, onStreamSynced, conversationId]); + + // Re-pin when user sends a new message (they expect to see the response) + const messageCount = messages?.length ?? 0; + const lastMsgRole = messages?.[messages.length - 1]?.role; + // biome-ignore lint/correctness/useExhaustiveDependencies: only reset on new user message + useEffect(() => { + if (lastMsgRole === "user") { + isPinnedRef.current = true; + setIsPinned(true); + } + }, [messageCount]); + + // Auto-scroll: runs in useLayoutEffect (synchronous, before paint) so it + // can't race with wheel/touch events that fire after paint. Once the user + // unpins, this becomes a no-op until they re-pin. + // biome-ignore lint/correctness/useExhaustiveDependencies: scroll on new messages and streaming + useLayoutEffect(() => { + if (skipNextTransition.current) { + skipNextTransition.current = false; + return; + } + if (scrollRef.current && isPinnedRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [messages, streamingContent, streamingReasoning]); + + useEffect(() => { + if (!scrollToMessageId || !messages?.length) return; + + const el = document.querySelector( + `[data-message-id="${scrollToMessageId}"]`, + ); + if (!el) return; + + // Clear any previous highlight timeout + if (highlightTimeoutRef.current) clearTimeout(highlightTimeoutRef.current); + + el.scrollIntoView({ behavior: "smooth", block: "center" }); + // Add ring + yellow highlight + el.classList.add( + "ring-2", + "ring-primary", + "ring-offset-2", + "highlight-fade", + ); + + highlightTimeoutRef.current = setTimeout(() => { + el.classList.remove( + "ring-2", + "ring-primary", + "ring-offset-2", + "highlight-fade", + ); + highlightTimeoutRef.current = null; + }, 3000); + + onClearScrollTarget(); + }, [scrollToMessageId, messages, onClearScrollTarget]); + + if (messages.length === 0 && !isActivelyStreaming) { + return ( +
+

+ Send a message to start the conversation. +

+
+ ); + } + + return ( +
+
+ {(() => { + const lastUserMsgIdx = messages + ? messages.reduce( + (last, m, idx) => (m.role === "user" ? idx : last), + -1, + ) + : -1; + return messages?.map((msg, i) => { + // Skip entrance animation for the message that just replaced the streaming bubble + const isJustSynced = convexHasMessage && msg._id === lastMsg?._id; + const showForkBanner = + forkedFromConversationId !== undefined && + forkedAtMessageCount !== undefined && + i === forkedAtMessageCount - 1; + const { rootId: editRootId, versionId: editVersionId } = + msg.role === "user" && activeConversation + ? findEditAncestor(activeConversation._id, i) + : { rootId: undefined, versionId: undefined }; + const editSiblings = + editRootId !== undefined + ? allConversations.filter( + (c) => + c.editParentConversationId === editRootId && + c.editParentMessageCount === i, + ) + : []; + const editAllVersionIds = + editSiblings.length > 0 + ? [ + editRootId as Id<"conversations">, + ...[...editSiblings] + .sort((a, b) => a._creationTime - b._creationTime) + .map((c) => c._id), + ] + : []; + const editVersionIdx = + editAllVersionIds.length === 0 || editVersionId === undefined + ? -1 + : editAllVersionIds.indexOf(editVersionId); + return ( + + + {msg.role === "assistant" && ( + + + + + + )} +
+ {msg.role === "user" && + msg.attachments && + msg.attachments.length > 0 && ( + + )} +
+ {msg.role === "assistant" && + (msg as Record).parts ? ( + ( + (msg as Record).parts as Array<{ + type: "text" | "reasoning" | "tool_call"; + content?: string; + tool?: string; + arguments?: Record; + call_id?: string; + result?: string; + }> + ).map((part) => { + const key = + part.type === "tool_call" + ? (part.call_id ?? part.tool) + : `${part.type}-${part.content?.slice(0, 32)}`; + if (part.type === "reasoning" && part.content) { + return ( + + ); + } + if (part.type === "text" && part.content) { + return ( + + ); + } + if (part.type === "tool_call" && part.tool) { + return ( + + ); + } + return null; + }) + ) : ( + <> + {msg.role === "assistant" && msg.reasoning && ( + + )} + {msg.role === "assistant" ? ( + + ) : editingMessageId === msg._id ? ( +
+