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

View File

@@ -1,6 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- pvc.yaml
- deployment.yaml
- service.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(),
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<typeof RequestSchema>;
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") {

View File

@@ -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<ModelInfo[]>([]);
const [selectedModel, setSelectedModel] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [conversations, setConversations] = useState<Conversation[]>([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<ToolCallRecord[]>([]);
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 streamTextRef = useRef("");
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(() => {
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<string>();
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 (
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{ flex: 1, display: "flex", flexDirection: "column", overflow: "hidden" }}>
{/* ── Header bar ── */}
<div
style={{
display: "flex",
alignItems: "center",
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}`,
flexWrap: "wrap",
}}
>
<div style={{ minWidth: 220, flex: mobile ? 1 : undefined, maxWidth: 360 }}>
<div style={{ minWidth: 200, maxWidth: 320 }}>
<SearchableDropdown
options={modelOptions}
value={selectedModel}
onChange={(v) => setSelectedModel(Array.isArray(v) ? v[0] : v)}
value={active.model}
onChange={(v) => setModel(Array.isArray(v) ? v[0] : v)}
placeholder="Select model..."
/>
</div>
{selectedProvider && (
<Label color={tokens.color.text2}>{selectedProvider}</Label>
)}
{selectedProvider && <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] }}>
{messages.length > 0 && (
{active.messages.length > 0 && (
<Btn variant="ghost" onClick={clearConversation} disabled={streaming}>
CLEAR
</Btn>
@@ -286,33 +493,133 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
</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
style={{
flex: 1,
overflowY: "auto",
padding: `${tokens.space[4]}px ${mobile ? tokens.space[3] : tokens.space[5]}px`,
padding: `${tokens.space[4]}px ${pad}px`,
display: "flex",
flexDirection: "column",
gap: tokens.space[4],
}}
>
{messages.length === 0 && !streaming && (
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{active.messages.length === 0 && !streaming && (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<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>
</div>
)}
{messages.map((msg) => (
{active.messages.map((msg) => (
<MessageBubble key={msg.id} message={msg} />
))}
@@ -330,11 +637,7 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
{streamToolCalls.length > 0 && (
<div style={{ marginBottom: streamText ? tokens.space[3] : 0 }}>
{streamToolCalls.map((tc) => (
<ToolCallBlock
key={tc.id}
toolCall={tc}
pending={pendingToolIds.has(tc.id)}
/>
<ToolCallBlock key={tc.id} toolCall={tc} pending={pendingToolIds.has(tc.id)} />
))}
</div>
)}
@@ -366,28 +669,45 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
</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>
{/* Input area */}
{/* ── Input area ── */}
<div
style={{
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",
gap: tokens.space[3],
alignItems: "flex-end",
}}
>
<textarea
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
selectedModel ? "Message the control plane..." : "Select a model first"
}
disabled={!selectedModel}
placeholder={active.model ? "Message the control plane..." : "Select a model first"}
disabled={!active.model}
rows={1}
style={{
flex: 1,
@@ -403,19 +723,13 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
minHeight: tokens.touch.min,
maxHeight: 160,
boxSizing: "border-box",
opacity: selectedModel ? 1 : 0.4,
opacity: active.model ? 1 : 0.4,
}}
/>
{streaming ? (
<Btn variant="danger" onClick={stopStreaming}>
STOP
</Btn>
<Btn variant="danger" onClick={stopStreaming}>STOP</Btn>
) : (
<Btn
variant="primary"
onClick={sendMessage}
disabled={!input.trim() || !selectedModel}
>
<Btn variant="primary" onClick={sendMessage} disabled={!input.trim() || !active.model}>
SEND
</Btn>
)}
@@ -428,16 +742,8 @@ export default function ChatTab({ mobile }: { mobile: boolean }) {
function MessageBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === "user";
return (
<div
style={{
display: "flex",
flexDirection: "column",
alignItems: isUser ? "flex-end" : "flex-start",
gap: tokens.space[1],
}}
>
<div style={{ display: "flex", flexDirection: "column", alignItems: isUser ? "flex-end" : "flex-start", gap: tokens.space[1] }}>
<div
style={{
background: isUser ? tokens.color.bg2 : tokens.color.bg1,
@@ -477,79 +783,29 @@ function MessageBubble({ message }: { message: ChatMessage }) {
// ── Tool Call Block ──────────────────────────────────────────
function ToolCallBlock({
toolCall,
pending,
}: {
toolCall: ToolCallRecord;
pending: boolean;
}) {
function ToolCallBlock({ toolCall, pending }: { toolCall: ToolCallRecord; pending: boolean }) {
const [expanded, setExpanded] = useState(false);
return (
<div
style={{
border: `1px solid ${pending ? tokens.color.warnDim : tokens.color.border0}`,
background: tokens.color.bg0,
marginBottom: tokens.space[2],
}}
>
<div style={{ border: `1px solid ${pending ? tokens.color.warnDim : tokens.color.border0}`, background: tokens.color.bg0, marginBottom: tokens.space[2] }}>
<div
onClick={() => setExpanded(!expanded)}
style={{
display: "flex",
alignItems: "center",
gap: tokens.space[2],
padding: `${tokens.space[1]}px ${tokens.space[3]}px`,
cursor: "pointer",
minHeight: 32,
}}
style={{ display: "flex", alignItems: "center", gap: tokens.space[2], padding: `${tokens.space[1]}px ${tokens.space[3]}px`, cursor: "pointer", minHeight: 32 }}
>
<span
style={{
color: tokens.color.text3,
fontSize: tokens.size.xs,
fontFamily: tokens.font.mono,
}}
>
<span style={{ color: tokens.color.text3, fontSize: tokens.size.xs, fontFamily: tokens.font.mono }}>
{expanded ? "▾" : "▸"}
</span>
<Label
color={pending ? tokens.color.warn : tokens.color.purple}
>
{toolCall.name}
</Label>
{pending && (
<Label color={tokens.color.warn} style={{ fontSize: 11 }}>
running...
</Label>
)}
<Label color={pending ? tokens.color.warn : tokens.color.purple}>{toolCall.name}</Label>
{pending && <Label color={tokens.color.warn} style={{ fontSize: 11 }}>running...</Label>}
{!pending && toolCall.durationMs > 0 && (
<Label color={tokens.color.text3} style={{ fontSize: 11 }}>
{toolCall.durationMs}ms
</Label>
<Label color={tokens.color.text3} style={{ fontSize: 11 }}>{toolCall.durationMs}ms</Label>
)}
</div>
{expanded && (
<div
style={{
borderTop: `1px solid ${tokens.color.border0}`,
padding: `${tokens.space[2]}px ${tokens.space[3]}px`,
}}
>
<div style={{ borderTop: `1px solid ${tokens.color.border0}`, padding: `${tokens.space[2]}px ${tokens.space[3]}px` }}>
{Object.keys(toolCall.input).length > 0 && (
<div style={{ marginBottom: tokens.space[2] }}>
<Label color={tokens.color.text3}>Input</Label>
<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",
}}
>
<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" }}>
{JSON.stringify(toolCall.input, null, 2)}
</pre>
</div>
@@ -557,18 +813,7 @@ function ToolCallBlock({
{toolCall.output && (
<div>
<Label color={tokens.color.text3}>Output</Label>
<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",
}}
>
<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" }}>
{toolCall.output}
</pre>
</div>

View File

@@ -22,6 +22,15 @@ export interface ToolCallRecord {
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 */
export interface ApiMessage {
role: "user" | "assistant" | "tool_result";