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:
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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(),
|
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") {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user