diff --git a/apps/harness/k8s/base/deployment.yaml b/apps/harness/k8s/base/deployment.yaml index ab13e9c..f0b72da 100644 --- a/apps/harness/k8s/base/deployment.yaml +++ b/apps/harness/k8s/base/deployment.yaml @@ -25,6 +25,8 @@ spec: env: - name: HARNESS_WORK_DIR value: /data/harness + - name: HARNESS_KNOWLEDGE_DIR + value: /data/knowledge - name: CLAUDE_CONFIG_DIR value: /secrets/claude - name: OPENCODE_CONFIG_DIR @@ -49,6 +51,8 @@ spec: volumeMounts: - name: workspace mountPath: /data/harness + - name: knowledge + mountPath: /data/knowledge - name: claude-credentials mountPath: /secrets/claude readOnly: true @@ -75,8 +79,11 @@ spec: periodSeconds: 20 volumes: - name: workspace - emptyDir: - sizeLimit: 2Gi + persistentVolumeClaim: + claimName: harness-workspace + - name: knowledge + persistentVolumeClaim: + claimName: harness-knowledge - name: claude-credentials secret: secretName: harness-claude-credentials diff --git a/apps/harness/k8s/base/kustomization.yaml b/apps/harness/k8s/base/kustomization.yaml index d58fbe3..e62a372 100644 --- a/apps/harness/k8s/base/kustomization.yaml +++ b/apps/harness/k8s/base/kustomization.yaml @@ -1,6 +1,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: + - pvc.yaml - deployment.yaml - service.yaml - rbac.yaml diff --git a/apps/harness/k8s/base/pvc.yaml b/apps/harness/k8s/base/pvc.yaml new file mode 100644 index 0000000..6285c2d --- /dev/null +++ b/apps/harness/k8s/base/pvc.yaml @@ -0,0 +1,27 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: harness-knowledge + labels: + app: harness +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: harness-workspace + labels: + app: harness +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 10Gi diff --git a/apps/harness/src/app/api/chat/route.ts b/apps/harness/src/app/api/chat/route.ts index 72e833e..5173d01 100644 --- a/apps/harness/src/app/api/chat/route.ts +++ b/apps/harness/src/app/api/chat/route.ts @@ -32,9 +32,10 @@ const RequestSchema = z.object({ ), model: z.string(), provider: z.string(), + workspace: z.string().optional(), }); -const SYSTEM_PROMPT = `You are the Harness control plane assistant. You help the user manage and direct autonomous coding agents, tasks, knowledge documents, and models within the Harness orchestration system. +const systemPrompt = `You are the Harness control plane assistant. You help the user manage and direct autonomous coding agents, tasks, knowledge documents, and models within the Harness orchestration system. You have access to tools for: - Listing, reading, writing, and searching knowledge documents @@ -45,6 +46,11 @@ Be concise and direct. When the user asks about tasks or agents, use the tools t Format your responses in markdown when helpful. Use code blocks for IDs, JSON, and technical values.`; +function buildSystemPrompt(workspace?: string): string { + if (!workspace) return systemPrompt; + return `${systemPrompt}\n\nYou are working in the context of the repository \`${workspace}\`. When creating tasks, default to this project. When using shell or filesystem tools, prefer paths under the workspace directory.`; +} + export async function POST(request: NextRequest) { let body: z.infer; try { @@ -56,7 +62,7 @@ export async function POST(request: NextRequest) { ); } - const { model, provider } = body; + const { model, provider, workspace } = body; // Look up credentials const creds = await getRawCredentialsByProvider(provider as Provider); @@ -130,8 +136,8 @@ export async function POST(request: NextRequest) { // Stream from provider const events = useAnthropic - ? streamAnthropic(apiKey, model, SYSTEM_PROMPT, currentMessages, toolsAsAnthropic()) - : streamOpenAI(apiKey, baseUrl!, model, SYSTEM_PROMPT, currentMessages, toolsAsOpenAI()); + ? streamAnthropic(apiKey, model, buildSystemPrompt(workspace), currentMessages, toolsAsAnthropic()) + : streamOpenAI(apiKey, baseUrl!, model, buildSystemPrompt(workspace), currentMessages, toolsAsOpenAI()); for await (const event of events) { if (event.type === "text_delta") { diff --git a/apps/harness/src/components/chat-tab.tsx b/apps/harness/src/components/chat-tab.tsx index ab8c373..2d54c4e 100644 --- a/apps/harness/src/components/chat-tab.tsx +++ b/apps/harness/src/components/chat-tab.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { tokens, Label, @@ -9,34 +9,70 @@ import { SearchableDropdown, type DropdownOption, } from "./harness-design-system"; -import type { ChatMessage, ToolCallRecord, StreamEvent } from "@/lib/chat-types"; +import type { ChatMessage, ToolCallRecord, StreamEvent, Conversation } from "@/lib/chat-types"; interface ModelInfo { id: string; name: string; provider: string; + contextWindow?: number; +} + +interface RepoResult { + provider: string; + fullName: string; + url: string; + description: string; +} + +let convCounter = 1; +function newConversation(): Conversation { + const id = `conv-${Date.now()}-${convCounter++}`; + return { id, label: `Chat ${convCounter - 1}`, messages: [], model: "", provider: "" }; } export default function ChatTab({ mobile }: { mobile: boolean }) { const [models, setModels] = useState([]); - const [selectedModel, setSelectedModel] = useState(""); - const [messages, setMessages] = useState([]); + const [conversations, setConversations] = useState([newConversation()]); + const [activeId, setActiveId] = useState(conversations[0].id); + + // Streaming state (component-level, only one conversation streams at a time) const [input, setInput] = useState(""); const [streaming, setStreaming] = useState(false); const [streamText, setStreamText] = useState(""); const [streamToolCalls, setStreamToolCalls] = useState([]); const [pendingToolIds, setPendingToolIds] = useState>(new Set()); + const [thinking, setThinking] = useState(false); + + // Workspace search + const [wsQuery, setWsQuery] = useState(""); + const [wsResults, setWsResults] = useState([]); + const [wsOpen, setWsOpen] = useState(false); const abortRef = useRef(null); + const streamTextRef = useRef(""); const messagesEndRef = useRef(null); - const inputRef = useRef(null); + const wsTimerRef = useRef | null>(null); + + const active = conversations.find((c) => c.id === activeId)!; + + // ── Helpers to update active conversation ────────────────── + + const updateActive = useCallback( + (updater: (c: Conversation) => Partial) => { + setConversations((prev) => + prev.map((c) => (c.id === activeId ? { ...c, ...updater(c) } : c)), + ); + }, + [activeId], + ); + + // ── Fetch models ─────────────────────────────────────────── - // Fetch all available models from providers useEffect(() => { fetch("/api/models") .then((r) => r.json()) .then((data: ModelInfo[]) => { - // Sort: anthropic first, then alphabetically by provider, then by name const sorted = (Array.isArray(data) ? data : []).sort((a, b) => { if (a.provider === "anthropic" && b.provider !== "anthropic") return -1; if (b.provider === "anthropic" && a.provider !== "anthropic") return 1; @@ -48,16 +84,48 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { .catch(() => {}); }, []); - // Auto-scroll on new content + // ── Auto-scroll ──────────────────────────────────────────── + useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [messages, streamText, streamToolCalls]); + }, [active.messages, streamText, streamToolCalls, thinking]); - const selectedProvider = models.find((m) => m.id === selectedModel)?.provider || ""; + // ── Workspace search (debounced) ─────────────────────────── + + useEffect(() => { + if (wsTimerRef.current) clearTimeout(wsTimerRef.current); + if (wsQuery.length < 2) { + setWsResults([]); + return; + } + wsTimerRef.current = setTimeout(() => { + fetch(`/api/repos/search?q=${encodeURIComponent(wsQuery)}`) + .then((r) => r.json()) + .then((data: RepoResult[]) => setWsResults(Array.isArray(data) ? data : [])) + .catch(() => setWsResults([])); + }, 300); + }, [wsQuery]); + + // ── Context estimation ───────────────────────────────────── + + const contextStats = useMemo(() => { + const text = JSON.stringify(active.messages); + const estimatedTokens = Math.round(text.length / 4); + const selectedModel = models.find((m) => m.id === active.model); + const maxTokens = selectedModel?.contextWindow || 128_000; + const ratio = Math.min(estimatedTokens / maxTokens, 1); + return { messageCount: active.messages.length, estimatedTokens, maxTokens, ratio }; + }, [active.messages, active.model, models]); + + // ── Provider from model ──────────────────────────────────── + + const selectedProvider = models.find((m) => m.id === active.model)?.provider || active.provider; + + // ── Send message ─────────────────────────────────────────── const sendMessage = useCallback(async () => { const text = input.trim(); - if (!text || !selectedModel || streaming) return; + if (!text || !active.model || streaming) return; const userMsg: ChatMessage = { id: `msg-${Date.now()}`, @@ -66,13 +134,15 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { timestamp: Date.now(), }; - const newMessages = [...messages, userMsg]; - setMessages(newMessages); + const newMessages = [...active.messages, userMsg]; + updateActive(() => ({ messages: newMessages })); setInput(""); setStreaming(true); setStreamText(""); + streamTextRef.current = ""; setStreamToolCalls([]); setPendingToolIds(new Set()); + setThinking(true); const abort = new AbortController(); abortRef.current = abort; @@ -92,8 +162,9 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { output: tc.output, })), })), - model: selectedModel, + model: active.model, provider: selectedProvider, + workspace: active.workspace, }), signal: abort.signal, }); @@ -109,7 +180,7 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { let accText = ""; let accToolCalls: ToolCallRecord[] = []; let pending = new Set(); - let msgModel = selectedModel; + let msgModel = active.model; while (true) { const { done, value } = await reader.read(); @@ -133,22 +204,19 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { switch (event.type) { case "text_delta": + setThinking(false); accText += event.content; + streamTextRef.current = accText; setStreamText(accText); break; case "tool_call_start": + setThinking(false); pending.add(event.id); setPendingToolIds(new Set(pending)); accToolCalls = [ ...accToolCalls, - { - id: event.id, - name: event.name, - input: event.input, - output: "", - durationMs: 0, - }, + { id: event.id, name: event.name, input: event.input, output: "", durationMs: 0 }, ]; setStreamToolCalls([...accToolCalls]); break; @@ -157,18 +225,20 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { pending.delete(event.id); setPendingToolIds(new Set(pending)); accToolCalls = accToolCalls.map((tc) => - tc.id === event.id - ? { ...tc, output: event.output, durationMs: event.durationMs } - : tc, + tc.id === event.id ? { ...tc, output: event.output, durationMs: event.durationMs } : tc, ); setStreamToolCalls([...accToolCalls]); + // If no more pending tool calls, we're waiting for next model response + if (pending.size === 0) setThinking(true); break; case "message_end": msgModel = event.model; + setThinking(false); break; case "error": + setThinking(false); accText += `\n\n**Error:** ${event.message}`; setStreamText(accText); break; @@ -176,7 +246,6 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { } } - // Commit the streamed message const assistantMsg: ChatMessage = { id: `msg-${Date.now()}`, role: "assistant", @@ -185,40 +254,35 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { model: msgModel, timestamp: Date.now(), }; - setMessages((prev) => [...prev, assistantMsg]); + updateActive((c) => ({ messages: [...c.messages, assistantMsg] })); } catch (err: any) { if (err.name === "AbortError") { - // User cancelled - if (streamText) { - setMessages((prev) => [ - ...prev, - { - id: `msg-${Date.now()}`, - role: "assistant", - content: streamText + "\n\n*(cancelled)*", - timestamp: Date.now(), - }, - ]); + const captured = streamTextRef.current; + if (captured) { + updateActive((c) => ({ + messages: [ + ...c.messages, + { id: `msg-${Date.now()}`, role: "assistant" as const, content: captured + "\n\n*(cancelled)*", timestamp: Date.now() }, + ], + })); } } else { - setMessages((prev) => [ - ...prev, - { - id: `msg-${Date.now()}`, - role: "assistant", - content: `**Error:** ${err.message}`, - timestamp: Date.now(), - }, - ]); + updateActive((c) => ({ + messages: [ + ...c.messages, + { id: `msg-${Date.now()}`, role: "assistant" as const, content: `**Error:** ${err.message}`, timestamp: Date.now() }, + ], + })); } } finally { setStreaming(false); setStreamText(""); setStreamToolCalls([]); setPendingToolIds(new Set()); + setThinking(false); abortRef.current = null; } - }, [input, selectedModel, selectedProvider, streaming, messages]); + }, [input, active, selectedProvider, streaming, updateActive]); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { @@ -228,57 +292,200 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { }; const clearConversation = () => { - setMessages([]); + updateActive(() => ({ messages: [] })); setStreamText(""); setStreamToolCalls([]); }; - const stopStreaming = () => { - abortRef.current?.abort(); + const stopStreaming = () => abortRef.current?.abort(); + + // ── Conversation management ──────────────────────────────── + + const addConversation = () => { + const conv = newConversation(); + // Inherit model from active conversation + conv.model = active.model; + conv.provider = active.provider; + setConversations((prev) => [...prev, conv]); + setActiveId(conv.id); + }; + + const closeConversation = (id: string) => { + const conv = conversations.find((c) => c.id === id); + if (conv && conv.messages.length > 0 && !confirm("Close this conversation?")) return; + if (conversations.length <= 1) { + // Replace with a fresh conversation + const fresh = newConversation(); + fresh.model = active.model; + fresh.provider = active.provider; + setConversations([fresh]); + setActiveId(fresh.id); + return; + } + const remaining = conversations.filter((c) => c.id !== id); + setConversations(remaining); + if (activeId === id) { + setActiveId(remaining[remaining.length - 1].id); + } + }; + + const setWorkspace = (repo: string) => { + updateActive(() => ({ workspace: repo })); + setWsOpen(false); + setWsQuery(""); + setWsResults([]); + }; + + const clearWorkspace = () => { + updateActive(() => ({ workspace: undefined })); + }; + + // ── Model selection ──────────────────────────────────────── + + const setModel = (modelId: string) => { + const m = models.find((mod) => mod.id === modelId); + updateActive(() => ({ model: modelId, provider: m?.provider || "" })); }; - // Model dropdown options grouped by provider const modelOptions: DropdownOption[] = models.map((m) => ({ value: m.id, label: m.name, detail: m.provider, })); + // ── Context bar color ────────────────────────────────────── + + const ctxColor = + contextStats.ratio > 0.9 + ? tokens.color.fail + : contextStats.ratio > 0.75 + ? tokens.color.warn + : tokens.color.accent; + + // ── Render ───────────────────────────────────────────────── + + const pad = mobile ? tokens.space[3] : tokens.space[5]; + return ( -
- {/* Header */} +
+ {/* ── Header bar ── */}
-
+
setSelectedModel(Array.isArray(v) ? v[0] : v)} + value={active.model} + onChange={(v) => setModel(Array.isArray(v) ? v[0] : v)} placeholder="Select model..." />
- {selectedProvider && ( - - )} + {selectedProvider && } + + {/* Workspace selector */} +
+ {active.workspace ? ( +
+ + {active.workspace} + + + × + +
+ ) : ( +
+ { + setWsQuery(e.target.value); + setWsOpen(true); + }} + onFocus={() => wsQuery.length >= 2 && setWsOpen(true)} + placeholder="Workspace..." + style={{ + background: tokens.color.bg0, + border: `1px solid ${tokens.color.border0}`, + color: tokens.color.text0, + fontFamily: tokens.font.mono, + fontSize: tokens.size.sm, + padding: `${tokens.space[1]}px ${tokens.space[3]}px`, + outline: "none", + borderRadius: 0, + width: "100%", + boxSizing: "border-box" as const, + minHeight: 36, + }} + /> + {wsOpen && wsResults.length > 0 && ( +
+ {wsResults.map((r) => ( +
setWorkspace(r.fullName)} + style={{ + padding: `${tokens.space[2]}px ${tokens.space[3]}px`, + cursor: "pointer", + display: "flex", + flexDirection: "column", + gap: 2, + }} + > + + {r.fullName} + + + {r.provider}{r.description ? ` · ${r.description}` : ""} + +
+ ))} +
+ )} +
+ )} +
- {messages.length > 0 && ( + {active.messages.length > 0 && ( CLEAR @@ -286,33 +493,133 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
- {/* Messages */} + {/* ── Conversation tabs ── */} +
+ {conversations.map((conv) => { + const isActive = conv.id === activeId; + return ( +
!streaming && setActiveId(conv.id)} + style={{ + display: "flex", + alignItems: "center", + gap: tokens.space[2], + padding: `${tokens.space[2]}px ${tokens.space[4]}px`, + cursor: streaming && !isActive ? "not-allowed" : "pointer", + borderBottom: `2px solid ${isActive ? tokens.color.accent : "transparent"}`, + opacity: streaming && !isActive ? 0.4 : 1, + flexShrink: 0, + }} + > + + {conv.label} + + {conv.workspace && ( + + )} + { + e.stopPropagation(); + if (!streaming) closeConversation(conv.id); + }} + style={{ + color: tokens.color.text3, + fontSize: tokens.size.xs, + cursor: streaming ? "not-allowed" : "pointer", + lineHeight: 1, + }} + > + × + +
+ ); + })} +
!streaming && addConversation()} + style={{ + padding: `${tokens.space[2]}px ${tokens.space[3]}px`, + cursor: streaming ? "not-allowed" : "pointer", + opacity: streaming ? 0.4 : 1, + flexShrink: 0, + }} + > + + + + +
+
+ + {/* ── Context bar ── */} +
+ +
+
+
+
+ + {/* ── Messages ── */}
- {messages.length === 0 && !streaming && ( -
+ {active.messages.length === 0 && !streaming && ( +
- Select a model and start a conversation + {active.model ? "Start a conversation" : "Select a model to begin"}
)} - {messages.map((msg) => ( + {active.messages.map((msg) => ( ))} @@ -330,11 +637,7 @@ export default function ChatTab({ mobile }: { mobile: boolean }) { {streamToolCalls.length > 0 && (
{streamToolCalls.map((tc) => ( - + ))}
)} @@ -366,28 +669,45 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
)} + {/* Thinking indicator */} + {thinking && streaming && ( +
+ + +
+ )} +
- {/* Input area */} + {/* ── Input area ── */}