Add persistent knowledge volume and enhance chat UI
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:
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
resources:
|
||||
- pvc.yaml
|
||||
- deployment.yaml
|
||||
- service.yaml
|
||||
- rbac.yaml
|
||||
|
||||
27
apps/harness/k8s/base/pvc.yaml
Normal file
27
apps/harness/k8s/base/pvc.yaml
Normal 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
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user