-
- {logs.map((log) => {
- if (log.type === "tool_call") {
- return (
-
- );
- }
+ {activeView === "orchestrator" && (
+ <>
+
+
+ {logs.map((log) => {
+ if (log.type === "tool_call") {
+ return (
+
+ );
+ }
- if (log.type === "artifact") {
- return (
-
handleOpenArtifact(log.artifactId)}
- role="button"
- tabIndex={0}
- onKeyDown={(e) => {
- if (e.key === "Enter" || e.key === " ") {
- handleOpenArtifact(log.artifactId);
- }
- }}
- >
-
- 📋 {log.artifactType}: {log.title}
-
+ if (log.type === "artifact") {
+ return (
+
handleOpenArtifact(log.artifactId)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ handleOpenArtifact(log.artifactId);
+ }
+ }}
+ >
+
+ 📋 {log.artifactType}: {log.title}
+
+
+ );
+ }
+
+ return (
+
+
{log.message}
+ {log.metadata && (
+
+ Details
+ {JSON.stringify(log.metadata, null, 2)}
+
+ )}
+
+ );
+ })}
+ {currentAssistantMessage && (
+
+ {currentAssistantMessage}
+ ...
- );
- }
-
- return (
-
-
{log.message}
- {log.metadata && (
-
- Details
- {JSON.stringify(log.metadata, null, 2)}
-
- )}
-
- );
- })}
- {currentAssistantMessage && (
-
-
{currentAssistantMessage}
-
...
+ )}
+
- )}
-
-
-
+
-
+ )}
+
+
diff --git a/packages/voice-assistant/src/ui/components/ActiveProcesses.tsx b/packages/voice-assistant/src/ui/components/ActiveProcesses.tsx
new file mode 100644
index 000000000..35cb087fc
--- /dev/null
+++ b/packages/voice-assistant/src/ui/components/ActiveProcesses.tsx
@@ -0,0 +1,102 @@
+import { Terminal, Bot } from "lucide-react";
+import type { AgentStatus } from "../../server/acp/types.js";
+
+export interface ActiveProcessesProps {
+ terminals: Array<{ id: string; name: string }>;
+ agents: Array<{ id: string; status: AgentStatus; type: "claude" }>;
+ activeProcessId: string | null;
+ activeProcessType: "terminal" | "agent" | null;
+ onSelectProcess: (id: string, type: "terminal" | "agent") => void;
+ onBackToOrchestrator: () => void;
+}
+
+function getAgentStatusColor(status: AgentStatus): string {
+ switch (status) {
+ case "initializing":
+ return "#f59e0b"; // orange
+ case "ready":
+ return "#3b82f6"; // blue
+ case "processing":
+ return "#fbbf24"; // yellow
+ case "completed":
+ return "#22c55e"; // green
+ case "failed":
+ return "#ef4444"; // red
+ case "killed":
+ return "#9ca3af"; // gray
+ default:
+ return "#9ca3af";
+ }
+}
+
+function getAgentStatusLabel(status: AgentStatus): string {
+ return status;
+}
+
+export function ActiveProcesses({
+ terminals,
+ agents,
+ activeProcessId,
+ activeProcessType,
+ onSelectProcess,
+ onBackToOrchestrator,
+}: ActiveProcessesProps) {
+ const hasActiveProcess = activeProcessId !== null && activeProcessType !== null;
+
+ return (
+
+ {hasActiveProcess && (
+
+ )}
+
+
+ {terminals.map((terminal) => (
+
+ ))}
+
+ {agents.map((agent) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/voice-assistant/src/ui/components/AgentStreamView.tsx b/packages/voice-assistant/src/ui/components/AgentStreamView.tsx
new file mode 100644
index 000000000..347836ca6
--- /dev/null
+++ b/packages/voice-assistant/src/ui/components/AgentStreamView.tsx
@@ -0,0 +1,261 @@
+import { useState, useEffect, useRef } from "react";
+import { ArrowLeft, Skull, XCircle, Loader2, CheckCircle } from "lucide-react";
+import type { AgentStatus } from "../../server/acp/types.js";
+import type { SessionNotification } from "@agentclientprotocol/sdk";
+
+export interface AgentStreamViewProps {
+ agentId: string;
+ agent: {
+ id: string;
+ status: AgentStatus;
+ createdAt: Date;
+ type: "claude";
+ };
+ updates: Array<{
+ timestamp: Date;
+ notification: SessionNotification;
+ }>;
+ onBack: () => void;
+ onKillAgent: (agentId: string) => void;
+ onCancelAgent: (agentId: string) => void;
+}
+
+function getStatusColor(status: AgentStatus): string {
+ switch (status) {
+ case "initializing":
+ return "#f59e0b";
+ case "ready":
+ return "#3b82f6";
+ case "processing":
+ return "#fbbf24";
+ case "completed":
+ return "#22c55e";
+ case "failed":
+ return "#ef4444";
+ case "killed":
+ return "#9ca3af";
+ default:
+ return "#9ca3af";
+ }
+}
+
+function getStatusIcon(status: AgentStatus) {
+ switch (status) {
+ case "initializing":
+ return
;
+ case "processing":
+ return
;
+ case "ready":
+ return
;
+ case "completed":
+ return
;
+ case "failed":
+ return
;
+ case "killed":
+ return
;
+ default:
+ return null;
+ }
+}
+
+function formatTimestamp(date: Date): string {
+ return new Intl.DateTimeFormat("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ hour12: true,
+ }).format(date);
+}
+
+function AgentUpdate({
+ update,
+}: {
+ update: { timestamp: Date; notification: SessionNotification };
+}) {
+ const [isExpanded, setIsExpanded] = useState(true);
+ const notification = update.notification as any;
+
+ // Parse different notification types
+ if (notification.type === "sessionUpdate") {
+ const sessionUpdate = notification.sessionUpdate || {};
+
+ // Handle message chunks
+ if (sessionUpdate.kind === "agent_message_chunk") {
+ return (
+
+
{formatTimestamp(update.timestamp)}
+
+
{sessionUpdate.chunk || ""}
+
+
+ );
+ }
+
+ // Handle tool calls
+ if (sessionUpdate.kind === "tool_call") {
+ return (
+
+
setIsExpanded(!isExpanded)}>
+
{formatTimestamp(update.timestamp)}
+
Tool Call: {sessionUpdate.toolName || "unknown"}
+
+ {isExpanded && sessionUpdate.arguments && (
+
+
+ {JSON.stringify(sessionUpdate.arguments, null, 2)}
+
+
+ )}
+
+ );
+ }
+
+ // Handle tool call updates
+ if (sessionUpdate.kind === "tool_call_update") {
+ return (
+
+
{formatTimestamp(update.timestamp)}
+
+
+ {sessionUpdate.status || "updating"}
+
+
+
+ );
+ }
+
+ // Handle available commands
+ if (sessionUpdate.kind === "available_commands_update") {
+ const commands = sessionUpdate.commands || [];
+ return (
+
+
setIsExpanded(!isExpanded)}>
+
{formatTimestamp(update.timestamp)}
+
Available Commands ({commands.length})
+
+ {isExpanded && (
+
+
+ {commands.map((cmd: any, idx: number) => (
+ - {cmd.name || cmd}
+ ))}
+
+
+ )}
+
+ );
+ }
+
+ // Generic session update
+ return (
+
+
{formatTimestamp(update.timestamp)}
+
+
+ {JSON.stringify(sessionUpdate, null, 2)}
+
+
+
+ );
+ }
+
+ // Fallback for unknown notification types
+ return (
+
+
{formatTimestamp(update.timestamp)}
+
+
+ {JSON.stringify(notification, null, 2)}
+
+
+
+ );
+}
+
+export function AgentStreamView({
+ agentId,
+ agent,
+ updates,
+ onBack,
+ onKillAgent,
+ onCancelAgent,
+}: AgentStreamViewProps) {
+ const updatesEndRef = useRef
(null);
+
+ // Auto-scroll to bottom when new updates arrive
+ useEffect(() => {
+ updatesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ }, [updates]);
+
+ const canCancel = agent.status === "processing";
+ const canKill = agent.status !== "killed" && agent.status !== "completed";
+
+ return (
+
+
+
+
+
+
+ Agent: {agentId.substring(0, 8)}
+
+
+ {getStatusIcon(agent.status)}
+ {agent.status}
+
+
+ Created: {formatTimestamp(agent.createdAt)}
+
+
+
+
+ {canCancel && (
+
+ )}
+ {canKill && (
+
+ )}
+
+
+
+
+
+ {updates.length === 0 ? (
+
+
No updates yet. Waiting for agent activity...
+
+ ) : (
+ updates.map((update, idx) => (
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/voice-assistant/src/ui/components/ConversationSelector.tsx b/packages/voice-assistant/src/ui/components/ConversationSelector.tsx
new file mode 100644
index 000000000..119ca050f
--- /dev/null
+++ b/packages/voice-assistant/src/ui/components/ConversationSelector.tsx
@@ -0,0 +1,218 @@
+import { useState, useEffect, useRef } from "react";
+import { MessageSquare, Plus, Trash2 } from "lucide-react";
+
+interface Conversation {
+ id: string;
+ lastUpdated: string;
+ messageCount: number;
+}
+
+interface ConversationSelectorProps {
+ currentConversationId: string | null;
+ onSelectConversation: (conversationId: string | null) => void;
+}
+
+export function ConversationSelector({
+ currentConversationId,
+ onSelectConversation,
+}: ConversationSelectorProps) {
+ const [conversations, setConversations] = useState([]);
+ const [isOpen, setIsOpen] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+ const dropdownRef = useRef(null);
+
+ // Fetch conversations when dropdown opens
+ useEffect(() => {
+ if (isOpen) {
+ fetchConversations();
+ }
+ }, [isOpen]);
+
+ // Close dropdown when clicking outside
+ useEffect(() => {
+ function handleClickOutside(event: MouseEvent) {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ }
+
+ if (isOpen) {
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ };
+ }
+ }, [isOpen]);
+
+ async function fetchConversations() {
+ setIsLoading(true);
+ try {
+ const response = await fetch("/api/conversations");
+ if (!response.ok) throw new Error("Failed to fetch conversations");
+ const data = await response.json();
+ setConversations(data);
+ } catch (error) {
+ console.error("[ConversationSelector] Failed to fetch conversations:", error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ async function handleDeleteConversation(id: string, event: React.MouseEvent) {
+ event.stopPropagation();
+
+ if (!confirm("Are you sure you want to delete this conversation?")) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/api/conversations/${id}`, {
+ method: "DELETE",
+ });
+ if (!response.ok) throw new Error("Failed to delete conversation");
+
+ // Refresh list
+ await fetchConversations();
+
+ // If we deleted the current conversation, start a new one
+ if (id === currentConversationId) {
+ onSelectConversation(null);
+ }
+ } catch (error) {
+ console.error("[ConversationSelector] Failed to delete conversation:", error);
+ alert("Failed to delete conversation");
+ }
+ }
+
+ async function handleClearAll(event: React.MouseEvent) {
+ event.stopPropagation();
+
+ if (!confirm("Are you sure you want to delete all conversations?")) {
+ return;
+ }
+
+ try {
+ // Delete all conversations
+ await Promise.all(
+ conversations.map((conv) =>
+ fetch(`/api/conversations/${conv.id}`, { method: "DELETE" })
+ )
+ );
+
+ // Refresh list
+ setConversations([]);
+
+ // Start new conversation
+ onSelectConversation(null);
+ setIsOpen(false);
+ } catch (error) {
+ console.error("[ConversationSelector] Failed to clear conversations:", error);
+ alert("Failed to clear conversations");
+ }
+ }
+
+ function handleSelectConversation(id: string) {
+ onSelectConversation(id);
+ setIsOpen(false);
+ }
+
+ function handleNewConversation() {
+ onSelectConversation(null);
+ setIsOpen(false);
+ }
+
+ function formatDate(dateString: string): string {
+ const date = new Date(dateString);
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffMins = Math.floor(diffMs / 60000);
+ const diffHours = Math.floor(diffMs / 3600000);
+ const diffDays = Math.floor(diffMs / 86400000);
+
+ if (diffMins < 1) return "Just now";
+ if (diffMins < 60) return `${diffMins}m ago`;
+ if (diffHours < 24) return `${diffHours}h ago`;
+ if (diffDays < 7) return `${diffDays}d ago`;
+ return date.toLocaleDateString();
+ }
+
+ return (
+
+
+
+ {isOpen && (
+
+
+
+
+
+ {isLoading ? (
+
Loading...
+ ) : conversations.length > 0 ? (
+ <>
+
+ {conversations.map((conversation) => (
+
handleSelectConversation(conversation.id)}
+ >
+
+
+ {conversation.messageCount} messages
+
+
+ {formatDate(conversation.lastUpdated)}
+
+
+
+
+ ))}
+
+
+
+
+
+ >
+ ) : (
+
+ No saved conversations
+
+ )}
+
+ )}
+
+ );
+}
diff --git a/packages/voice-assistant/src/ui/hooks/useWebSocket.ts b/packages/voice-assistant/src/ui/hooks/useWebSocket.ts
index 6569f61e6..6ecc8ac05 100644
--- a/packages/voice-assistant/src/ui/hooks/useWebSocket.ts
+++ b/packages/voice-assistant/src/ui/hooks/useWebSocket.ts
@@ -7,14 +7,16 @@ import type {
export interface UseWebSocketReturn {
isConnected: boolean;
+ conversationId: string | null;
send: (message: WSInboundMessage) => void;
on: (type: SessionOutboundMessage['type'], handler: (message: SessionOutboundMessage) => void) => () => void;
sendPing: () => void;
sendUserMessage: (message: string) => void;
}
-export function useWebSocket(url: string): UseWebSocketReturn {
+export function useWebSocket(url: string, conversationId?: string | null): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
+ const [currentConversationId, setCurrentConversationId] = useState(null);
const wsRef = useRef(null);
const handlersRef = useRef