From f967ea972c551382ef4785d0fd38f8d3a22e35cb Mon Sep 17 00:00:00 2001 From: Richard Wang Date: Wed, 1 Apr 2026 15:32:46 -0400 Subject: [PATCH 01/87] add initial implementation of harness creation helper. Created 'Create with AI' option which allows novice users to chat with AI to build a custom Harness. --- .../components/harness-creation-assistant.tsx | 500 ++++++++++++++++++ apps/web/src/routes/chat/index.tsx | 51 ++ apps/web/src/routes/harnesses/index.tsx | 52 +- apps/web/src/routes/onboarding.tsx | 24 +- .../convex-backend/convex/conversations.ts | 25 + packages/convex-backend/convex/schema.ts | 1 + packages/fastapi/app/main.py | 3 +- .../fastapi/app/routes/harness_suggest.py | 138 +++++ 8 files changed, 776 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/harness-creation-assistant.tsx create mode 100644 packages/fastapi/app/routes/harness_suggest.py diff --git a/apps/web/src/components/harness-creation-assistant.tsx b/apps/web/src/components/harness-creation-assistant.tsx new file mode 100644 index 0000000..fb0ccaa --- /dev/null +++ b/apps/web/src/components/harness-creation-assistant.tsx @@ -0,0 +1,500 @@ +import { useAuth } from "@clerk/tanstack-react-start"; +import { useConvexMutation } from "@convex-dev/react-query"; +import { api } from "@harness/convex-backend/convex/_generated/api"; +import type { Id } from "@harness/convex-backend/convex/_generated/dataModel"; +import { useMutation } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowRight, Lock, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { env } from "../env"; +import { PRESET_MCPS, presetIdsToServerEntries } from "../lib/mcp"; +import { MODELS } from "../lib/models"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./ui/dialog"; +import { Input } from "./ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./ui/select"; + +const FASTAPI_URL = env.VITE_FASTAPI_URL ?? "http://localhost:8000"; + +const INITIAL_MESSAGE = + "Hi! I'll help you set up a harness. What would you like it to help you with?"; + +interface ChatMessage { + id: string; + role: "user" | "assistant"; + content: string; +} + +interface HarnessConfigPreview { + name: string; + model: string; + mcpIds: string[]; +} + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function stripConfigBlock(content: string): string { + return content + .replace(/[\s\S]*?<\/harness-config>/, "") + .replace(/[\s\S]*$/, "") // strip partial block during streaming + .trim(); +} + +export function HarnessCreationAssistant({ open, onOpenChange }: Props) { + const navigate = useNavigate(); + const { getToken } = useAuth(); + + const [messages, setMessages] = useState([ + { id: "init", role: "assistant", content: INITIAL_MESSAGE }, + ]); + const [input, setInput] = useState(""); + const [isStreaming, setIsStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(""); + const [harnessConfig, setHarnessConfig] = + useState(null); + const [editedConfig, setEditedConfig] = useState({ + name: "", + model: "claude-sonnet-4", + mcpIds: [], + }); + const [convexConvoId, setConvexConvoId] = + useState | null>(null); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + const createSession = useMutation({ + mutationFn: useConvexMutation(api.conversations.createCreationSession), + }); + const linkToHarness = useMutation({ + mutationFn: useConvexMutation(api.conversations.linkToHarness), + }); + const sendMessage = useMutation({ + mutationFn: useConvexMutation(api.messages.send), + }); + const createHarness = useMutation({ + mutationFn: useConvexMutation(api.harnesses.create), + }); + + // Scroll to bottom when messages change + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, []); + + // Focus input when dialog opens + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 50); + } + }, [open]); + + const handleSend = async () => { + if (!input.trim() || isStreaming) return; + const userText = input.trim(); + setInput(""); + + const updatedMessages: ChatMessage[] = [ + ...messages, + { id: `user-${Date.now()}`, role: "user", content: userText }, + ]; + setMessages(updatedMessages); + setIsStreaming(true); + setStreamingContent(""); + + try { + // On first user message: create the Convex conversation and seed messages + let convoId = convexConvoId; + if (!convoId) { + convoId = await createSession.mutateAsync({ title: "Harness Setup" }); + setConvexConvoId(convoId); + await sendMessage.mutateAsync({ + conversationId: convoId, + role: "assistant", + content: INITIAL_MESSAGE, + }); + } + await sendMessage.mutateAsync({ + conversationId: convoId, + role: "user", + content: userText, + }); + + // Stream suggestion from FastAPI + const token = await getToken(); + const response = await fetch( + `${FASTAPI_URL}/api/harness/suggest/stream`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ + conversation_id: convoId, + messages: updatedMessages.map((m) => ({ + role: m.role, + content: m.content, + })), + }), + }, + ); + + if (!response.ok || !response.body) { + throw new Error(`Request failed: ${response.status}`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + let currentEvent = ""; + let fullContent = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + + for (const line of lines) { + if (line.startsWith("event: ")) { + currentEvent = line.slice(7).trim(); + continue; + } + if (line.startsWith("data: ")) { + try { + const data = JSON.parse(line.slice(6)); + if (currentEvent === "token" && data.content) { + fullContent += data.content; + setStreamingContent(fullContent); + } + } catch { + // skip malformed chunks + } + currentEvent = ""; + } + } + } + + // Extract block from complete response + const configMatch = fullContent.match( + /([\s\S]*?)<\/harness-config>/, + ); + let displayContent = stripConfigBlock(fullContent); + + if (configMatch) { + try { + const config = JSON.parse( + configMatch[1].trim(), + ) as HarnessConfigPreview; + setHarnessConfig(config); + setEditedConfig({ ...config }); + } catch { + // Config block was malformed — show the full response as-is + displayContent = fullContent; + } + } + + setMessages((prev) => [ + ...prev, + { + id: `asst-${Date.now()}`, + role: "assistant", + content: displayContent, + }, + ]); + } catch { + toast.error("Something went wrong. Please try again."); + setMessages((prev) => [ + ...prev, + { + id: `asst-err-${Date.now()}`, + role: "assistant", + content: "Sorry, I ran into an error. Please try again.", + }, + ]); + } finally { + setIsStreaming(false); + setStreamingContent(""); + } + }; + + const handleCreate = async () => { + const mcpServers = presetIdsToServerEntries(editedConfig.mcpIds); + try { + const harnessId = await createHarness.mutateAsync({ + name: editedConfig.name, + model: editedConfig.model, + status: "started", + mcpServers, + skills: [], + }); + + if (convexConvoId) { + linkToHarness.mutate({ + id: convexConvoId, + harnessId: harnessId as Id<"harnesses">, + }); + } + + onOpenChange(false); + navigate({ to: "/chat", search: { harnessId: harnessId as string } }); + } catch { + toast.error("Failed to create harness. Please try again."); + } + }; + + const handleEditManually = () => { + sessionStorage.setItem( + "harness-prefill", + JSON.stringify({ + name: editedConfig.name, + model: editedConfig.model, + selectedPresetMcps: editedConfig.mcpIds, + }), + ); + onOpenChange(false); + navigate({ to: "/onboarding" }); + }; + + const handleToggleMcp = (id: string) => { + setEditedConfig((prev) => ({ + ...prev, + mcpIds: prev.mcpIds.includes(id) + ? prev.mcpIds.filter((m) => m !== id) + : [...prev.mcpIds, id], + })); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + setMessages([ + { id: "init", role: "assistant", content: INITIAL_MESSAGE }, + ]); + setInput(""); + setIsStreaming(false); + setStreamingContent(""); + setHarnessConfig(null); + setEditedConfig({ name: "", model: "claude-sonnet-4", mcpIds: [] }); + setConvexConvoId(null); + } + onOpenChange(nextOpen); + }; + + const authRequiredMcps = editedConfig.mcpIds.filter((id) => { + const preset = PRESET_MCPS.find((p) => p.id === id); + return preset && preset.server.authType !== "none"; + }); + + return ( + + + + + Create with AI + + + + {/* Message list */} +
+ {messages.map((msg) => ( +
+
+ {msg.content} +
+
+ ))} + + {/* Streaming response */} + {isStreaming && ( +
+
+ {streamingContent ? ( + stripConfigBlock(streamingContent) || ( + + ) + ) : ( + + Thinking… + + )} +
+
+ )} + +
+
+ + {/* Config preview (shown after AI produces a config) */} + {harnessConfig && ( +
+

+ Review your harness +

+ +
+ {/* Name */} +
+ + + setEditedConfig((p) => ({ ...p, name: e.target.value })) + } + className="h-7 text-xs" + /> +
+ + {/* Model */} +
+

Model

+ +
+ + {/* MCP chips */} + {editedConfig.mcpIds.length > 0 && ( +
+

+ Integrations +

+
+ {editedConfig.mcpIds.map((id) => { + const preset = PRESET_MCPS.find((p) => p.id === id); + if (!preset) return null; + const needsAuth = preset.server.authType !== "none"; + return ( + + {needsAuth && ( + + )} + {preset.server.name} + + + ); + })} +
+ {authRequiredMcps.length > 0 && ( +

+ Integrations with {" "} + require sign-in after creation. +

+ )} +
+ )} +
+ + {/* Action buttons */} +
+ + +
+
+ )} + + {/* Input bar (hidden once config is shown) */} + {!harnessConfig && ( +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + placeholder="Describe what you want…" + className="h-8 text-xs" + disabled={isStreaming} + /> + +
+
+ )} + +
+ ); +} diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 450083c..bbb9bea 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -49,6 +49,7 @@ import React, { } from "react"; import toast from "react-hot-toast"; import { AttachmentChip } from "../../components/attachment-chip"; +import { HarnessCreationAssistant } from "../../components/harness-creation-assistant"; import { HarnessMark } from "../../components/harness-mark"; import { MarkdownMessage } from "../../components/markdown-message"; import { @@ -181,6 +182,7 @@ function ChatPage() { const [sessionModel, setSessionModel] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); const [pendingPrompt, setPendingPrompt] = useState(null); + const [creationAssistantOpen, setCreationAssistantOpen] = useState(false); const [editingMessageId, setEditingMessageId] = useState | null>(null); const [editingContent, setEditingContent] = useState(""); @@ -1024,6 +1026,8 @@ function ChatPage() { setPendingPrompt(text)} + hasNoHarnesses={!harnessesLoading && (harnesses?.length ?? 0) === 0} + onCreateWithAI={() => setCreationAssistantOpen(true)} /> )} @@ -1067,6 +1071,10 @@ function ChatPage() { {sandboxEnabled && } + ); } @@ -2884,15 +2892,58 @@ function StreamingUsage({ function EmptyChat({ suggestedPrompts, onPromptClick, + hasNoHarnesses, + onCreateWithAI, }: { suggestedPrompts?: string[]; onPromptClick: (text: string) => void; + hasNoHarnesses?: boolean; + onCreateWithAI?: () => void; }) { const prompts = suggestedPrompts && suggestedPrompts.length > 0 ? suggestedPrompts : SUGGESTED_PROMPTS; + if (hasNoHarnesses) { + return ( +
+ +
+ +
+

+ Create your first harness +

+

+ A harness equips your AI with tools and context. Let AI help you set one up. +

+
+ + + Create manually + +
+
+
+ ); + } + return (
| null>( null, ); + const [creationAssistantOpen, setCreationAssistantOpen] = useState(false); if (isLoading) { return ; @@ -123,17 +126,27 @@ function HarnessesPage() {

- +
+ + +
{harnesses?.length === 0 ? ( - + setCreationAssistantOpen(true)} /> ) : (
{active.length > 0 && ( @@ -185,6 +198,11 @@ function HarnessesPage() { )}
+ + setDeleteTarget(null)}> @@ -384,7 +402,7 @@ function HarnessCard({ ); } -function EmptyState() { +function EmptyState({ onCreateWithAI }: { onCreateWithAI: () => void }) { return (
@@ -397,12 +415,18 @@ function EmptyState() { Create your first harness to equip your AI agent with tools, MCPs, and skills.

- +
+ + +
); } diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index 11e91c5..e98a21c 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -77,8 +77,24 @@ const CONNECT_STEP = { key: "connect", label: "Connect", icon: Link2 }; function OnboardingPage() { const navigate = useNavigate(); - const [name, setName] = useState(""); - const [model, setModel] = useState(""); + // Read AI-generated prefill from sessionStorage (set by HarnessCreationAssistant) + const _prefill = (() => { + try { + const raw = sessionStorage.getItem("harness-prefill"); + if (raw) { + sessionStorage.removeItem("harness-prefill"); + return JSON.parse(raw) as { + name?: string; + model?: string; + selectedPresetMcps?: string[]; + }; + } + } catch {} + return null; + })(); + + const [name, setName] = useState(_prefill?.name ?? ""); + const [model, setModel] = useState(_prefill?.model ?? ""); const [customMcpServers, setCustomMcpServers] = useState( [], ); @@ -89,7 +105,9 @@ function OnboardingPage() { defaultLanguage: "python", resourceTier: "basic" as "basic" | "standard" | "performance", }); - const [selectedPresetMcps, setSelectedPresetMcps] = useState([]); + const [selectedPresetMcps, setSelectedPresetMcps] = useState( + _prefill?.selectedPresetMcps ?? [], + ); const [selectedSkills, setSelectedSkills] = useState([]); const [stepIndex, setStepIndex] = useState(0); diff --git a/packages/convex-backend/convex/conversations.ts b/packages/convex-backend/convex/conversations.ts index 4b254af..18277d4 100644 --- a/packages/convex-backend/convex/conversations.ts +++ b/packages/convex-backend/convex/conversations.ts @@ -51,6 +51,31 @@ export const create = mutation({ }, }); +export const createCreationSession = mutation({ + args: { title: v.string() }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Unauthenticated"); + return await ctx.db.insert("conversations", { + title: args.title, + userId: identity.subject, + lastMessageAt: Date.now(), + isCreationSession: true, + }); + }, +}); + +export const linkToHarness = mutation({ + args: { id: v.id("conversations"), harnessId: v.id("harnesses") }, + handler: async (ctx, args) => { + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Unauthenticated"); + const convo = await ctx.db.get(args.id); + if (!convo || convo.userId !== identity.subject) throw new Error("Not found"); + await ctx.db.patch(args.id, { lastHarnessId: args.harnessId, isCreationSession: undefined }); + }, +}); + export const updateTitle = mutation({ args: { id: v.id("conversations"), title: v.string() }, handler: async (ctx, args) => { diff --git a/packages/convex-backend/convex/schema.ts b/packages/convex-backend/convex/schema.ts index 150b3c8..7b8ecc1 100644 --- a/packages/convex-backend/convex/schema.ts +++ b/packages/convex-backend/convex/schema.ts @@ -84,6 +84,7 @@ export default defineSchema({ forkedAtMessageCount: v.optional(v.number()), editParentConversationId: v.optional(v.id("conversations")), editParentMessageCount: v.optional(v.number()), + isCreationSession: v.optional(v.boolean()), }) .index("by_user", ["userId"]) .index("by_user_last_message", ["userId", "lastMessageAt"]) diff --git a/packages/fastapi/app/main.py b/packages/fastapi/app/main.py index 3af1aca..502bf8c 100644 --- a/packages/fastapi/app/main.py +++ b/packages/fastapi/app/main.py @@ -6,7 +6,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.config import settings -from app.routes import chat, health, mcp_health, mcp_oauth, sandbox, terminal +from app.routes import chat, harness_suggest, health, mcp_health, mcp_oauth, sandbox, terminal logging.basicConfig( level=logging.INFO, @@ -63,6 +63,7 @@ async def lifespan(app: FastAPI): app.include_router(health.router) app.include_router(chat.router, prefix="/api/chat", tags=["chat"]) +app.include_router(harness_suggest.router, prefix="/api/harness/suggest", tags=["harness-suggest"]) app.include_router(mcp_oauth.router, prefix="/api/mcp/oauth", tags=["mcp-oauth"]) app.include_router(mcp_health.router, prefix="/api/mcp/health", tags=["mcp-health"]) app.include_router(sandbox.router, prefix="/api/sandbox", tags=["sandbox"]) diff --git a/packages/fastapi/app/routes/harness_suggest.py b/packages/fastapi/app/routes/harness_suggest.py new file mode 100644 index 0000000..e796ced --- /dev/null +++ b/packages/fastapi/app/routes/harness_suggest.py @@ -0,0 +1,138 @@ +import json +import logging + +import httpx +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel +from sse_starlette.sse import EventSourceResponse + +from app.config import MODEL_MAP +from app.dependencies import get_current_user, get_http_client +from app.services.convex import save_assistant_message +from app.services.openrouter import stream_chat + +router = APIRouter() +logger = logging.getLogger(__name__) + +# Mirrors frontend PRESET_MCPS — kept in sync manually. +_PRESET_MCP_CATALOG = [ + {"id": "princetoncourses", "name": "Princeton Courses", "description": "Search Princeton courses, read evaluations, and explore instructors.", "auth": "tiger_junction"}, + {"id": "tigerjunction", "name": "TigerJunction", "description": "Manage course schedules — create, edit, verify conflicts.", "auth": "tiger_junction"}, + {"id": "tigersnatch", "name": "TigerSnatch", "description": "Track course demand and subscribe to enrollment notifications.", "auth": "tiger_junction"}, + {"id": "github", "name": "GitHub", "description": "Browse repos, manage issues and pull requests, and search code.", "auth": "oauth"}, + {"id": "notion", "name": "Notion", "description": "Read and write pages, databases, and blocks in your workspace.", "auth": "oauth"}, + {"id": "linear", "name": "Linear", "description": "Create and track issues, manage projects, and streamline engineering workflows.", "auth": "oauth"}, + {"id": "slack", "name": "Slack", "description": "Send messages, read channel history, and search conversations.", "auth": "oauth"}, + {"id": "jira", "name": "Jira", "description": "Create tickets, track sprints, and manage Agile releases.", "auth": "oauth"}, + {"id": "awsknowledge", "name": "AWS Knowledge", "description": "Search AWS documentation and knowledge bases for services and best practices.", "auth": "none"}, + {"id": "exa", "name": "Exa", "description": "AI-powered semantic web search and content retrieval.", "auth": "none"}, + {"id": "context7", "name": "Context7", "description": "Fetch up-to-date library docs and code examples for any framework.", "auth": "none"}, +] + +_AVAILABLE_MODELS = list(MODEL_MAP.keys()) + +_CREATION_SYSTEM_PROMPT = None + + +def _get_system_prompt() -> str: + global _CREATION_SYSTEM_PROMPT + if _CREATION_SYSTEM_PROMPT is not None: + return _CREATION_SYSTEM_PROMPT + + models_text = "\n".join(f" - {m}" for m in _AVAILABLE_MODELS) + + mcps_lines = [] + for mcp in _PRESET_MCP_CATALOG: + auth_note = f" [requires {mcp['auth']} sign-in after creation]" if mcp["auth"] != "none" else "" + mcps_lines.append(f" - id={mcp['id']!r}, name={mcp['name']!r}: {mcp['description']}{auth_note}") + mcps_text = "\n".join(mcps_lines) + + _CREATION_SYSTEM_PROMPT = f"""You are a friendly assistant that helps users set up an AI "Harness" — a named AI agent configuration with a chosen model and optional tool integrations. + +Your goal: ask a few focused questions to understand the user's use case, then recommend a harness configuration. + +Keep responses short and conversational. After 1–3 exchanges you should have enough information to produce a config. Do not ask about sandboxes or skills. + +## Available models +{models_text} + +Defaults: recommend "claude-sonnet-4" for general use, "gpt-4.1-mini" for quick/lightweight tasks, "gemini-2.5-pro" for long-context or multimodal tasks. + +## Available MCP integrations (tools the agent can use) +{mcps_text} + +Only suggest MCPs that are clearly relevant to the user's stated use case. Leave mcpIds as [] if no tools are needed. + +## When you have gathered enough information + +Output a brief summary sentence, then immediately output the harness config block — no other text after the block: + + +{{ + "name": "Short Harness Name", + "model": "exact-model-id", + "mcpIds": ["id1", "id2"] +}} + + +Rules: +- Use only exact model IDs from the list above. +- Use only exact MCP ids from the list above. +- Keep the name to 2–4 words. +- Do not include trailing text after the closing tag.""" + + return _CREATION_SYSTEM_PROMPT + + +class _Message(BaseModel): + role: str + content: str + + +class SuggestRequest(BaseModel): + conversation_id: str + messages: list[_Message] + + +@router.post("/stream") +async def suggest_harness_stream( + request: Request, + body: SuggestRequest, + http_client: httpx.AsyncClient = Depends(get_http_client), + user: dict = Depends(get_current_user), +): + async def event_generator(): + messages = [{"role": "system", "content": _get_system_prompt()}] + messages.extend({"role": m.role, "content": m.content} for m in body.messages) + + collected_content = "" + + try: + async for chunk in stream_chat(http_client, messages, "claude-sonnet-4"): + if await request.is_disconnected(): + return + + if chunk.get("type") == "done": + break + + choices = chunk.get("choices", []) + if not choices: + continue + + delta = choices[0].get("delta", {}) + if delta.get("content"): + collected_content += delta["content"] + yield { + "event": "token", + "data": json.dumps({"content": delta["content"]}), + } + + except Exception: + logger.exception("Error in harness suggestion stream for conversation '%s'", body.conversation_id) + yield {"event": "error", "data": json.dumps({"message": "Internal server error"})} + return + + await save_assistant_message(http_client, body.conversation_id, collected_content) + yield {"event": "done", "data": json.dumps({"content": collected_content})} + + return EventSourceResponse(event_generator()) From 8969e1bc55c84055d0caf4aa8557ec6e0c00be53 Mon Sep 17 00:00:00 2001 From: jon3350 Date: Wed, 1 Apr 2026 16:52:43 -0400 Subject: [PATCH 02/87] made ui changes for harness logo, creating harness page, etc. --- apps/web/src/routes/chat/index.tsx | 94 ++++++++------- apps/web/src/routes/harnesses/$harnessId.tsx | 63 ++++++++-- apps/web/src/routes/onboarding.tsx | 119 +++++++++++++++---- 3 files changed, 205 insertions(+), 71 deletions(-) diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 450083c..39965ad 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -26,6 +26,7 @@ import { PanelLeftOpen, Paperclip, Plus, + RotateCcw, Search, // Icon for search Settings, SlidersHorizontal, @@ -449,7 +450,8 @@ function ChatPage() { const partialContent = state.content ?? ""; // model is only sent in the "done" event which doesn't fire on abort, // so fall back to the session model, then the harness model - const model = state.model ?? sessionModel ?? activeHarness?.model ?? null; + const model = + state.model ?? sessionModel ?? activeHarness?.model ?? null; saveInterruptedMsg.mutate({ conversationId: convoId as Id<"conversations">, @@ -708,16 +710,14 @@ function ChatPage() { name: activeHarness.name, harness_id: activeHarness._id, - sandbox_enabled: (activeHarness as any).sandboxEnabled ?? false, - sandbox_id: (activeHarness as any).daytonaSandboxId ?? undefined, - sandbox_config: (activeHarness as any).sandboxConfig + sandbox_enabled: activeHarness.sandboxEnabled ?? false, + sandbox_id: activeHarness.daytonaSandboxId ?? undefined, + sandbox_config: activeHarness.sandboxConfig ? { - persistent: (activeHarness as any).sandboxConfig.persistent, - auto_start: (activeHarness as any).sandboxConfig.autoStart, - default_language: (activeHarness as any).sandboxConfig - .defaultLanguage, - resource_tier: (activeHarness as any).sandboxConfig - .resourceTier, + persistent: activeHarness.sandboxConfig.persistent, + auto_start: activeHarness.sandboxConfig.autoStart, + default_language: activeHarness.sandboxConfig.defaultLanguage, + resource_tier: activeHarness.sandboxConfig.resourceTier, } : undefined, }, @@ -795,15 +795,14 @@ function ChatPage() { skills: activeHarness.skills ?? [], name: activeHarness.name, harness_id: activeHarness._id, - sandbox_enabled: (activeHarness as any).sandboxEnabled ?? false, - sandbox_id: (activeHarness as any).daytonaSandboxId ?? undefined, - sandbox_config: (activeHarness as any).sandboxConfig + sandbox_enabled: activeHarness.sandboxEnabled ?? false, + sandbox_id: activeHarness.daytonaSandboxId ?? undefined, + sandbox_config: activeHarness.sandboxConfig ? { - persistent: (activeHarness as any).sandboxConfig.persistent, - auto_start: (activeHarness as any).sandboxConfig.autoStart, - default_language: (activeHarness as any).sandboxConfig - .defaultLanguage, - resource_tier: (activeHarness as any).sandboxConfig.resourceTier, + persistent: activeHarness.sandboxConfig.persistent, + auto_start: activeHarness.sandboxConfig.autoStart, + default_language: activeHarness.sandboxConfig.defaultLanguage, + resource_tier: activeHarness.sandboxConfig.resourceTier, } : undefined, }; @@ -927,8 +926,8 @@ function ChatPage() { ? chatStream.streamingConvoIds.has(activeConvoId) : false; - const sandboxEnabled = (activeHarness as any)?.sandboxEnabled ?? false; - const daytonaSandboxId = (activeHarness as any)?.daytonaSandboxId ?? null; + const sandboxEnabled = activeHarness?.sandboxEnabled ?? false; + const daytonaSandboxId = activeHarness?.daytonaSandboxId ?? null; return ( @@ -1031,7 +1030,9 @@ function ChatPage() { conversationId={activeConvoId} activeHarness={activeHarness} sessionModel={ - userSettings?.modelSelectorMode === "harness" ? null : sessionModel + userSettings?.modelSelectorMode === "harness" + ? null + : sessionModel } modelSelectorMode={ (userSettings?.modelSelectorMode as "session" | "harness") ?? @@ -1187,12 +1188,12 @@ function ChatSidebar({ return (
-
+ Harness -
+
@@ -1584,9 +1585,7 @@ function SettingsDialog({

@@ -570,9 +587,9 @@ function HarnessEditPage() { {/* Default language */}
- + @@ -929,9 +1008,9 @@ function StepSandbox({ {/* Default language */}
- +
-
+ ); } diff --git a/apps/web/src/components/sandbox/file-viewer.tsx b/apps/web/src/components/sandbox/file-viewer.tsx index e2bb8c1..d1ca8c2 100644 --- a/apps/web/src/components/sandbox/file-viewer.tsx +++ b/apps/web/src/components/sandbox/file-viewer.tsx @@ -297,8 +297,8 @@ function FileContent({ className="sticky left-0 shrink-0 select-none border-r border-border/60 bg-muted/15 px-2 py-1.5 text-right font-mono text-[10.5px] leading-[1.65] text-muted-foreground/20" aria-hidden > - {lines.map((_, i) => ( -
{i + 1}
+ {lines.map((line, i) => ( +
{i + 1}
))}
@@ -316,7 +316,10 @@ function FileContent({ /> ) : highlighted ? (
-								
+								
 							
) : (
diff --git a/apps/web/src/components/sandbox/sandbox-panel.tsx b/apps/web/src/components/sandbox/sandbox-panel.tsx
index 5a776a5..082351f 100644
--- a/apps/web/src/components/sandbox/sandbox-panel.tsx
+++ b/apps/web/src/components/sandbox/sandbox-panel.tsx
@@ -108,8 +108,10 @@ export function SandboxPanel() {
 			className="relative flex h-full flex-col overflow-hidden border-l border-border bg-background"
 		>
 			{/* Resize handle */}
-			
diff --git a/apps/web/src/components/skill-viewer-dialog.tsx b/apps/web/src/components/skill-viewer-dialog.tsx new file mode 100644 index 0000000..8d5dc77 --- /dev/null +++ b/apps/web/src/components/skill-viewer-dialog.tsx @@ -0,0 +1,101 @@ +import { convexQuery, useConvexAction } from "@convex-dev/react-query"; +import { api } from "@harness/convex-backend/convex/_generated/api"; +import { useQuery } from "@tanstack/react-query"; +import { Download, Loader2 } from "lucide-react"; +import { useCallback, useRef } from "react"; +import { MarkdownMessage } from "./markdown-message"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; + +interface SkillViewerDialogProps { + /** The full skill ID, e.g. "vercel-labs/agent-skills/vercel-react-best-practices" */ + fullId: string | null; + /** Short display name */ + skillId?: string; + /** Source repo path */ + source?: string; + /** Install count */ + installs?: number; + onClose: () => void; +} + +export function SkillViewerDialog({ + fullId, + skillId, + source, + installs, + onClose, +}: SkillViewerDialogProps) { + const ensureSkillDetailsFn = useConvexAction(api.skills.ensureSkillDetails); + const ensuredRef = useRef(new Set()); + + const detailQuery = useQuery({ + ...convexQuery(api.skills.getByName, { name: fullId ?? "" }), + enabled: !!fullId, + }); + + // Fire-and-forget ensure on first open + if (fullId && !ensuredRef.current.has(fullId)) { + ensuredRef.current.add(fullId); + ensureSkillDetailsFn({ names: [fullId] }).catch(() => {}); + } + + const formatInstalls = useCallback((n: number) => { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return n.toString(); + }, []); + + const displayName = skillId ?? fullId?.split("/").pop() ?? ""; + const displaySource = + source ?? (fullId ? fullId.split("/").slice(0, -1).join("/") : ""); + + return ( + { + if (!open) onClose(); + }} + > + + + {displayName} + + {displaySource} + {installs != null && installs > 0 && ( + + + {formatInstalls(installs)} + + )} + + +
+ {detailQuery.isLoading || !detailQuery.data?.detail ? ( +
+ + + Fetching skill documentation... + +
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/src/components/skills-browser.tsx b/apps/web/src/components/skills-browser.tsx index daf3f6f..a1953ae 100644 --- a/apps/web/src/components/skills-browser.tsx +++ b/apps/web/src/components/skills-browser.tsx @@ -352,23 +352,17 @@ export function SkillsBrowser({ {skill.source}

- { e.stopPropagation(); handleViewSkill(skill); }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.stopPropagation(); - handleViewSkill(skill); - } - }} - className="mt-0.5 shrink-0 text-muted-foreground/40 transition-colors hover:text-foreground" + className="mt-0.5 shrink-0 border-0 bg-transparent p-0 text-muted-foreground/40 transition-colors hover:text-foreground" > - + ); })} diff --git a/apps/web/src/lib/harness-types.ts b/apps/web/src/lib/harness-types.ts new file mode 100644 index 0000000..b65fb1b --- /dev/null +++ b/apps/web/src/lib/harness-types.ts @@ -0,0 +1,30 @@ +313; /** + * Sandbox-related fields on the harness document. + * These exist in the Convex schema but are missing from the stale generated types. + * Remove this file once `npx convex dev` regenerates the types. + */ +export interface HarnessSandboxFields { + sandboxEnabled?: boolean; + daytonaSandboxId?: string; + sandboxConfig?: { + persistent: boolean; + autoStart: boolean; + defaultLanguage: string; + resourceTier: "basic" | "standard" | "performance"; + snapshotId?: string; + gitRepo?: string; + networkRestricted?: boolean; + }; +} + +/** Helper to extract sandbox config as the snake_case shape expected by the API. */ +export function toSandboxApiConfig(h: HarnessSandboxFields) { + return h.sandboxConfig + ? { + persistent: h.sandboxConfig.persistent, + auto_start: h.sandboxConfig.autoStart, + default_language: h.sandboxConfig.defaultLanguage, + resource_tier: h.sandboxConfig.resourceTier, + } + : undefined; +} diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx index 39965ad..af835d0 100644 --- a/apps/web/src/routes/chat/index.tsx +++ b/apps/web/src/routes/chat/index.tsx @@ -18,6 +18,7 @@ import { ChevronDown, ChevronRight, Cpu, + Eye, Loader2, LogOut, MessageSquare, @@ -65,6 +66,7 @@ import { import { MessageAttachments } from "../../components/message-attachments"; import { SandboxPanel } from "../../components/sandbox/sandbox-panel"; import { SandboxResult } from "../../components/sandbox-result"; +import { SkillViewerDialog } from "../../components/skill-viewer-dialog"; import { Avatar, AvatarFallback, @@ -1735,42 +1737,83 @@ function McpFailureBanner({ } 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 - - - - -
- - Skills - -
-
- {skills.map((skill) => ( - - - {skill.name.split("/").pop() ?? skill.name} +
+ + + + + Active skills + + + + {open && ( + +
+ + Skills - - ))} -
- - +
+
+ {skills.map((skill) => ( +
+ + + {skill.name.split("/").pop() ?? skill.name} + + +
+ ))} +
+ + )} + + + setViewingSkillId(null)} + /> +
); } @@ -1874,7 +1917,7 @@ function ChatHeader({ )} - {harness && harness.sandboxEnabled && } + {harness?.sandboxEnabled && }
); diff --git a/apps/web/src/routes/harnesses/$harnessId.tsx b/apps/web/src/routes/harnesses/$harnessId.tsx index 1678293..9376673 100644 --- a/apps/web/src/routes/harnesses/$harnessId.tsx +++ b/apps/web/src/routes/harnesses/$harnessId.tsx @@ -40,6 +40,7 @@ import { OAuthConnectRow } from "../../components/mcp-oauth-connect-row"; import { PresetMcpGrid } from "../../components/preset-mcp-grid"; import { PrincetonConnectRow } from "../../components/princeton-connect-row"; import { RecommendedSkillsGrid } from "../../components/recommended-skills-grid"; +import { SkillViewerDialog } from "../../components/skill-viewer-dialog"; import { SkillsBrowser } from "../../components/skills-browser"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -157,6 +158,7 @@ function HarnessEditPage() { const [mcpServers, setMcpServers] = useState(null); const [skills, setSkills] = useState(null); const [skillsBrowserOpen, setSkillsBrowserOpen] = useState(false); + const [viewingSkillId, setViewingSkillId] = useState(null); const [sandboxEnabled, setSandboxEnabled] = useState(null); const [sandboxConfig, setSandboxConfig] = useState<{ persistent: boolean; @@ -184,9 +186,9 @@ function HarnessEditPage() { }; const currentSandboxEnabled = - sandboxEnabled ?? (harness as any)?.sandboxEnabled ?? false; + sandboxEnabled ?? harness?.sandboxEnabled ?? false; const currentSandboxConfig = sandboxConfig ?? - (harness as any)?.sandboxConfig ?? { + harness?.sandboxConfig ?? { persistent: false, autoStart: true, defaultLanguage: "python", @@ -459,33 +461,29 @@ function HarnessEditPage() {
{/* Enable toggle */} -
setSandboxEnabled(!currentSandboxEnabled)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") - setSandboxEnabled(!currentSandboxEnabled); - }} - role="checkbox" - aria-checked={currentSandboxEnabled} - tabIndex={0} - > +
setSandboxEnabled(checked === true) } /> -
-

- Enable sandbox for this harness -

-

- Gives this harness access to code execution, file system, - terminal, and git operations in an isolated environment -

-
- +
{currentSandboxEnabled && ( @@ -640,32 +638,9 @@ function HarnessEditPage() { className="max-w-sm text-xs" />
-
- setSandboxConfig({ - ...currentSandboxConfig, - networkRestricted: !( - currentSandboxConfig.networkRestricted ?? false - ), - }) - } - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") - setSandboxConfig({ - ...currentSandboxConfig, - networkRestricted: !( - currentSandboxConfig.networkRestricted ?? false - ), - }); - }} - role="checkbox" - aria-checked={ - currentSandboxConfig.networkRestricted ?? false - } - tabIndex={0} - > +
-
-

- Restrict network access -

-

- Block all outbound network traffic from the sandbox -

-
- +
@@ -775,9 +756,23 @@ function HarnessEditPage() { {displayName}

+ ); })} + setViewingSkillId(null)} + />
)} diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index b2ca92e..665571c 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -207,7 +207,7 @@ function OnboardingPage() { createHarness.mutate({ name: name.trim(), model, - status: "started", + status: "started" as const, mcpServers: allMcpServers, skills: selectedSkills, sandboxEnabled: sandboxEnabled || undefined, @@ -219,7 +219,7 @@ function OnboardingPage() { createHarness.mutate({ name: name.trim() || "Untitled Harness", model: model || "gpt-4o", - status: "draft", + status: "draft" as const, mcpServers: allMcpServers, skills: selectedSkills, sandboxEnabled: sandboxEnabled || undefined, From d3e06eb176deea390975a95a8a77f9f4971538e9 Mon Sep 17 00:00:00 2001 From: Cole Ramer Date: Mon, 6 Apr 2026 17:27:22 -0400 Subject: [PATCH 07/87] some skilll and minor bug fixes --- .../src/components/skill-viewer-dialog.tsx | 66 +++++++++++---- apps/web/src/components/skills-browser.tsx | 83 ++----------------- apps/web/src/lib/harness-types.ts | 30 ------- apps/web/src/routes/onboarding.tsx | 15 +++- packages/convex-backend/convex/skills.ts | 71 ++++++++++++++-- 5 files changed, 137 insertions(+), 128 deletions(-) delete mode 100644 apps/web/src/lib/harness-types.ts diff --git a/apps/web/src/components/skill-viewer-dialog.tsx b/apps/web/src/components/skill-viewer-dialog.tsx index 8d5dc77..f5a292d 100644 --- a/apps/web/src/components/skill-viewer-dialog.tsx +++ b/apps/web/src/components/skill-viewer-dialog.tsx @@ -2,7 +2,7 @@ import { convexQuery, useConvexAction } from "@convex-dev/react-query"; import { api } from "@harness/convex-backend/convex/_generated/api"; import { useQuery } from "@tanstack/react-query"; import { Download, Loader2 } from "lucide-react"; -import { useCallback, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { MarkdownMessage } from "./markdown-message"; import { Dialog, @@ -32,18 +32,40 @@ export function SkillViewerDialog({ onClose, }: SkillViewerDialogProps) { const ensureSkillDetailsFn = useConvexAction(api.skills.ensureSkillDetails); - const ensuredRef = useRef(new Set()); + // Track per-skill ensure status: maps fullId → { done, error } + const ensureStatusRef = useRef( + new Map(), + ); + // Force re-render when a status changes + const [, forceUpdate] = useState(0); const detailQuery = useQuery({ ...convexQuery(api.skills.getByName, { name: fullId ?? "" }), enabled: !!fullId, }); - // Fire-and-forget ensure on first open - if (fullId && !ensuredRef.current.has(fullId)) { - ensuredRef.current.add(fullId); - ensureSkillDetailsFn({ names: [fullId] }).catch(() => {}); - } + const currentStatus = fullId + ? ensureStatusRef.current.get(fullId) + : undefined; + const ensureDone = currentStatus?.done ?? false; + const ensureError = currentStatus?.error ?? false; + + // Fetch details when a new fullId is opened + useEffect(() => { + if (!fullId) return; + if (ensureStatusRef.current.has(fullId)) return; + ensureStatusRef.current.set(fullId, { done: false, error: false }); + forceUpdate((n) => n + 1); + ensureSkillDetailsFn({ names: [fullId] }) + .then(() => { + ensureStatusRef.current.set(fullId, { done: true, error: false }); + forceUpdate((n) => n + 1); + }) + .catch(() => { + ensureStatusRef.current.set(fullId, { done: true, error: true }); + forceUpdate((n) => n + 1); + }); + }, [fullId, ensureSkillDetailsFn]); const formatInstalls = useCallback((n: number) => { if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; @@ -76,7 +98,28 @@ export function SkillViewerDialog({
- {detailQuery.isLoading || !detailQuery.data?.detail ? ( + {detailQuery.data?.detail ? ( + + ) : detailQuery.isError || ensureError ? ( +
+ + Failed to load skill documentation. + +
+ ) : ensureDone && + !detailQuery.isLoading && + !detailQuery.isFetching ? ( +
+ + No documentation available for this skill. + +
+ ) : (
- ) : ( - )}
diff --git a/apps/web/src/components/skills-browser.tsx b/apps/web/src/components/skills-browser.tsx index a1953ae..2394fbf 100644 --- a/apps/web/src/components/skills-browser.tsx +++ b/apps/web/src/components/skills-browser.tsx @@ -14,17 +14,10 @@ import { import { useCallback, useEffect, useRef, useState } from "react"; import type { SkillEntry, SkillRow } from "../lib/skills"; import { searchSkillsSh } from "../lib/skills-api"; -import { MarkdownMessage } from "./markdown-message"; +import { SkillViewerDialog } from "./skill-viewer-dialog"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "./ui/dialog"; import { Input } from "./ui/input"; const PAGE_SIZE = 20; @@ -82,30 +75,8 @@ export function SkillsBrowser({ const debounceRef = useRef>(undefined); const [viewingSkill, setViewingSkill] = useState(null); - const ensuredSkillsRef = useRef(new Set()); const discoverSkillsFn = useConvexAction(api.skills.discoverSkillsFromSearch); - const ensureSkillDetailsFn = useConvexAction(api.skills.ensureSkillDetails); - - // Query skill detail when viewing — Convex reactivity auto-updates when ensureSkillDetails finishes - const skillDetailQuery = useQuery({ - ...convexQuery(api.skills.getByName, { - name: viewingSkill?.fullId ?? "", - }), - enabled: !!viewingSkill, - }); - - const handleViewSkill = useCallback( - (skill: SkillRow) => { - setViewingSkill(skill); - // Fire-and-forget: ensure detail is cached (same pattern as harness save) - if (!ensuredSkillsRef.current.has(skill.fullId)) { - ensuredSkillsRef.current.add(skill.fullId); - ensureSkillDetailsFn({ names: [skill.fullId] }).catch(() => {}); - } - }, - [ensureSkillDetailsFn], - ); useEffect(() => { clearTimeout(debounceRef.current); @@ -357,7 +328,7 @@ export function SkillsBrowser({ aria-label={`View skill ${skill.skillId}`} onClick={(e) => { e.stopPropagation(); - handleViewSkill(skill); + setViewingSkill(skill); }} className="mt-0.5 shrink-0 border-0 bg-transparent p-0 text-muted-foreground/40 transition-colors hover:text-foreground" > @@ -400,49 +371,13 @@ export function SkillsBrowser({
)} - { - if (!open) setViewingSkill(null); - }} - > - - - - {viewingSkill?.skillId} - - - {viewingSkill?.source} - {viewingSkill && viewingSkill.installs > 0 && ( - - - {formatInstalls(viewingSkill.installs)} - - )} - - -
- {skillDetailQuery.isLoading || !skillDetailQuery.data?.detail ? ( -
- - - Fetching skill documentation... - -
- ) : ( - - )} -
-
-
+ setViewingSkill(null)} + /> ); } diff --git a/apps/web/src/lib/harness-types.ts b/apps/web/src/lib/harness-types.ts deleted file mode 100644 index b65fb1b..0000000 --- a/apps/web/src/lib/harness-types.ts +++ /dev/null @@ -1,30 +0,0 @@ -313; /** - * Sandbox-related fields on the harness document. - * These exist in the Convex schema but are missing from the stale generated types. - * Remove this file once `npx convex dev` regenerates the types. - */ -export interface HarnessSandboxFields { - sandboxEnabled?: boolean; - daytonaSandboxId?: string; - sandboxConfig?: { - persistent: boolean; - autoStart: boolean; - defaultLanguage: string; - resourceTier: "basic" | "standard" | "performance"; - snapshotId?: string; - gitRepo?: string; - networkRestricted?: boolean; - }; -} - -/** Helper to extract sandbox config as the snake_case shape expected by the API. */ -export function toSandboxApiConfig(h: HarnessSandboxFields) { - return h.sandboxConfig - ? { - persistent: h.sandboxConfig.persistent, - auto_start: h.sandboxConfig.autoStart, - default_language: h.sandboxConfig.defaultLanguage, - resource_tier: h.sandboxConfig.resourceTier, - } - : undefined; -} diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index 665571c..62f12d6 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -908,8 +908,19 @@ function StepSandbox({ file management, terminal commands, and git operations.

-
+
- + {enabled && ( { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + }; + const token = process.env.GITHUB_TOKEN; + if (token) { + headers.Authorization = `token ${token}`; + } + return headers; +} + +/** Build headers for raw.githubusercontent.com requests (auth only). */ +function ghRawHeaders(): Record | undefined { + const token = process.env.GITHUB_TOKEN; + if (token) { + return { Authorization: `token ${token}` }; + } + return undefined; +} + /** Resolve the canonical owner/repo via GitHub API (follows renames/redirects). */ async function resolveGitHubRepo(source: string): Promise { try { const resp = await fetch(`https://api.github.com/repos/${source}`, { - headers: { Accept: "application/vnd.github.v3+json" }, + headers: ghApiHeaders(), }); if (resp.ok) { const data = (await resp.json()) as { full_name?: string }; @@ -229,7 +250,7 @@ async function fetchSkillMdFromRepo( const bases = ["skills", ".agents/skills", ".claude/skills"]; const ghApi = "https://api.github.com"; const ghRaw = "https://raw.githubusercontent.com"; - const ghHeaders = { Accept: "application/vnd.github.v3+json" }; + const rawHeaders = ghRawHeaders(); const normalizedId = normalizeSkillId(skillId); const branches = ["main", "master"]; @@ -242,6 +263,7 @@ async function fetchSkillMdFromRepo( try { const resp = await fetch( `${ghRaw}/${source}/${branch}/${base}/${id}/SKILL.md`, + rawHeaders ? { headers: rawHeaders } : undefined, ); if (resp.ok) return await resp.text(); } catch { @@ -252,7 +274,10 @@ async function fetchSkillMdFromRepo( // 2. Try repo-root SKILL.md try { - const resp = await fetch(`${ghRaw}/${source}/${branch}/SKILL.md`); + const resp = await fetch( + `${ghRaw}/${source}/${branch}/SKILL.md`, + rawHeaders ? { headers: rawHeaders } : undefined, + ); if (resp.ok) return await resp.text(); } catch { // Continue @@ -264,7 +289,7 @@ async function fetchSkillMdFromRepo( try { const resp = await fetch( `${ghApi}/repos/${source}/git/trees/${branch}?recursive=1`, - { headers: ghHeaders }, + { headers: ghApiHeaders() }, ); if (!resp.ok) continue; @@ -295,6 +320,7 @@ async function fetchSkillMdFromRepo( if (match) { const mdResp = await fetch( `${ghRaw}/${source}/${branch}/${match}`, + rawHeaders ? { headers: rawHeaders } : undefined, ); if (mdResp.ok) return await mdResp.text(); } @@ -306,6 +332,7 @@ async function fetchSkillMdFromRepo( if (rootSkillMd) { const mdResp = await fetch( `${ghRaw}/${source}/${branch}/${rootSkillMd}`, + rawHeaders ? { headers: rawHeaders } : undefined, ); if (mdResp.ok) return await mdResp.text(); } @@ -377,9 +404,27 @@ export const ensureSkillDetails = action({ }); if (existing?.detail) return; - const parts = name.split("/"); - const skillId = parts.pop() ?? name; - const source = parts.join("/"); + // Look up the authoritative source from skillsIndex first, + // falling back to splitting the fullId (which can be wrong for + // skills whose fullId has more than 3 segments). + const indexSource = await ctx.runQuery( + internal.skills.getSkillSource, + { fullId: name }, + ); + + let source: string; + let skillId: string; + if (indexSource) { + source = indexSource; + // skillId is the portion of fullId after the source prefix + skillId = name.startsWith(source + "/") + ? name.slice(source.length + 1) + : name.split("/").pop() ?? name; + } else { + const parts = name.split("/"); + skillId = parts.pop() ?? name; + source = parts.join("/"); + } if (!source) return; @@ -423,6 +468,18 @@ export const getDetailByName = internalQuery({ }, }); +/** Look up a skill's source from the skillsIndex by its fullId. */ +export const getSkillSource = internalQuery({ + args: { fullId: v.string() }, + handler: async (ctx, args) => { + const doc = await ctx.db + .query("skillsIndex") + .withIndex("by_fullId", (q) => q.eq("fullId", args.fullId)) + .first(); + return doc?.source ?? null; + }, +}); + /** * Action to discover and upsert new skills from skills.sh search API. * Called from the frontend when search returns results not in our index. From 763f7ad63ce0ac8df409c56c21e79229131ceb08 Mon Sep 17 00:00:00 2001 From: Cole Ramer Date: Mon, 6 Apr 2026 17:45:54 -0400 Subject: [PATCH 08/87] html nesting fix --- .../src/components/recommended-skills-grid.tsx | 16 +++++++++++++--- apps/web/src/components/skills-browser.tsx | 16 +++++++++++++--- apps/web/src/routes/harnesses/$harnessId.tsx | 13 ++++++++++--- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/recommended-skills-grid.tsx b/apps/web/src/components/recommended-skills-grid.tsx index 9e2292c..fe1dce4 100644 --- a/apps/web/src/components/recommended-skills-grid.tsx +++ b/apps/web/src/components/recommended-skills-grid.tsx @@ -34,15 +34,25 @@ export function RecommendedSkillsGrid({ {RECOMMENDED_SKILLS.map((rec) => { const isSelected = selected.some((s) => s.name === rec.skill.fullId); return ( - - + ); })} diff --git a/apps/web/src/components/skills-browser.tsx b/apps/web/src/components/skills-browser.tsx index 2394fbf..47084e7 100644 --- a/apps/web/src/components/skills-browser.tsx +++ b/apps/web/src/components/skills-browser.tsx @@ -287,15 +287,25 @@ export function SkillsBrowser({ {rows.map((skill) => { const added = isAdded(skill.fullId); return ( - - + ); })} diff --git a/apps/web/src/routes/harnesses/$harnessId.tsx b/apps/web/src/routes/harnesses/$harnessId.tsx index 9376673..a64b20b 100644 --- a/apps/web/src/routes/harnesses/$harnessId.tsx +++ b/apps/web/src/routes/harnesses/$harnessId.tsx @@ -740,10 +740,17 @@ function HarnessEditPage() { {currentSkills.map((skill) => { const displayName = skill.name.split("/").pop() ?? skill.name; return ( - - + ); })} Date: Mon, 6 Apr 2026 17:54:33 -0400 Subject: [PATCH 09/87] search authentication fix --- packages/convex-backend/convex/skills.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/convex-backend/convex/skills.ts b/packages/convex-backend/convex/skills.ts index 1e2a151..cd59b49 100644 --- a/packages/convex-backend/convex/skills.ts +++ b/packages/convex-backend/convex/skills.ts @@ -496,6 +496,9 @@ export const discoverSkillsFromSearch = action({ ), }, handler: async (ctx, args): Promise => { + + const identity = await ctx.auth.getUserIdentity(); + if (!identity) throw new Error("Unauthenticated"); // Check which ones we already have const fullIds = args.skills.map((s) => s.fullId); const existingIds = await ctx.runQuery( From 73e7603bcff6aabc302ad9b54bd34ffb7195c05f Mon Sep 17 00:00:00 2001 From: jon3350 Date: Wed, 1 Apr 2026 16:52:43 -0400 Subject: [PATCH 10/87] made ui changes for harness logo, creating harness page, etc. --- apps/web/src/routes/onboarding.tsx | 35 ++++++++++++++---------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx index 62f12d6..e35a106 100644 --- a/apps/web/src/routes/onboarding.tsx +++ b/apps/web/src/routes/onboarding.tsx @@ -908,30 +908,27 @@ function StepSandbox({ file management, terminal commands, and git operations.

-