Add persistent knowledge volume and enhance chat UI
Some checks failed
CI / lint-and-test (push) Successful in 38s
Deploy Production / deploy (push) Successful in 1m15s
CI / build (push) Has been cancelled

Infrastructure:
- Add Longhorn PVCs for knowledge store (1Gi) and workspace (10Gi),
  replacing ephemeral emptyDir for workspace
- Set HARNESS_KNOWLEDGE_DIR=/data/knowledge env var in deployment

Chat UI improvements:
- Thinking ticker: pulsing indicator while waiting for model response
  and between tool-use rounds
- Context bar: message count, estimated token usage, color-coded fill
  bar against model context window
- Multiple conversation tabs: independent state per conversation with
  create/close/switch, model selection inherited on new tabs
- Workspace binding: per-conversation repo search that injects project
  context into the system prompt
This commit is contained in:
Julia McGhee
2026-03-22 11:17:32 +00:00
parent 642f14dd3e
commit 9830a1b742
6 changed files with 484 additions and 189 deletions

View File

@@ -25,6 +25,8 @@ spec:
env: env:
- name: HARNESS_WORK_DIR - name: HARNESS_WORK_DIR
value: /data/harness value: /data/harness
- name: HARNESS_KNOWLEDGE_DIR
value: /data/knowledge
- name: CLAUDE_CONFIG_DIR - name: CLAUDE_CONFIG_DIR
value: /secrets/claude value: /secrets/claude
- name: OPENCODE_CONFIG_DIR - name: OPENCODE_CONFIG_DIR
@@ -49,6 +51,8 @@ spec:
volumeMounts: volumeMounts:
- name: workspace - name: workspace
mountPath: /data/harness mountPath: /data/harness
- name: knowledge
mountPath: /data/knowledge
- name: claude-credentials - name: claude-credentials
mountPath: /secrets/claude mountPath: /secrets/claude
readOnly: true readOnly: true
@@ -75,8 +79,11 @@ spec:
periodSeconds: 20 periodSeconds: 20
volumes: volumes:
- name: workspace - name: workspace
emptyDir: persistentVolumeClaim:
sizeLimit: 2Gi claimName: harness-workspace
- name: knowledge
persistentVolumeClaim:
claimName: harness-knowledge
- name: claude-credentials - name: claude-credentials
secret: secret:
secretName: harness-claude-credentials secretName: harness-claude-credentials

View File

@@ -1,6 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1 apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization kind: Kustomization
resources: resources:
- pvc.yaml
- deployment.yaml - deployment.yaml
- service.yaml - service.yaml
- rbac.yaml - rbac.yaml

View File

@@ -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

View File

@@ -32,9 +32,10 @@ const RequestSchema = z.object({
), ),
model: z.string(), model: z.string(),
provider: 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: You have access to tools for:
- Listing, reading, writing, and searching knowledge documents - 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.`; 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) { export async function POST(request: NextRequest) {
let body: z.infer<typeof RequestSchema>; let body: z.infer<typeof RequestSchema>;
try { try {
@@ -56,7 +62,7 @@ export async function POST(request: NextRequest) {
); );
} }
const { model, provider } = body; const { model, provider, workspace } = body;
// Look up credentials // Look up credentials
const creds = await getRawCredentialsByProvider(provider as Provider); const creds = await getRawCredentialsByProvider(provider as Provider);
@@ -130,8 +136,8 @@ export async function POST(request: NextRequest) {
// Stream from provider // Stream from provider
const events = useAnthropic const events = useAnthropic
? streamAnthropic(apiKey, model, SYSTEM_PROMPT, currentMessages, toolsAsAnthropic()) ? streamAnthropic(apiKey, model, buildSystemPrompt(workspace), currentMessages, toolsAsAnthropic())
: streamOpenAI(apiKey, baseUrl!, model, SYSTEM_PROMPT, currentMessages, toolsAsOpenAI()); : streamOpenAI(apiKey, baseUrl!, model, buildSystemPrompt(workspace), currentMessages, toolsAsOpenAI());
for await (const event of events) { for await (const event of events) {
if (event.type === "text_delta") { if (event.type === "text_delta") {

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useRef, useCallback } from "react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { import {
tokens, tokens,
Label, Label,
@@ -9,34 +9,70 @@ import {
SearchableDropdown, SearchableDropdown,
type DropdownOption, type DropdownOption,
} from "./harness-design-system"; } 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 { interface ModelInfo {
id: string; id: string;
name: string; name: string;
provider: 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 }) { export default function ChatTab({ mobile }: { mobile: boolean }) {
const [models, setModels] = useState<ModelInfo[]>([]); const [models, setModels] = useState<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState(""); const [conversations, setConversations] = useState<Conversation[]>([newConversation()]);
const [messages, setMessages] = useState<ChatMessage[]>([]); const [activeId, setActiveId] = useState(conversations[0].id);
// Streaming state (component-level, only one conversation streams at a time)
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [streaming, setStreaming] = useState(false); const [streaming, setStreaming] = useState(false);
const [streamText, setStreamText] = useState(""); const [streamText, setStreamText] = useState("");
const [streamToolCalls, setStreamToolCalls] = useState<ToolCallRecord[]>([]); const [streamToolCalls, setStreamToolCalls] = useState<ToolCallRecord[]>([]);
const [pendingToolIds, setPendingToolIds] = useState<Set<string>>(new Set()); const [pendingToolIds, setPendingToolIds] = useState<Set<string>>(new Set());
const [thinking, setThinking] = useState(false);
// Workspace search
const [wsQuery, setWsQuery] = useState("");
const [wsResults, setWsResults] = useState<RepoResult[]>([]);
const [wsOpen, setWsOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const streamTextRef = useRef("");
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null); const wsTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const active = conversations.find((c) => c.id === activeId)!;
// ── Helpers to update active conversation ──────────────────
const updateActive = useCallback(
(updater: (c: Conversation) => Partial<Conversation>) => {
setConversations((prev) =>
prev.map((c) => (c.id === activeId ? { ...c, ...updater(c) } : c)),
);
},
[activeId],
);
// ── Fetch models ───────────────────────────────────────────
// Fetch all available models from providers
useEffect(() => { useEffect(() => {
fetch("/api/models") fetch("/api/models")
.then((r) => r.json()) .then((r) => r.json())
.then((data: ModelInfo[]) => { .then((data: ModelInfo[]) => {
// Sort: anthropic first, then alphabetically by provider, then by name
const sorted = (Array.isArray(data) ? data : []).sort((a, b) => { const sorted = (Array.isArray(data) ? data : []).sort((a, b) => {
if (a.provider === "anthropic" && b.provider !== "anthropic") return -1; if (a.provider === "anthropic" && b.provider !== "anthropic") return -1;
if (b.provider === "anthropic" && a.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(() => {}); .catch(() => {});
}, []); }, []);
// Auto-scroll on new content // ── Auto-scroll ────────────────────────────────────────────
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); 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 sendMessage = useCallback(async () => {
const text = input.trim(); const text = input.trim();
if (!text || !selectedModel || streaming) return; if (!text || !active.model || streaming) return;
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: `msg-${Date.now()}`, id: `msg-${Date.now()}`,
@@ -66,13 +134,15 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
timestamp: Date.now(), timestamp: Date.now(),
}; };
const newMessages = [...messages, userMsg]; const newMessages = [...active.messages, userMsg];
setMessages(newMessages); updateActive(() => ({ messages: newMessages }));
setInput(""); setInput("");
setStreaming(true); setStreaming(true);
setStreamText(""); setStreamText("");
streamTextRef.current = "";
setStreamToolCalls([]); setStreamToolCalls([]);
setPendingToolIds(new Set()); setPendingToolIds(new Set());
setThinking(true);
const abort = new AbortController(); const abort = new AbortController();
abortRef.current = abort; abortRef.current = abort;
@@ -92,8 +162,9 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
output: tc.output, output: tc.output,
})), })),
})), })),
model: selectedModel, model: active.model,
provider: selectedProvider, provider: selectedProvider,
workspace: active.workspace,
}), }),
signal: abort.signal, signal: abort.signal,
}); });
@@ -109,7 +180,7 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
let accText = ""; let accText = "";
let accToolCalls: ToolCallRecord[] = []; let accToolCalls: ToolCallRecord[] = [];
let pending = new Set<string>(); let pending = new Set<string>();
let msgModel = selectedModel; let msgModel = active.model;
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
@@ -133,22 +204,19 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
switch (event.type) { switch (event.type) {
case "text_delta": case "text_delta":
setThinking(false);
accText += event.content; accText += event.content;
streamTextRef.current = accText;
setStreamText(accText); setStreamText(accText);
break; break;
case "tool_call_start": case "tool_call_start":
setThinking(false);
pending.add(event.id); pending.add(event.id);
setPendingToolIds(new Set(pending)); setPendingToolIds(new Set(pending));
accToolCalls = [ accToolCalls = [
...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]); setStreamToolCalls([...accToolCalls]);
break; break;
@@ -157,18 +225,20 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
pending.delete(event.id); pending.delete(event.id);
setPendingToolIds(new Set(pending)); setPendingToolIds(new Set(pending));
accToolCalls = accToolCalls.map((tc) => accToolCalls = accToolCalls.map((tc) =>
tc.id === event.id tc.id === event.id ? { ...tc, output: event.output, durationMs: event.durationMs } : tc,
? { ...tc, output: event.output, durationMs: event.durationMs }
: tc,
); );
setStreamToolCalls([...accToolCalls]); setStreamToolCalls([...accToolCalls]);
// If no more pending tool calls, we're waiting for next model response
if (pending.size === 0) setThinking(true);
break; break;
case "message_end": case "message_end":
msgModel = event.model; msgModel = event.model;
setThinking(false);
break; break;
case "error": case "error":
setThinking(false);
accText += `\n\n**Error:** ${event.message}`; accText += `\n\n**Error:** ${event.message}`;
setStreamText(accText); setStreamText(accText);
break; break;
@@ -176,7 +246,6 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
} }
} }
// Commit the streamed message
const assistantMsg: ChatMessage = { const assistantMsg: ChatMessage = {
id: `msg-${Date.now()}`, id: `msg-${Date.now()}`,
role: "assistant", role: "assistant",
@@ -185,40 +254,35 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
model: msgModel, model: msgModel,
timestamp: Date.now(), timestamp: Date.now(),
}; };
setMessages((prev) => [...prev, assistantMsg]); updateActive((c) => ({ messages: [...c.messages, assistantMsg] }));
} catch (err: any) { } catch (err: any) {
if (err.name === "AbortError") { if (err.name === "AbortError") {
// User cancelled const captured = streamTextRef.current;
if (streamText) { if (captured) {
setMessages((prev) => [ updateActive((c) => ({
...prev, messages: [
{ ...c.messages,
id: `msg-${Date.now()}`, { id: `msg-${Date.now()}`, role: "assistant" as const, content: captured + "\n\n*(cancelled)*", timestamp: Date.now() },
role: "assistant", ],
content: streamText + "\n\n*(cancelled)*", }));
timestamp: Date.now(),
},
]);
} }
} else { } else {
setMessages((prev) => [ updateActive((c) => ({
...prev, messages: [
{ ...c.messages,
id: `msg-${Date.now()}`, { id: `msg-${Date.now()}`, role: "assistant" as const, content: `**Error:** ${err.message}`, timestamp: Date.now() },
role: "assistant", ],
content: `**Error:** ${err.message}`, }));
timestamp: Date.now(),
},
]);
} }
} finally { } finally {
setStreaming(false); setStreaming(false);
setStreamText(""); setStreamText("");
setStreamToolCalls([]); setStreamToolCalls([]);
setPendingToolIds(new Set()); setPendingToolIds(new Set());
setThinking(false);
abortRef.current = null; abortRef.current = null;
} }
}, [input, selectedModel, selectedProvider, streaming, messages]); }, [input, active, selectedProvider, streaming, updateActive]);
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
@@ -228,57 +292,200 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
}; };
const clearConversation = () => { const clearConversation = () => {
setMessages([]); updateActive(() => ({ messages: [] }));
setStreamText(""); setStreamText("");
setStreamToolCalls([]); setStreamToolCalls([]);
}; };
const stopStreaming = () => { const stopStreaming = () => abortRef.current?.abort();
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) => ({ const modelOptions: DropdownOption[] = models.map((m) => ({
value: m.id, value: m.id,
label: m.name, label: m.name,
detail: m.provider, 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 ( return (
<div <div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
style={{ {/* ── Header bar ── */}
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header */}
<div <div
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
gap: tokens.space[3], gap: tokens.space[3],
padding: `${tokens.space[3]}px ${mobile ? tokens.space[3] : tokens.space[5]}px`, padding: `${tokens.space[3]}px ${pad}px`,
borderBottom: `1px solid ${tokens.color.border0}`, borderBottom: `1px solid ${tokens.color.border0}`,
flexWrap: "wrap", flexWrap: "wrap",
}} }}
> >
<div style={{ minWidth: 220, flex: mobile ? 1 : undefined, maxWidth: 360 }}> <div style={{ minWidth: 200, maxWidth: 320 }}>
<SearchableDropdown <SearchableDropdown
options={modelOptions} options={modelOptions}
value={selectedModel} value={active.model}
onChange={(v) => setSelectedModel(Array.isArray(v) ? v[0] : v)} onChange={(v) => setModel(Array.isArray(v) ? v[0] : v)}
placeholder="Select model..." placeholder="Select model..."
/> />
</div> </div>
{selectedProvider && ( {selectedProvider && <Label color={tokens.color.text2}>{selectedProvider}</Label>}
<Label color={tokens.color.text2}>{selectedProvider}</Label>
)} {/* Workspace selector */}
<div style={{ position: "relative", minWidth: 160, maxWidth: 260 }}>
{active.workspace ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.space[2],
padding: `${tokens.space[1]}px ${tokens.space[3]}px`,
background: tokens.color.bg2,
border: `1px solid ${tokens.color.border0}`,
minHeight: 36,
}}
>
<Mono size={tokens.size.sm} color={tokens.color.text1}>
{active.workspace}
</Mono>
<span
onClick={clearWorkspace}
style={{
cursor: "pointer",
color: tokens.color.text3,
fontSize: tokens.size.xs,
marginLeft: "auto",
}}
>
×
</span>
</div>
) : (
<div>
<input
value={wsQuery}
onChange={(e) => {
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 && (
<div
style={{
position: "absolute",
top: "100%",
left: 0,
right: 0,
zIndex: 50,
background: tokens.color.bg1,
border: `1px solid ${tokens.color.border1}`,
borderTop: "none",
maxHeight: 200,
overflowY: "auto",
}}
>
{wsResults.map((r) => (
<div
key={`${r.provider}:${r.fullName}`}
onClick={() => setWorkspace(r.fullName)}
style={{
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
cursor: "pointer",
display: "flex",
flexDirection: "column",
gap: 2,
}}
>
<Mono size={tokens.size.sm} color={tokens.color.text1}>
{r.fullName}
</Mono>
<Mono size={tokens.size.xs} color={tokens.color.text3}>
{r.provider}{r.description ? ` · ${r.description}` : ""}
</Mono>
</div>
))}
</div>
)}
</div>
)}
</div>
<div style={{ marginLeft: "auto", display: "flex", gap: tokens.space[2] }}> <div style={{ marginLeft: "auto", display: "flex", gap: tokens.space[2] }}>
{messages.length > 0 && ( {active.messages.length > 0 && (
<Btn variant="ghost" onClick={clearConversation} disabled={streaming}> <Btn variant="ghost" onClick={clearConversation} disabled={streaming}>
CLEAR CLEAR
</Btn> </Btn>
@@ -286,33 +493,133 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
</div> </div>
</div> </div>
{/* Messages */} {/* ── Conversation tabs ── */}
<div
style={{
display: "flex",
alignItems: "center",
borderBottom: `1px solid ${tokens.color.border0}`,
overflowX: "auto",
flexShrink: 0,
}}
>
{conversations.map((conv) => {
const isActive = conv.id === activeId;
return (
<div
key={conv.id}
onClick={() => !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,
}}
>
<Mono
size={tokens.size.sm}
color={isActive ? tokens.color.text0 : tokens.color.text2}
>
{conv.label}
</Mono>
{conv.workspace && (
<Label color={tokens.color.text3} style={{ fontSize: 10 }}>
{conv.workspace.split("/").pop()}
</Label>
)}
<span
onClick={(e) => {
e.stopPropagation();
if (!streaming) closeConversation(conv.id);
}}
style={{
color: tokens.color.text3,
fontSize: tokens.size.xs,
cursor: streaming ? "not-allowed" : "pointer",
lineHeight: 1,
}}
>
×
</span>
</div>
);
})}
<div
onClick={() => !streaming && addConversation()}
style={{
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
cursor: streaming ? "not-allowed" : "pointer",
opacity: streaming ? 0.4 : 1,
flexShrink: 0,
}}
>
<Mono size={tokens.size.sm} color={tokens.color.text2}>
+
</Mono>
</div>
</div>
{/* ── Context bar ── */}
<div
style={{
display: "flex",
alignItems: "center",
gap: tokens.space[4],
padding: `${tokens.space[1]}px ${pad}px`,
borderBottom: `1px solid ${tokens.color.border0}`,
minHeight: 24,
}}
>
<Label color={tokens.color.text3} style={{ fontSize: 11, whiteSpace: "nowrap" }}>
{contextStats.messageCount} messages · ~{Math.round(contextStats.estimatedTokens / 1000)}k tokens
</Label>
<div
style={{
flex: 1,
maxWidth: 200,
height: 3,
background: tokens.color.bg3,
position: "relative",
}}
>
<div
style={{
position: "absolute",
left: 0,
top: 0,
height: "100%",
width: `${Math.round(contextStats.ratio * 100)}%`,
background: ctxColor,
transition: tokens.transition.normal,
}}
/>
</div>
</div>
{/* ── Messages ── */}
<div <div
style={{ style={{
flex: 1, flex: 1,
overflowY: "auto", overflowY: "auto",
padding: `${tokens.space[4]}px ${mobile ? tokens.space[3] : tokens.space[5]}px`, padding: `${tokens.space[4]}px ${pad}px`,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: tokens.space[4], gap: tokens.space[4],
}} }}
> >
{messages.length === 0 && !streaming && ( {active.messages.length === 0 && !streaming && (
<div <div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Mono size={tokens.size.sm} color={tokens.color.text3}> <Mono size={tokens.size.sm} color={tokens.color.text3}>
Select a model and start a conversation {active.model ? "Start a conversation" : "Select a model to begin"}
</Mono> </Mono>
</div> </div>
)} )}
{messages.map((msg) => ( {active.messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} /> <MessageBubble key={msg.id} message={msg} />
))} ))}
@@ -330,11 +637,7 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
{streamToolCalls.length > 0 && ( {streamToolCalls.length > 0 && (
<div style={{ marginBottom: streamText ? tokens.space[3] : 0 }}> <div style={{ marginBottom: streamText ? tokens.space[3] : 0 }}>
{streamToolCalls.map((tc) => ( {streamToolCalls.map((tc) => (
<ToolCallBlock <ToolCallBlock key={tc.id} toolCall={tc} pending={pendingToolIds.has(tc.id)} />
key={tc.id}
toolCall={tc}
pending={pendingToolIds.has(tc.id)}
/>
))} ))}
</div> </div>
)} )}
@@ -366,28 +669,45 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
</div> </div>
)} )}
{/* Thinking indicator */}
{thinking && streaming && (
<div style={{ display: "flex", alignItems: "center", gap: tokens.space[2], padding: `${tokens.space[2]}px 0` }}>
<span
style={{
display: "inline-block",
width: 6,
height: 6,
borderRadius: "50%",
background: tokens.color.accent,
animation: "hpulse 1.5s infinite",
boxShadow: tokens.color.accentGlow,
}}
/>
<Label color={tokens.color.text2} style={{ animation: "hpulse 1.5s infinite" }}>
Thinking...
</Label>
</div>
)}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Input area */} {/* ── Input area ── */}
<div <div
style={{ style={{
borderTop: `1px solid ${tokens.color.border0}`, borderTop: `1px solid ${tokens.color.border0}`,
padding: `${tokens.space[3]}px ${mobile ? tokens.space[3] : tokens.space[5]}px`, padding: `${tokens.space[3]}px ${pad}px`,
display: "flex", display: "flex",
gap: tokens.space[3], gap: tokens.space[3],
alignItems: "flex-end", alignItems: "flex-end",
}} }}
> >
<textarea <textarea
ref={inputRef}
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={ placeholder={active.model ? "Message the control plane..." : "Select a model first"}
selectedModel ? "Message the control plane..." : "Select a model first" disabled={!active.model}
}
disabled={!selectedModel}
rows={1} rows={1}
style={{ style={{
flex: 1, flex: 1,
@@ -403,19 +723,13 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
minHeight: tokens.touch.min, minHeight: tokens.touch.min,
maxHeight: 160, maxHeight: 160,
boxSizing: "border-box", boxSizing: "border-box",
opacity: selectedModel ? 1 : 0.4, opacity: active.model ? 1 : 0.4,
}} }}
/> />
{streaming ? ( {streaming ? (
<Btn variant="danger" onClick={stopStreaming}> <Btn variant="danger" onClick={stopStreaming}>STOP</Btn>
STOP
</Btn>
) : ( ) : (
<Btn <Btn variant="primary" onClick={sendMessage} disabled={!input.trim() || !active.model}>
variant="primary"
onClick={sendMessage}
disabled={!input.trim() || !selectedModel}
>
SEND SEND
</Btn> </Btn>
)} )}
@@ -428,16 +742,8 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
function MessageBubble({ message }: { message: ChatMessage }) { function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === "user"; const isUser = message.role === "user";
return ( return (
<div <div style={{ display: "flex", flexDirection: "column", alignItems: isUser ? "flex-end" : "flex-start", gap: tokens.space[1] }}>
style={{
display: "flex",
flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start",
gap: tokens.space[1],
}}
>
<div <div
style={{ style={{
background: isUser ? tokens.color.bg2 : tokens.color.bg1, background: isUser ? tokens.color.bg2 : tokens.color.bg1,
@@ -477,79 +783,29 @@ function MessageBubble({ message }: { message: ChatMessage }) {
// ── Tool Call Block ────────────────────────────────────────── // ── Tool Call Block ──────────────────────────────────────────
function ToolCallBlock({ function ToolCallBlock({ toolCall, pending }: { toolCall: ToolCallRecord; pending: boolean }) {
toolCall,
pending,
}: {
toolCall: ToolCallRecord;
pending: boolean;
}) {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
return ( return (
<div <div style={{ border: `1px solid ${pending ? tokens.color.warnDim : tokens.color.border0}`, background: tokens.color.bg0, marginBottom: tokens.space[2] }}>
style={{
border: `1px solid ${pending ? tokens.color.warnDim : tokens.color.border0}`,
background: tokens.color.bg0,
marginBottom: tokens.space[2],
}}
>
<div <div
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
style={{ style={{ display: "flex", alignItems: "center", gap: tokens.space[2], padding: `${tokens.space[1]}px ${tokens.space[3]}px`, cursor: "pointer", minHeight: 32 }}
display: "flex",
alignItems: "center",
gap: tokens.space[2],
padding: `${tokens.space[1]}px ${tokens.space[3]}px`,
cursor: "pointer",
minHeight: 32,
}}
> >
<span <span style={{ color: tokens.color.text3, fontSize: tokens.size.xs, fontFamily: tokens.font.mono }}>
style={{
color: tokens.color.text3,
fontSize: tokens.size.xs,
fontFamily: tokens.font.mono,
}}
>
{expanded ? "▾" : "▸"} {expanded ? "▾" : "▸"}
</span> </span>
<Label <Label color={pending ? tokens.color.warn : tokens.color.purple}>{toolCall.name}</Label>
color={pending ? tokens.color.warn : tokens.color.purple} {pending && <Label color={tokens.color.warn} style={{ fontSize: 11 }}>running...</Label>}
>
{toolCall.name}
</Label>
{pending && (
<Label color={tokens.color.warn} style={{ fontSize: 11 }}>
running...
</Label>
)}
{!pending && toolCall.durationMs > 0 && ( {!pending && toolCall.durationMs > 0 && (
<Label color={tokens.color.text3} style={{ fontSize: 11 }}> <Label color={tokens.color.text3} style={{ fontSize: 11 }}>{toolCall.durationMs}ms</Label>
{toolCall.durationMs}ms
</Label>
)} )}
</div> </div>
{expanded && ( {expanded && (
<div <div style={{ borderTop: `1px solid ${tokens.color.border0}`, padding: `${tokens.space[2]}px ${tokens.space[3]}px` }}>
style={{
borderTop: `1px solid ${tokens.color.border0}`,
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
}}
>
{Object.keys(toolCall.input).length > 0 && ( {Object.keys(toolCall.input).length > 0 && (
<div style={{ marginBottom: tokens.space[2] }}> <div style={{ marginBottom: tokens.space[2] }}>
<Label color={tokens.color.text3}>Input</Label> <Label color={tokens.color.text3}>Input</Label>
<pre <pre style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.xs, color: tokens.color.text2, margin: `${tokens.space[1]}px 0`, whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
style={{
fontFamily: tokens.font.mono,
fontSize: tokens.size.xs,
color: tokens.color.text2,
margin: `${tokens.space[1]}px 0`,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{JSON.stringify(toolCall.input, null, 2)} {JSON.stringify(toolCall.input, null, 2)}
</pre> </pre>
</div> </div>
@@ -557,18 +813,7 @@ function ToolCallBlock({
{toolCall.output && ( {toolCall.output && (
<div> <div>
<Label color={tokens.color.text3}>Output</Label> <Label color={tokens.color.text3}>Output</Label>
<pre <pre style={{ fontFamily: tokens.font.mono, fontSize: tokens.size.xs, color: tokens.color.text2, margin: `${tokens.space[1]}px 0`, whiteSpace: "pre-wrap", wordBreak: "break-word", maxHeight: 200, overflowY: "auto" }}>
style={{
fontFamily: tokens.font.mono,
fontSize: tokens.size.xs,
color: tokens.color.text2,
margin: `${tokens.space[1]}px 0`,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: 200,
overflowY: "auto",
}}
>
{toolCall.output} {toolCall.output}
</pre> </pre>
</div> </div>

View File

@@ -22,6 +22,15 @@ export interface ToolCallRecord {
durationMs: number; durationMs: number;
} }
export interface Conversation {
id: string;
label: string;
messages: ChatMessage[];
model: string;
provider: string;
workspace?: string;
}
/** Wire format for messages sent to/from the API route */ /** Wire format for messages sent to/from the API route */
export interface ApiMessage { export interface ApiMessage {
role: "user" | "assistant" | "tool_result"; role: "user" | "assistant" | "tool_result";