diff --git a/apps/web/src/components/mcp-server-status.tsx b/apps/web/src/components/mcp-server-status.tsx
index d736739..8d149ae 100644
--- a/apps/web/src/components/mcp-server-status.tsx
+++ b/apps/web/src/components/mcp-server-status.tsx
@@ -2,7 +2,7 @@ import { useAuth } from "@clerk/tanstack-react-start";
import { convexQuery } from "@convex-dev/react-query";
import { api } from "@harness/convex-backend/convex/_generated/api";
import { useQuery } from "@tanstack/react-query";
-import { AlertTriangle, Server, Shield } from "lucide-react";
+import { AlertTriangle, GraduationCap, Server, Shield } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import toast from "react-hot-toast";
@@ -98,7 +98,12 @@ export type HealthStatus =
| "unreachable"
| "auth_required";
-type ServerStatus = "connected" | "expired" | "disconnected" | "checking";
+type ServerStatus =
+ | "connected"
+ | "expired"
+ | "disconnected"
+ | "checking"
+ | "needs_verification";
function getServerStatus(
server: McpServer,
@@ -126,7 +131,15 @@ function getServerStatus(
return "connected";
}
- // For non-OAuth servers: use health check result
+ // For Princeton servers: auth_required means no verified netid on the account
+ if (server.authType === "tiger_junction") {
+ if (healthStatus === "auth_required") return "needs_verification";
+ if (healthStatus === "unreachable") return "disconnected";
+ if (healthStatus === "reachable") return "connected";
+ return "checking";
+ }
+
+ // For bearer / none servers: use health check result
if (healthStatus === "unreachable") return "disconnected";
if (healthStatus === "auth_required") return "disconnected";
if (healthStatus === "reachable") return "connected";
@@ -139,6 +152,7 @@ const STATUS_DOT: Record = {
expired: "bg-amber-400",
disconnected: "bg-red-400",
checking: "bg-muted-foreground/40",
+ needs_verification: "bg-amber-400",
};
const STATUS_LABEL: Record = {
@@ -146,6 +160,7 @@ const STATUS_LABEL: Record = {
expired: "Token expired",
disconnected: "Unreachable",
checking: "Checking…",
+ needs_verification: "Verify Princeton account",
};
export function McpServerStatus({
@@ -186,7 +201,10 @@ export function McpServerStatus({
const allConnected = statuses.every((s) => s.status === "connected");
const hasIssue = statuses.some(
- (s) => s.status === "expired" || s.status === "disconnected",
+ (s) =>
+ s.status === "expired" ||
+ s.status === "disconnected" ||
+ s.status === "needs_verification",
);
const anyChecking = statuses.some((s) => s.status === "checking");
@@ -315,17 +333,44 @@ function McpServerRow({
Reconnect
)}
- {server.authType !== "oauth" && status === "connected" && (
+ {status === "connected" && server.authType === "bearer" && (
- {server.authType === "bearer" ? "Key" : "Open"}
+ Key
)}
- {server.authType === "oauth" && status === "connected" && (
+ {status === "connected" && server.authType === "none" && (
+
+ Public
+
+ )}
+ {status === "connected" && server.authType === "tiger_junction" && (
+
+
+ Princeton
+
+ )}
+ {status === "connected" && server.authType === "oauth" && (
OAuth
)}
+ {status === "needs_verification" && (
+
+
+
+
+ Verify
+
+
+
+ Open this harness's settings to verify your Princeton account.
+
+
+ )}
);
}
diff --git a/apps/web/src/components/pending-response-indicator.tsx b/apps/web/src/components/pending-response-indicator.tsx
new file mode 100644
index 0000000..38332b2
--- /dev/null
+++ b/apps/web/src/components/pending-response-indicator.tsx
@@ -0,0 +1,73 @@
+import { AnimatePresence, motion } from "motion/react";
+import { useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
+import { RoseCurveSpinner } from "./rose-curve-spinner";
+
+const FLAVOR_MESSAGES = [
+ "Thinking…",
+ "Warming up…",
+ "Consulting the tools…",
+ "Wiring up neurons…",
+ "Querying the oracle…",
+ "Reticulating splines…",
+ "Gathering thoughts…",
+ "Brewing a response…",
+ "Summoning tokens…",
+ "Sharpening the quill…",
+ "Reading the room…",
+ "Chasing down sources…",
+ "Following the thread…",
+ "Picking the right words…",
+ "Double-checking the math…",
+ "Aligning the stars…",
+];
+
+const ROTATE_MS = 2600;
+
+function pickInitial(): number {
+ return Math.floor(Math.random() * FLAVOR_MESSAGES.length);
+}
+
+export function PendingResponseIndicator({
+ className,
+}: {
+ className?: string;
+}) {
+ const [idx, setIdx] = useState(pickInitial);
+
+ useEffect(() => {
+ const prefersReduced =
+ window?.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
+ if (prefersReduced) return;
+ const id = setInterval(() => {
+ setIdx((prev) => (prev + 1) % FLAVOR_MESSAGES.length);
+ }, ROTATE_MS);
+ return () => clearInterval(id);
+ }, []);
+
+ const message = FLAVOR_MESSAGES[idx];
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts
index e3641d4..26d7076 100644
--- a/apps/web/src/routeTree.gen.ts
+++ b/apps/web/src/routeTree.gen.ts
@@ -9,6 +9,7 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as SignUpRouteImport } from './routes/sign-up'
import { Route as SignInRouteImport } from './routes/sign-in'
import { Route as OnboardingRouteImport } from './routes/onboarding'
import { Route as AppRouteImport } from './routes/app'
@@ -21,6 +22,11 @@ import { Route as SandboxesCreate_sandboxRouteImport } from './routes/sandboxes/
import { Route as SandboxesSandboxIdRouteImport } from './routes/sandboxes/$sandboxId'
import { Route as HarnessesHarnessIdRouteImport } from './routes/harnesses/$harnessId'
+const SignUpRoute = SignUpRouteImport.update({
+ id: '/sign-up',
+ path: '/sign-up',
+ getParentRoute: () => rootRouteImport,
+} as any)
const SignInRoute = SignInRouteImport.update({
id: '/sign-in',
path: '/sign-in',
@@ -82,6 +88,7 @@ export interface FileRoutesByFullPath {
'/app': typeof AppRoute
'/onboarding': typeof OnboardingRoute
'/sign-in': typeof SignInRoute
+ '/sign-up': typeof SignUpRoute
'/harnesses/$harnessId': typeof HarnessesHarnessIdRoute
'/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute
'/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute
@@ -95,6 +102,7 @@ export interface FileRoutesByTo {
'/app': typeof AppRoute
'/onboarding': typeof OnboardingRoute
'/sign-in': typeof SignInRoute
+ '/sign-up': typeof SignUpRoute
'/harnesses/$harnessId': typeof HarnessesHarnessIdRoute
'/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute
'/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute
@@ -109,6 +117,7 @@ export interface FileRoutesById {
'/app': typeof AppRoute
'/onboarding': typeof OnboardingRoute
'/sign-in': typeof SignInRoute
+ '/sign-up': typeof SignUpRoute
'/harnesses/$harnessId': typeof HarnessesHarnessIdRoute
'/sandboxes/$sandboxId': typeof SandboxesSandboxIdRoute
'/sandboxes/create_sandbox': typeof SandboxesCreate_sandboxRoute
@@ -124,6 +133,7 @@ export interface FileRouteTypes {
| '/app'
| '/onboarding'
| '/sign-in'
+ | '/sign-up'
| '/harnesses/$harnessId'
| '/sandboxes/$sandboxId'
| '/sandboxes/create_sandbox'
@@ -137,6 +147,7 @@ export interface FileRouteTypes {
| '/app'
| '/onboarding'
| '/sign-in'
+ | '/sign-up'
| '/harnesses/$harnessId'
| '/sandboxes/$sandboxId'
| '/sandboxes/create_sandbox'
@@ -150,6 +161,7 @@ export interface FileRouteTypes {
| '/app'
| '/onboarding'
| '/sign-in'
+ | '/sign-up'
| '/harnesses/$harnessId'
| '/sandboxes/$sandboxId'
| '/sandboxes/create_sandbox'
@@ -164,6 +176,7 @@ export interface RootRouteChildren {
AppRoute: typeof AppRoute
OnboardingRoute: typeof OnboardingRoute
SignInRoute: typeof SignInRoute
+ SignUpRoute: typeof SignUpRoute
HarnessesHarnessIdRoute: typeof HarnessesHarnessIdRoute
SandboxesSandboxIdRoute: typeof SandboxesSandboxIdRoute
SandboxesCreate_sandboxRoute: typeof SandboxesCreate_sandboxRoute
@@ -175,6 +188,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
+ '/sign-up': {
+ id: '/sign-up'
+ path: '/sign-up'
+ fullPath: '/sign-up'
+ preLoaderRoute: typeof SignUpRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/sign-in': {
id: '/sign-in'
path: '/sign-in'
@@ -260,6 +280,7 @@ const rootRouteChildren: RootRouteChildren = {
AppRoute: AppRoute,
OnboardingRoute: OnboardingRoute,
SignInRoute: SignInRoute,
+ SignUpRoute: SignUpRoute,
HarnessesHarnessIdRoute: HarnessesHarnessIdRoute,
SandboxesSandboxIdRoute: SandboxesSandboxIdRoute,
SandboxesCreate_sandboxRoute: SandboxesCreate_sandboxRoute,
diff --git a/apps/web/src/routes/chat/index.tsx b/apps/web/src/routes/chat/index.tsx
index 8234082..0547f89 100644
--- a/apps/web/src/routes/chat/index.tsx
+++ b/apps/web/src/routes/chat/index.tsx
@@ -64,6 +64,7 @@ import {
MessageActions,
} from "../../components/message-actions";
import { MessageAttachments } from "../../components/message-attachments";
+import { PendingResponseIndicator } from "../../components/pending-response-indicator";
import { RoseCurveSpinner } from "../../components/rose-curve-spinner";
import { SandboxPanel } from "../../components/sandbox/sandbox-panel";
import { SandboxResult } from "../../components/sandbox-result";
@@ -708,83 +709,97 @@ function ChatPage() {
),
);
- // 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,
- }),
- });
+ // Health-check MCP servers when harness changes, or on-demand via refreshHealth.
+ const healthCheckRunRef = useRef<{ cancel: () => void } | null>(null);
+ const runHealthCheck = useCallback(
+ (
+ servers: Array<{
+ name: string;
+ url: string;
+ authType: McpAuthType;
+ authToken?: string;
+ }>,
+ ) => {
+ healthCheckRunRef.current?.cancel();
- if (cancelled) return;
+ if (servers.length === 0) {
+ setMcpHealthStatuses({});
+ return;
+ }
- if (!res.ok) {
- const fallback: Record = {};
- for (const s of activeHarness.mcpServers) {
- fallback[s.url] = "unreachable";
+ const checking: Record = {};
+ for (const s of servers) checking[s.url] = "checking";
+ setMcpHealthStatuses(checking);
+
+ let cancelled = false;
+ const run = async () => {
+ try {
+ const token = await getToken();
+ const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({
+ mcp_servers: servers.map((s) => ({
+ name: s.name,
+ url: s.url,
+ auth_type: s.authType,
+ ...(s.authToken ? { auth_token: s.authToken } : {}),
+ })),
+ force: true,
+ }),
+ });
+ if (cancelled) return;
+ if (!res.ok) {
+ const fallback: Record = {};
+ for (const s of servers) fallback[s.url] = "unreachable";
+ setMcpHealthStatuses(fallback);
+ return;
}
+ const data = await res.json();
+ if (cancelled) return;
+ const statuses: Record = {};
+ for (const server of data.servers) {
+ if (server.status === "ok") statuses[server.url] = "reachable";
+ else if (server.status === "auth_required") statuses[server.url] = "auth_required";
+ else statuses[server.url] = "unreachable";
+ }
+ setMcpHealthStatuses(statuses);
+ } catch {
+ if (cancelled) return;
+ const fallback: Record = {};
+ for (const s of servers) fallback[s.url] = "unreachable";
setMcpHealthStatuses(fallback);
- return;
}
+ };
- const data = await res.json();
- if (cancelled) return;
+ run();
+ healthCheckRunRef.current = {
+ cancel: () => {
+ cancelled = true;
+ },
+ };
+ },
+ [getToken],
+ );
- 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);
- }
- };
+ const refreshHealth = useCallback(() => {
+ if (activeHarness) runHealthCheck(activeHarness.mcpServers);
+ }, [activeHarness, runHealthCheck]);
- runCheck();
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes
+ useEffect(() => {
+ if (!activeHarness) {
+ setMcpHealthStatuses({});
+ return;
+ }
+ runHealthCheck(activeHarness.mcpServers);
return () => {
- cancelled = true;
+ healthCheckRunRef.current?.cancel();
};
- }, [activeHarness?._id, getToken]);
+ }, [activeHarness?._id]);
// Sync slash commands: fetch from MCP servers, upsert into commands table,
// and store the resulting IDs on the harness's mcpServers.
@@ -1143,6 +1158,7 @@ function ChatPage() {
isStreaming={isActiveConvoStreaming}
mcpHealthStatuses={mcpHealthStatuses}
onRefreshCommands={refreshCommands}
+ onRefreshHealth={refreshHealth}
onAddSkill={handleAddSkill}
onRemoveSkill={handleRemoveSkill}
/>
@@ -2018,6 +2034,7 @@ function ChatHeader({
isStreaming,
mcpHealthStatuses,
onRefreshCommands,
+ onRefreshHealth,
onAddSkill,
onRemoveSkill,
}: {
@@ -2059,6 +2076,7 @@ function ChatHeader({
isStreaming: boolean;
mcpHealthStatuses?: Record;
onRefreshCommands: () => void;
+ onRefreshHealth: () => void;
onAddSkill: (skill: SkillEntry) => void;
onRemoveSkill: (skill: SkillEntry) => void;
}) {
@@ -2120,7 +2138,10 @@ function ChatHeader({
{
+ onRefreshHealth();
+ onRefreshCommands();
+ }}
/>
)}
@@ -2529,9 +2550,13 @@ function ChatMessages({
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
+ // Show the streaming bubble while we're waiting for or receiving a response
+ // (content, reasoning, tool calls) but Convex hasn't synced yet. Include
+ // `isStreaming` so the bubble appears immediately with a pending spinner
+ // before the first chunk arrives.
const showStreamingBubble =
- (streamingContent !== null ||
+ (isStreaming ||
+ streamingContent !== null ||
streamingReasoning !== null ||
activeToolCalls.length > 0) &&
!convexHasMessage;
@@ -2996,12 +3021,7 @@ function ChatMessages({
})
: !streamingReasoning &&
activeToolCalls.length === 0 &&
- !streamingContent && (
-
- )}
+ !streamingContent && }
{displayMode === "developer" && streamUsage && (
diff --git a/apps/web/src/routes/harnesses/$harnessId.tsx b/apps/web/src/routes/harnesses/$harnessId.tsx
index 1a2978b..7d659e9 100644
--- a/apps/web/src/routes/harnesses/$harnessId.tsx
+++ b/apps/web/src/routes/harnesses/$harnessId.tsx
@@ -89,6 +89,8 @@ type SandboxConfig = {
type Sandbox = Doc<"sandboxes">;
+type HarnessStatus = "draft" | "started" | "stopped";
+
function getResourceTierFromSandbox(
sandbox: Sandbox,
): SandboxConfig["resourceTier"] {
@@ -154,6 +156,7 @@ function HarnessEditPage() {
setName(null);
setModel(null);
+ setStatus(null);
setMcpServers(null);
setSkills(null);
setSystemPrompt(null);
@@ -247,6 +250,7 @@ function HarnessEditPage() {
const [name, setName] = useState
(null);
const [model, setModel] = useState(null);
+ const [status, setStatus] = useState(null);
const [mcpServers, setMcpServers] = useState(null);
const [skills, setSkills] = useState(null);
const [skillsBrowserOpen, setSkillsBrowserOpen] = useState(false);
@@ -259,6 +263,8 @@ function HarnessEditPage() {
// Use local state if edited, otherwise fall back to server data
const currentName = name ?? harness?.name ?? "";
const currentModel = model ?? harness?.model ?? "";
+ const currentStatus: HarnessStatus =
+ status ?? (harness?.status as HarnessStatus | undefined) ?? "draft";
const currentMcpServers = mcpServers ?? harness?.mcpServers ?? [];
const currentSkills: SkillEntry[] = skills ?? harness?.skills ?? [];
@@ -295,6 +301,7 @@ function HarnessEditPage() {
const hasChanges =
name !== null ||
model !== null ||
+ status !== null ||
mcpServers !== null ||
skills !== null ||
systemPrompt !== null ||
@@ -360,6 +367,7 @@ function HarnessEditPage() {
}
if (name !== null) updates.name = name;
if (model !== null) updates.model = model;
+ if (status !== null) updates.status = status;
if (mcpServers !== null) updates.mcpServers = mcpServers;
if (skills !== null) updates.skills = skills;
if (systemPrompt !== null) updates.systemPrompt = systemPrompt.trim();
@@ -814,20 +822,39 @@ function HarnessEditPage() {
Status
-
-
-
- {harness.status}
-
-
+
+ {harness.status === "draft" && currentStatus !== "draft" && (
+
+ Saving will promote this harness out of draft.
+
+ )}
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx
index 7af172c..6cf8826 100644
--- a/apps/web/src/routes/index.tsx
+++ b/apps/web/src/routes/index.tsx
@@ -535,7 +535,7 @@ function LandingNav() {
Log in
-
+
{isSignedIn ? "Go to Chat" : "Get Started Free"}
diff --git a/apps/web/src/routes/onboarding.tsx b/apps/web/src/routes/onboarding.tsx
index fccaa02..1e029f7 100644
--- a/apps/web/src/routes/onboarding.tsx
+++ b/apps/web/src/routes/onboarding.tsx
@@ -233,9 +233,14 @@ function OnboardingPage() {
return harnessId;
},
- onSuccess: (harnessId) => {
+ onSuccess: (harnessId, variables) => {
const id = harnessId as Id<"harnesses">;
- navigate({ to: "/chat", search: { harnessId: id as string } });
+ if (variables.status === "draft") {
+ navigate({ to: "/harnesses" });
+ toast.success("Draft saved");
+ } else {
+ navigate({ to: "/chat", search: { harnessId: id as string } });
+ }
// Fire-and-forget: sync skill details for added skills
if (selectedSkills.length > 0) {
@@ -361,14 +366,22 @@ function OnboardingPage() {
};
const handleSaveDraft = () => {
+ if (!name.trim()) {
+ toast.error("Give your harness a name before saving");
+ return;
+ }
+ if (!model) {
+ toast.error("Pick a model before saving");
+ return;
+ }
const defaultSandbox = getDefaultSandboxSelection(selectedSandbox);
if (sandboxEnabled && !defaultSandbox) {
toast.error("Select an existing sandbox");
return;
}
createHarness.mutate({
- name: name.trim() || "Untitled Harness",
- model: model || "gpt-4o",
+ name: name.trim(),
+ model,
status: "draft" as const,
mcpServers: mcpServersForMutation,
skills: selectedSkills,
@@ -415,7 +428,14 @@ function OnboardingPage() {
variant="ghost"
size="sm"
onClick={handleSaveDraft}
- disabled={createHarness.isPending}
+ disabled={
+ createHarness.isPending || !name.trim() || !model
+ }
+ title={
+ !name.trim() || !model
+ ? "Name the harness and pick a model first"
+ : undefined
+ }
>
Save Draft
diff --git a/apps/web/src/routes/sign-in.tsx b/apps/web/src/routes/sign-in.tsx
index 0fb0ae2..2f6455a 100644
--- a/apps/web/src/routes/sign-in.tsx
+++ b/apps/web/src/routes/sign-in.tsx
@@ -65,6 +65,7 @@ function SignInPage() {
+
+
+
+
+
+ Harness
+
+
+
+
+
+
+ Equip your AI agents for anything.
+
+
+ Create, manage, and deploy custom tool configurations for AI agents
+ — switching contexts in seconds, not hours.
+
+
+
+
+ © 2026 Harness. All rights reserved.
+
+
+
+
+
+
+
+
+ Harness
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/routes/workspaces/index.tsx b/apps/web/src/routes/workspaces/index.tsx
index b3dcef2..e43dc62 100644
--- a/apps/web/src/routes/workspaces/index.tsx
+++ b/apps/web/src/routes/workspaces/index.tsx
@@ -67,6 +67,7 @@ import {
MessageActions,
} from "../../components/message-actions";
import { MessageAttachments } from "../../components/message-attachments";
+import { PendingResponseIndicator } from "../../components/pending-response-indicator";
import { RoseCurveSpinner } from "../../components/rose-curve-spinner";
import { SandboxPanel } from "../../components/sandbox/sandbox-panel";
import { SandboxResult } from "../../components/sandbox-result";
@@ -747,83 +748,97 @@ function ChatPage() {
sessionModel,
]);
- // 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,
- }),
- });
+ // Health-check MCP servers when harness changes, or on-demand via refreshHealth.
+ const healthCheckRunRef = useRef<{ cancel: () => void } | null>(null);
+ const runHealthCheck = useCallback(
+ (
+ servers: Array<{
+ name: string;
+ url: string;
+ authType: McpAuthType;
+ authToken?: string;
+ }>,
+ ) => {
+ healthCheckRunRef.current?.cancel();
- if (cancelled) return;
+ if (servers.length === 0) {
+ setMcpHealthStatuses({});
+ return;
+ }
- if (!res.ok) {
- const fallback: Record = {};
- for (const s of activeHarness.mcpServers) {
- fallback[s.url] = "unreachable";
+ const checking: Record = {};
+ for (const s of servers) checking[s.url] = "checking";
+ setMcpHealthStatuses(checking);
+
+ let cancelled = false;
+ const run = async () => {
+ try {
+ const token = await getToken();
+ const res = await fetch(`${FASTAPI_URL}/api/mcp/health/check`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ },
+ body: JSON.stringify({
+ mcp_servers: servers.map((s) => ({
+ name: s.name,
+ url: s.url,
+ auth_type: s.authType,
+ ...(s.authToken ? { auth_token: s.authToken } : {}),
+ })),
+ force: true,
+ }),
+ });
+ if (cancelled) return;
+ if (!res.ok) {
+ const fallback: Record = {};
+ for (const s of servers) fallback[s.url] = "unreachable";
+ setMcpHealthStatuses(fallback);
+ return;
}
+ const data = await res.json();
+ if (cancelled) return;
+ const statuses: Record = {};
+ for (const server of data.servers) {
+ if (server.status === "ok") statuses[server.url] = "reachable";
+ else if (server.status === "auth_required") statuses[server.url] = "auth_required";
+ else statuses[server.url] = "unreachable";
+ }
+ setMcpHealthStatuses(statuses);
+ } catch {
+ if (cancelled) return;
+ const fallback: Record = {};
+ for (const s of servers) fallback[s.url] = "unreachable";
setMcpHealthStatuses(fallback);
- 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);
- }
- };
+ run();
+ healthCheckRunRef.current = {
+ cancel: () => {
+ cancelled = true;
+ },
+ };
+ },
+ [getToken],
+ );
+
+ const refreshHealth = useCallback(() => {
+ if (activeHarness) runHealthCheck(activeHarness.mcpServers);
+ }, [activeHarness, runHealthCheck]);
- runCheck();
+ // biome-ignore lint/correctness/useExhaustiveDependencies: only re-run when harness ID changes
+ useEffect(() => {
+ if (!activeHarness) {
+ setMcpHealthStatuses({});
+ return;
+ }
+ runHealthCheck(activeHarness.mcpServers);
return () => {
- cancelled = true;
+ healthCheckRunRef.current?.cancel();
};
- }, [activeHarness?._id, getToken]);
+ }, [activeHarness?._id]);
const handleInterrupt = useCallback(
(convoId: string) => {
@@ -1122,6 +1137,7 @@ function ChatPage() {
sidebarOpen={sidebarOpen}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
mcpHealthStatuses={mcpHealthStatuses}
+ onRefreshHealth={refreshHealth}
onSwapSandbox={handleSwapSandbox}
onAddSkill={handleAddSkill}
onRemoveSkill={handleRemoveSkill}
@@ -2502,6 +2518,7 @@ function ChatHeader({
sidebarOpen,
onToggleSidebar,
mcpHealthStatuses,
+ onRefreshHealth,
onSwapSandbox,
onAddSkill,
onRemoveSkill,
@@ -2538,6 +2555,7 @@ function ChatHeader({
sidebarOpen: boolean;
onToggleSidebar: () => void;
mcpHealthStatuses?: Record;
+ onRefreshHealth: () => void;
onSwapSandbox: (sandboxId: Id<"sandboxes">) => void;
onAddSkill: (skill: SkillEntry) => void;
onRemoveSkill: (skill: SkillEntry) => void;
@@ -2606,6 +2624,7 @@ function ChatHeader({
)}
@@ -2963,9 +2982,13 @@ function ChatMessages({
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
+ // Show the streaming bubble while we're waiting for or receiving a response
+ // (content, reasoning, tool calls) but Convex hasn't synced yet. Include
+ // `isStreaming` so the bubble appears immediately with a pending spinner
+ // before the first chunk arrives.
const showStreamingBubble =
- (streamingContent !== null ||
+ (isStreaming ||
+ streamingContent !== null ||
streamingReasoning !== null ||
activeToolCalls.length > 0) &&
!convexHasMessage;
@@ -3430,12 +3453,7 @@ function ChatMessages({
})
: !streamingReasoning &&
activeToolCalls.length === 0 &&
- !streamingContent && (
-
- )}
+ !streamingContent && }
{displayMode === "developer" && streamUsage && (
diff --git a/packages/convex-backend/convex/messages.ts b/packages/convex-backend/convex/messages.ts
index f9a5ee2..c05367d 100644
--- a/packages/convex-backend/convex/messages.ts
+++ b/packages/convex-backend/convex/messages.ts
@@ -201,6 +201,8 @@ export const saveAssistantMessage = internalMutation({
}),
),
model: v.optional(v.string()),
+ interrupted: v.optional(v.boolean()),
+ interruptionReason: v.optional(v.string()),
},
handler: async (ctx, args) => {
const convo = await ctx.db.get(args.conversationId);
@@ -221,6 +223,10 @@ export const saveAssistantMessage = internalMutation({
: {}),
...(args.usage ? { usage: args.usage } : {}),
...(args.model ? { model: args.model } : {}),
+ ...(args.interrupted ? { interrupted: true } : {}),
+ ...(args.interruptionReason
+ ? { interruptionReason: args.interruptionReason }
+ : {}),
});
await ctx.db.patch(args.conversationId, {
diff --git a/packages/convex-backend/convex/schema.ts b/packages/convex-backend/convex/schema.ts
index 19974c7..953765b 100644
--- a/packages/convex-backend/convex/schema.ts
+++ b/packages/convex-backend/convex/schema.ts
@@ -159,6 +159,7 @@ export default defineSchema({
),
model: v.optional(v.string()),
interrupted: v.optional(v.boolean()),
+ interruptionReason: v.optional(v.string()),
attachments: v.optional(
v.array(
v.object({
diff --git a/packages/fastapi/app/main.py b/packages/fastapi/app/main.py
index 655b9f8..0d70eb5 100644
--- a/packages/fastapi/app/main.py
+++ b/packages/fastapi/app/main.py
@@ -42,16 +42,17 @@ async def lifespan(app: FastAPI):
lifespan=lifespan,
)
+_local_dev_origins = [
+ f"http://{host}:{port}"
+ for host in ("localhost", "127.0.0.1")
+ for port in range(3000, 3021)
+]
+
app.add_middleware(
CORSMiddleware,
allow_origins=[
settings.frontend_url,
- "http://localhost:3000",
- "http://localhost:3001",
- "http://localhost:3003",
- "http://127.0.0.1:3000",
- "http://127.0.0.1:3001",
- "http://127.0.0.1:3003",
+ *_local_dev_origins,
"http://127.0.0.1:57177",
"https://harness.nz",
"https://staging.harness.nz",
diff --git a/packages/fastapi/app/routes/chat.py b/packages/fastapi/app/routes/chat.py
index bcb396b..3a8e551 100644
--- a/packages/fastapi/app/routes/chat.py
+++ b/packages/fastapi/app/routes/chat.py
@@ -645,6 +645,18 @@ async def event_generator():
"OpenRouter HTTP error: %s",
e.response.status_code,
)
+ await _save_interrupted(
+ http_client,
+ body,
+ user_id,
+ collected_content,
+ all_reasoning,
+ all_tool_calls_history,
+ all_parts,
+ collected_usage,
+ collected_model,
+ f"Upstream service error ({e.response.status_code})",
+ )
yield {
"event": "error",
"data": json.dumps(
@@ -656,6 +668,18 @@ async def event_generator():
return
except httpx.HTTPError as e:
logger.error("HTTP error during chat stream: %s", e)
+ await _save_interrupted(
+ http_client,
+ body,
+ user_id,
+ collected_content,
+ all_reasoning,
+ all_tool_calls_history,
+ all_parts,
+ collected_usage,
+ collected_model,
+ "Service unavailable",
+ )
yield {
"event": "error",
"data": json.dumps({"message": "Service unavailable"}),
@@ -666,6 +690,18 @@ async def event_generator():
"Unexpected error in chat stream for conversation '%s'",
body.conversation_id,
)
+ await _save_interrupted(
+ http_client,
+ body,
+ user_id,
+ collected_content,
+ all_reasoning,
+ all_tool_calls_history,
+ all_parts,
+ collected_usage,
+ collected_model,
+ "Internal server error",
+ )
yield {
"event": "error",
"data": json.dumps({"message": "Internal server error"}),
@@ -904,15 +940,89 @@ async def _execute_tool(tool_info: dict) -> tuple[dict, str]:
body.conversation_id,
)
- # Max iterations reached
+ # Max iterations reached — persist everything we have so the user can
+ # see the partial trace instead of it silently vanishing.
logger.warning(
"Max tool iterations (%d) reached for conversation '%s'",
MAX_TOOL_ITERATIONS,
body.conversation_id,
)
+ await _save_interrupted(
+ http_client,
+ body,
+ user_id,
+ "",
+ all_reasoning,
+ all_tool_calls_history,
+ all_parts,
+ collected_usage,
+ collected_model,
+ f"Max tool call iterations ({MAX_TOOL_ITERATIONS}) reached",
+ )
yield {
"event": "error",
"data": json.dumps({"message": "Max tool call iterations reached"}),
}
return EventSourceResponse(event_generator())
+
+
+async def _save_interrupted(
+ http_client: httpx.AsyncClient,
+ body: "ChatRequest",
+ user_id: str,
+ content: str,
+ reasoning: str,
+ tool_calls_history: list[dict],
+ parts: list[dict],
+ collected_usage: dict | None,
+ collected_model: str | None,
+ reason: str,
+) -> None:
+ """Persist partial assistant state when the stream ends before a normal finish."""
+ usage_for_convex: dict | None = None
+ if collected_usage:
+ usage_for_convex = {
+ "promptTokens": collected_usage.get("prompt_tokens", 0),
+ "completionTokens": collected_usage.get("completion_tokens", 0),
+ "totalTokens": collected_usage.get("total_tokens", 0),
+ }
+ cost = collected_usage.get("cost")
+ if cost is not None:
+ usage_for_convex["cost"] = cost
+
+ try:
+ await save_assistant_message(
+ http_client,
+ body.conversation_id,
+ content,
+ reasoning=reasoning or None,
+ tool_calls=tool_calls_history or None,
+ parts=parts or None,
+ usage=usage_for_convex,
+ model=collected_model,
+ interrupted=True,
+ interruption_reason=reason,
+ )
+ except Exception:
+ logger.exception(
+ "Failed to persist interrupted message for conversation '%s'",
+ body.conversation_id,
+ )
+
+ if collected_usage:
+ try:
+ await record_usage(
+ http_client,
+ user_id=user_id,
+ conversation_id=body.conversation_id,
+ harness_id=body.harness.harness_id,
+ harness_name=body.harness.name,
+ model=collected_model or body.harness.model,
+ usage_data=collected_usage,
+ )
+ except Exception:
+ logger.exception(
+ "Failed to record usage for interrupted conversation '%s'",
+ body.conversation_id,
+ )
diff --git a/packages/fastapi/app/routes/mcp_health.py b/packages/fastapi/app/routes/mcp_health.py
index 43a70e0..4c54b57 100644
--- a/packages/fastapi/app/routes/mcp_health.py
+++ b/packages/fastapi/app/routes/mcp_health.py
@@ -15,6 +15,7 @@
check_server_health,
evict_session_cache,
list_tools,
+ resolve_princeton_netid,
)
from app.services.openrouter import complete_chat
@@ -44,6 +45,11 @@ async def _check_one(
user_ctx: UserContext | None,
force: bool,
) -> ServerHealth:
+ # Princeton MCPs need a verified netid — reachable without one is misleading,
+ # since actual tool calls fail server-side. Surface it as auth_required.
+ if server.auth_type == "tiger_junction" and (user_ctx is None or not user_ctx.princeton_netid):
+ return ServerHealth(name=server.name, url=server.url, reachable=False, status="auth_required")
+
if force:
evict_session_cache(server.url)
try:
@@ -64,7 +70,8 @@ async def check_health(
http_client: httpx.AsyncClient = Depends(get_http_client),
user: dict = Depends(get_current_user),
):
- user_ctx = UserContext(user_id=user.get("sub"))
+ netid = await resolve_princeton_netid(http_client, user)
+ user_ctx = UserContext(user_id=user.get("sub"), princeton_netid=netid)
results = await asyncio.gather(
*[_check_one(http_client, s, user_ctx, body.force) for s in body.mcp_servers],
@@ -96,7 +103,8 @@ async def generate_prompts(
http_client: httpx.AsyncClient = Depends(get_http_client),
user: dict = Depends(get_current_user),
):
- user_ctx = UserContext(user_id=user.get("sub"))
+ netid = await resolve_princeton_netid(http_client, user)
+ user_ctx = UserContext(user_id=user.get("sub"), princeton_netid=netid)
if not body.mcp_servers:
return GeneratePromptsResponse(prompts=[])
diff --git a/packages/fastapi/app/services/convex.py b/packages/fastapi/app/services/convex.py
index 0847132..40c311b 100644
--- a/packages/fastapi/app/services/convex.py
+++ b/packages/fastapi/app/services/convex.py
@@ -47,6 +47,8 @@ async def save_assistant_message(
parts: list[dict] | None = None,
usage: dict | None = None,
model: str | None = None,
+ interrupted: bool = False,
+ interruption_reason: str | None = None,
) -> None:
"""Save an assistant message to Convex via the HTTP API.
@@ -72,6 +74,10 @@ async def save_assistant_message(
args["usage"] = usage
if model:
args["model"] = model
+ if interrupted:
+ args["interrupted"] = True
+ if interruption_reason:
+ args["interruptionReason"] = interruption_reason
try:
resp = await http_client.post(
diff --git a/packages/fastapi/tests/test_convex_service.py b/packages/fastapi/tests/test_convex_service.py
index 742f693..1eb75b0 100644
--- a/packages/fastapi/tests/test_convex_service.py
+++ b/packages/fastapi/tests/test_convex_service.py
@@ -94,6 +94,32 @@ async def test_includes_optional_fields_when_provided(self, convex_settings):
assert body["args"]["toolCalls"] == [{"name": "t"}]
assert body["args"]["model"] == "gpt-4o"
+ @respx.mock
+ async def test_includes_interrupted_fields_when_provided(self, convex_settings):
+ route = respx.post(f"{CONVEX_URL}/api/mutation").mock(
+ return_value=httpx.Response(200, json={"value": None})
+ )
+ async with httpx.AsyncClient() as client:
+ await save_assistant_message(
+ client, "c1", "partial",
+ interrupted=True,
+ interruption_reason="Service unavailable",
+ )
+ body = json.loads(route.calls.last.request.content)
+ assert body["args"]["interrupted"] is True
+ assert body["args"]["interruptionReason"] == "Service unavailable"
+
+ @respx.mock
+ async def test_omits_interrupted_fields_by_default(self, convex_settings):
+ route = respx.post(f"{CONVEX_URL}/api/mutation").mock(
+ return_value=httpx.Response(200, json={"value": None})
+ )
+ async with httpx.AsyncClient() as client:
+ await save_assistant_message(client, "c1", "ok")
+ body = json.loads(route.calls.last.request.content)
+ assert "interrupted" not in body["args"]
+ assert "interruptionReason" not in body["args"]
+
@respx.mock
async def test_swallows_http_status_errors(self, convex_settings):
respx.post(f"{CONVEX_URL}/api/mutation").mock(