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 (
+
+ );
+}
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() {
)}
+
+
);
})}
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 (
-
onToggle({
name: skill.fullId,
description: skill.description,
})
}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onToggle({
+ name: skill.fullId,
+ description: skill.description,
+ });
+ }
+ }}
className={`flex items-start gap-3 border p-3 text-left transition-colors ${
added
? "border-foreground bg-foreground/3"
@@ -334,7 +344,7 @@ export function SkillsBrowser({
>
-
+
);
})}
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 (
- toggleSkill(skill)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ toggleSkill(skill);
+ }
+ }}
className="flex w-full items-start gap-3 border border-foreground bg-foreground/3 p-3 text-left transition-colors hover:border-foreground/20"
>
-
+
);
})}
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.
-