Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ fastembed = "4"
base64 = "0.22"
hex = "0.4"

# Compression
flate2 = "1"

# Logging and tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
Expand Down
56 changes: 56 additions & 0 deletions interface/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export interface WorkerStartedEvent {
channel_id: string | null;
worker_id: string;
task: string;
worker_type?: string;
}

export interface WorkerStatusEvent {
Expand All @@ -69,6 +70,7 @@ export interface WorkerCompletedEvent {
channel_id: string | null;
worker_id: string;
result: string;
success?: boolean;
}

export interface BranchStartedEvent {
Expand All @@ -94,6 +96,7 @@ export interface ToolStartedEvent {
process_type: ProcessType;
process_id: string;
tool_name: string;
args: string;
}

export interface ToolCompletedEvent {
Expand All @@ -103,6 +106,7 @@ export interface ToolCompletedEvent {
process_type: ProcessType;
process_id: string;
tool_name: string;
result: string;
}

export type ApiEvent =
Expand Down Expand Up @@ -193,6 +197,49 @@ export interface StatusBlockSnapshot {
/** channel_id -> StatusBlockSnapshot */
export type ChannelStatusResponse = Record<string, StatusBlockSnapshot>;

// --- Workers API types ---

export type ActionContent =
| { type: "text"; text: string }
| { type: "tool_call"; id: string; name: string; args: string };

export type TranscriptStep =
| { type: "action"; content: ActionContent[] }
| { type: "tool_result"; call_id: string; name: string; text: string };

export interface WorkerRunInfo {
id: string;
task: string;
status: string;
worker_type: string;
channel_id: string | null;
channel_name: string | null;
started_at: string;
completed_at: string | null;
has_transcript: boolean;
live_status: string | null;
tool_calls: number;
}

export interface WorkerDetailResponse {
id: string;
task: string;
result: string | null;
status: string;
worker_type: string;
channel_id: string | null;
channel_name: string | null;
started_at: string;
completed_at: string | null;
transcript: TranscriptStep[] | null;
tool_calls: number;
}

export interface WorkerListResponse {
workers: WorkerRunInfo[];
total: number;
}

export interface AgentInfo {
id: string;
display_name?: string;
Expand Down Expand Up @@ -1076,6 +1123,15 @@ export const api = {
return fetchJson<MessagesResponse>(`/channels/messages?${params}`);
},
channelStatus: () => fetchJson<ChannelStatusResponse>("/channels/status"),
workersList: (agentId: string, params: { limit?: number; offset?: number; status?: string } = {}) => {
const search = new URLSearchParams({ agent_id: agentId });
if (params.limit) search.set("limit", String(params.limit));
if (params.offset) search.set("offset", String(params.offset));
if (params.status) search.set("status", params.status);
return fetchJson<WorkerListResponse>(`/agents/workers?${search}`);
},
workerDetail: (agentId: string, workerId: string) =>
fetchJson<WorkerDetailResponse>(`/agents/workers/detail?agent_id=${encodeURIComponent(agentId)}&worker_id=${encodeURIComponent(workerId)}`),
agentMemories: (agentId: string, params: MemoriesListParams = {}) => {
const search = new URLSearchParams({ agent_id: agentId });
if (params.limit) search.set("limit", String(params.limit));
Expand Down
61 changes: 51 additions & 10 deletions interface/src/components/WebChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useEffect, useRef, useState} from "react";
import {useEffect, useMemo, useRef, useState} from "react";
import {
useWebChat,
getPortalChatSessionId,
Expand Down Expand Up @@ -174,14 +174,55 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
useWebChat(agentId);
const {liveStates} = useLiveContext();
const [input, setInput] = useState("");
const [sseMessages, setSseMessages] = useState<{id: string; role: "assistant"; content: string}[]>([]);
const messagesEndRef = useRef<HTMLDivElement>(null);
const sessionId = getPortalChatSessionId(agentId);
const activeWorkers = Object.values(liveStates[sessionId]?.workers ?? {});
const hasActiveWorkers = activeWorkers.length > 0;

// Pick up assistant messages from the global SSE stream that arrived
// after the webchat request SSE closed (e.g. worker completion retriggers).
const timeline = liveStates[sessionId]?.timeline;
const seenIdsRef = useRef(new Set<string>());
useEffect(() => {
if (!timeline) return;
// Seed seen IDs from webchat messages so we don't duplicate
for (const m of messages) seenIdsRef.current.add(m.id);

const newMessages: {id: string; role: "assistant"; content: string}[] = [];
for (const item of timeline) {
if (
item.type === "message" &&
item.role === "assistant" &&
!seenIdsRef.current.has(item.id)
) {
seenIdsRef.current.add(item.id);
newMessages.push({
id: item.id,
role: "assistant",
content: item.content,
});
}
}
if (newMessages.length > 0) {
setSseMessages((prev) => [...prev, ...newMessages]);
}
}, [timeline, messages]);

// Clear SSE messages when a new webchat send starts (they'll be in history on next load)
useEffect(() => {
if (isStreaming) setSseMessages([]);
}, [isStreaming]);

const allMessages = useMemo(() => {
const messageIds = new Set(messages.map((message) => message.id));
const dedupedSse = sseMessages.filter((message) => !messageIds.has(message.id));
return [...messages, ...dedupedSse];
}, [messages, sseMessages]);

useEffect(() => {
messagesEndRef.current?.scrollIntoView({behavior: "smooth"});
}, [messages.length, isStreaming, toolActivity.length, activeWorkers.length]);
}, [allMessages.length, isStreaming, toolActivity.length, activeWorkers.length]);

const handleSubmit = () => {
const trimmed = input.trim();
Expand All @@ -201,15 +242,15 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
</div>
)}

{messages.length === 0 && !isStreaming && (
{allMessages.length === 0 && !isStreaming && (
<div className="flex flex-col items-center justify-center py-24">
<p className="text-sm text-ink-faint">
Start a conversation with {agentId}
</p>
</div>
)}

{messages.map((message) => (
{allMessages.map((message) => (
<div key={message.id}>
{message.role === "user" ? (
<div className="flex justify-end">
Expand All @@ -225,18 +266,18 @@ export function WebChatPanel({agentId}: WebChatPanelProps) {
</div>
))}

{/* Streaming state */}
{isStreaming &&
messages[messages.length - 1]?.role !== "assistant" && (
{/* Streaming state */}
{isStreaming &&
allMessages[allMessages.length - 1]?.role !== "assistant" && (
<div>
<ToolActivityIndicator activity={toolActivity} />
{toolActivity.length === 0 && <ThinkingIndicator />}
</div>
)}

{/* Inline tool activity during streaming assistant message */}
{isStreaming &&
messages[messages.length - 1]?.role === "assistant" &&
{/* Inline tool activity during streaming assistant message */}
{isStreaming &&
allMessages[allMessages.length - 1]?.role === "assistant" &&
toolActivity.length > 0 && (
<ToolActivityIndicator activity={toolActivity} />
)}
Expand Down
Loading