feat: add conversation management, persistence, and mobile app spec

Major enhancements to voice assistant with conversation persistence
and mobile app planning:

- Add conversation management with multiple active conversations
- Implement conversation persistence (save/load from disk)
- Add conversation selector UI for switching between conversations
- Enhance agent prompt with per-conversation tmux sessions
- Add mobile app technical specification (React Native + Expo)
- Improve UI with active processes view and agent stream
- Add ACP (Agent Control Protocol) server implementation

Mobile spec details:
- React Native with Expo SDK 54+
- NativeWind v5 for styling
- Share Zod schemas/types from server
- Android in-call experience (InCallManager)
- Direct WebSocket connection via Tailscale
- Same audio quality as web prototype

🤖 Generated with [Claude Code](https://claude.com/claude-code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
Mohamed Boudra
2025-10-21 14:50:07 +02:00
co-authored by Claude Happy
parent 37a363da6d
commit 4f2c2fc14a
34 changed files with 6963 additions and 420 deletions
+570 -57
View File
@@ -7,36 +7,14 @@
}
.header {
display: none;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 1rem;
padding: 0.5rem;
background-color: #1a1a1a;
border-bottom: 1px solid #333;
flex-shrink: 0;
min-height: 0;
overflow: hidden;
max-width: 100%;
min-width: 0;
}
.header h1 {
font-size: 1rem;
font-weight: 600;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
}
.header-status {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
min-width: 0;
}
.audio-playing-indicator {
@@ -58,32 +36,6 @@
}
}
.status-indicator {
padding: 0.25rem 0.75rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
flex-shrink: 0;
white-space: nowrap;
}
.status-indicator.disconnected {
background-color: #444;
color: #999;
}
.status-indicator.connecting {
background-color: #ffa500;
color: #000;
animation: pulse 1.5s ease-in-out infinite;
}
.status-indicator.connected {
background-color: #22c55e;
color: #000;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
@@ -748,18 +700,579 @@
.chat-interface {
padding: 0.5rem;
}
}
.header {
padding: 0.5rem 0.5rem;
/* Conversation Selector Styles */
.conversation-selector {
position: relative;
}
.conversation-selector-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.conversation-selector-button:hover {
background-color: #333;
border-color: #555;
}
.conversation-selector-label {
font-weight: 500;
}
.conversation-selector-dropdown {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
min-width: 300px;
max-width: 400px;
max-height: 500px;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
z-index: 1000;
overflow: hidden;
display: flex;
flex-direction: column;
}
.conversation-selector-header {
padding: 0.75rem;
border-bottom: 1px solid #444;
flex-shrink: 0;
}
.conversation-selector-new {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background-color: #22c55e;
color: #000;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.conversation-selector-new:hover {
background-color: #16a34a;
}
.conversation-selector-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.conversation-selector-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
margin-bottom: 0.5rem;
background-color: #1a1a1a;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.conversation-selector-item:hover {
background-color: #333;
border-color: #555;
}
.conversation-selector-item.active {
background-color: #1e3a8a;
border-color: #3b82f6;
}
.conversation-selector-item-info {
flex: 1;
min-width: 0;
}
.conversation-selector-item-title {
font-size: 0.875rem;
font-weight: 600;
color: #e2e8f0;
margin-bottom: 0.25rem;
}
.conversation-selector-item-date {
font-size: 0.75rem;
color: #999;
}
.conversation-selector-delete {
padding: 0.5rem;
background-color: transparent;
border: none;
color: #ef4444;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.conversation-selector-delete:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.conversation-selector-footer {
padding: 0.75rem;
border-top: 1px solid #444;
flex-shrink: 0;
}
.conversation-selector-clear-all {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background-color: transparent;
border: 1px solid #ef4444;
color: #ef4444;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.conversation-selector-clear-all:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.conversation-selector-loading,
.conversation-selector-empty {
padding: 2rem 1rem;
text-align: center;
color: #999;
font-size: 0.875rem;
}
/* Active Processes Bar */
.active-processes-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: #1a1a1a;
border-top: 1px solid #333;
padding: 0.5rem;
display: flex;
align-items: center;
gap: 0.75rem;
z-index: 100;
overflow-x: auto;
flex-shrink: 0;
}
.back-to-orchestrator-button {
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.back-to-orchestrator-button:hover {
background-color: #2563eb;
}
.processes-pills {
display: flex;
gap: 0.5rem;
align-items: center;
flex: 1;
overflow-x: auto;
padding: 0.25rem 0;
}
.process-pill {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background-color: #2a2a2a;
border: 1px solid #444;
border-radius: 6px;
color: #e2e8f0;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.process-pill:hover {
background-color: #333;
border-color: #555;
transform: translateY(-2px);
}
.process-pill.active {
background-color: #1e3a8a;
border-color: #3b82f6;
}
.process-pill.terminal {
border-left: 3px solid #22c55e;
}
.process-pill.agent {
border-left: 3px solid #8b5cf6;
}
.process-name {
font-weight: 500;
}
.process-status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.process-status-indicator.running {
background-color: #22c55e;
animation: pulse 2s ease-in-out infinite;
}
.process-status-indicator.processing {
animation: pulse-processing 1s ease-in-out infinite;
}
@keyframes pulse-processing {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.3);
}
}
/* Agent Stream View */
.agent-stream-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.agent-header {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background-color: #2a2a2a;
border-bottom: 1px solid #444;
flex-shrink: 0;
}
.back-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #3b82f6;
color: #fff;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
}
.back-button:hover {
background-color: #2563eb;
}
.agent-info {
display: flex;
align-items: center;
gap: 1rem;
flex: 1;
flex-wrap: wrap;
}
.agent-id {
font-size: 0.875rem;
color: #999;
}
.agent-id-value {
font-family: monospace;
color: #e2e8f0;
font-weight: 600;
}
.agent-status-badge {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
color: #000;
}
.agent-status-badge .spinning {
animation: spin 1s linear infinite;
}
.agent-created {
font-size: 0.75rem;
color: #999;
}
.agent-controls {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.control-button {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.cancel-button {
background-color: #f59e0b;
color: #000;
}
.cancel-button:hover {
background-color: #d97706;
}
.kill-button {
background-color: #ef4444;
color: #fff;
}
.kill-button:hover {
background-color: #dc2626;
}
.agent-updates-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.agent-updates-list {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.no-updates {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 0.875rem;
font-style: italic;
}
.agent-update {
background-color: #2a2a2a;
border-radius: 6px;
padding: 0.75rem;
border-left: 3px solid #8b5cf6;
}
.agent-update.message-chunk {
border-left-color: #0f766e;
background-color: rgba(15, 118, 110, 0.1);
}
.agent-update.tool-call {
border-left-color: #f59e0b;
background-color: rgba(245, 158, 11, 0.1);
}
.agent-update.tool-call-update {
border-left-color: #fbbf24;
background-color: rgba(251, 191, 36, 0.1);
}
.agent-update.available-commands {
border-left-color: #3b82f6;
background-color: rgba(59, 130, 246, 0.1);
}
.agent-update.session-update,
.agent-update.unknown {
border-left-color: #9ca3af;
background-color: rgba(156, 163, 175, 0.1);
}
.update-header {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
margin-bottom: 0.5rem;
}
.update-header:hover {
opacity: 0.8;
}
.update-timestamp {
font-size: 0.75rem;
color: #999;
font-family: monospace;
flex-shrink: 0;
}
.update-title {
font-weight: 600;
color: #e2e8f0;
font-size: 0.875rem;
}
.update-content {
margin-top: 0.5rem;
}
.message-text {
color: #e2e8f0;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.tool-arguments,
.update-data {
background-color: #1a1a1a;
border-radius: 4px;
padding: 0.5rem;
font-family: monospace;
font-size: 0.75rem;
color: #e2e8f0;
overflow-x: auto;
border: 1px solid #444;
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
.tool-update-status {
color: #fbbf24;
font-size: 0.875rem;
font-weight: 500;
}
.commands-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.commands-list li {
padding: 0.5rem;
background-color: #1a1a1a;
border-radius: 4px;
border: 1px solid #444;
font-family: monospace;
font-size: 0.75rem;
color: #e2e8f0;
}
/* Adjust chat interface to account for active processes bar */
.chat-interface {
padding-bottom: 60px; /* Make room for the active processes bar */
}
/* Mobile responsive adjustments */
@media (max-width: 768px) {
.active-processes-bar {
padding: 0.375rem;
gap: 0.5rem;
}
.header h1 {
font-size: 0.875rem;
.back-to-orchestrator-button {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.status-indicator {
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
.process-pill {
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
}
.agent-header {
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
}
.agent-info {
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
}
.agent-controls {
width: 100%;
justify-content: stretch;
}
.control-button {
flex: 1;
}
}
+290 -117
View File
@@ -10,6 +10,16 @@ import {
} from "./lib/audio-realtime";
import { ToolCallCard } from "./components/ToolCallCard";
import { ArtifactDrawer, type Artifact } from "./components/ArtifactDrawer";
import { ConversationSelector } from "./components/ConversationSelector";
import { ActiveProcesses } from "./components/ActiveProcesses";
import { AgentStreamView } from "./components/AgentStreamView";
import {
getCurrentConversationId,
setCurrentConversationId,
clearCurrentConversationId,
} from "./lib/conversation-storage";
import type { AgentStatus } from "../server/acp/types.js";
import type { SessionNotification } from "@agentclientprotocol/sdk";
import "./App.css";
type LogEntry =
@@ -58,6 +68,22 @@ function App() {
const [isVADLoading, setIsVADLoading] = useState(false);
const [currentArtifact, setCurrentArtifact] = useState<Artifact | null>(null);
const [artifacts, setArtifacts] = useState<Map<string, Artifact>>(new Map());
const [selectedConversationId, setSelectedConversationId] = useState<
string | null
>(() => getCurrentConversationId());
const [agents, setAgents] = useState<
Map<
string,
{ id: string; status: AgentStatus; createdAt: Date; type: "claude" }
>
>(new Map());
const [agentUpdates, setAgentUpdates] = useState<
Map<string, Array<{ timestamp: Date; notification: SessionNotification }>>
>(new Map());
const [activeView, setActiveView] = useState<
"orchestrator" | "terminal" | "agent"
>("orchestrator");
const [activeProcessId, setActiveProcessId] = useState<string | null>(null);
const logEndRef = useRef<HTMLDivElement>(null);
const audioPlayerRef = useRef(createAudioPlayer());
const recorderRef = useRef<AudioRecorder | null>(null);
@@ -69,7 +95,7 @@ function App() {
window.location.host
}/ws`;
const ws = useWebSocket(wsUrl);
const ws = useWebSocket(wsUrl, selectedConversationId);
useEffect(() => {
recorderRef.current = createAudioRecorder();
@@ -236,6 +262,20 @@ function App() {
}
});
// Listen for conversation loaded confirmation
const unsubConversationLoaded = ws.on("conversation_loaded", (message) => {
if (message.type !== "conversation_loaded") return;
const { conversationId, messageCount } = message.payload;
// Save to localStorage for persistence
setCurrentConversationId(conversationId);
addLog(
"success",
`Loaded conversation with ${messageCount} messages`
);
});
// Listen for artifacts
const unsubArtifact = ws.on("artifact", (message) => {
if (message.type !== "artifact") return;
@@ -305,13 +345,75 @@ function App() {
}
});
// Listen for agent created
const unsubAgentCreated = ws.on("agent_created", (message) => {
if (message.type !== "agent_created") return;
const { agentId, status, type } = message.payload;
setAgents((prev) => {
const newMap = new Map(prev);
newMap.set(agentId, {
id: agentId,
status: status as AgentStatus,
createdAt: new Date(),
type,
});
return newMap;
});
addLog("success", `Agent ${agentId.substring(0, 8)} created`);
});
// Listen for agent updates
const unsubAgentUpdate = ws.on("agent_update", (message) => {
if (message.type !== "agent_update") return;
const { agentId, timestamp, notification } = message.payload;
setAgentUpdates((prev) => {
const newMap = new Map(prev);
const updates = newMap.get(agentId) || [];
newMap.set(agentId, [
...updates,
{
timestamp: new Date(timestamp),
notification: notification as SessionNotification,
},
]);
return newMap;
});
});
// Listen for agent status changes
const unsubAgentStatus = ws.on("agent_status", (message) => {
if (message.type !== "agent_status") return;
const { agentId, status } = message.payload;
setAgents((prev) => {
const newMap = new Map(prev);
const agent = newMap.get(agentId);
if (agent) {
newMap.set(agentId, {
...agent,
status: status as AgentStatus,
});
}
return newMap;
});
addLog("info", `Agent ${agentId.substring(0, 8)}: ${status}`);
});
return () => {
unsubStatus();
unsubActivity();
unsubChunk();
unsubTranscription();
unsubConversationLoaded();
unsubArtifact();
unsubAudioOutput();
unsubAgentCreated();
unsubAgentUpdate();
unsubAgentStatus();
};
}, [ws]);
@@ -470,6 +572,59 @@ function App() {
}
};
const handleSelectConversation = (conversationId: string | null) => {
if (conversationId === null) {
// New conversation
clearCurrentConversationId();
} else {
// Load existing conversation
setCurrentConversationId(conversationId);
}
// Update state to trigger WebSocket reconnection
setSelectedConversationId(conversationId);
// Clear UI state
setLogs([
{
id: "1",
timestamp: Date.now(),
type: "system",
message: conversationId
? "Loading conversation..."
: "Starting new conversation...",
},
]);
setUserInput("");
setCurrentAssistantMessage("");
setArtifacts(new Map());
setCurrentArtifact(null);
};
const handleSelectProcess = (id: string, type: "terminal" | "agent") => {
setActiveProcessId(id);
setActiveView(type);
};
const handleBackToOrchestrator = () => {
setActiveView("orchestrator");
setActiveProcessId(null);
};
const handleKillAgent = async (agentId: string) => {
// In a full implementation, this would send a message to the server
// For now, just log
console.log("[App] Kill agent:", agentId);
addLog("info", `Killing agent ${agentId.substring(0, 8)}...`);
};
const handleCancelAgent = async (agentId: string) => {
// In a full implementation, this would send a message to the server
// For now, just log
console.log("[App] Cancel agent:", agentId);
addLog("info", `Cancelling agent ${agentId.substring(0, 8)}...`);
};
const handleToggleRealtimeMode = async () => {
if (!ws.isConnected) return;
@@ -575,131 +730,149 @@ function App() {
return (
<div className="app">
<header className="header">
<h1>Voice Assistant</h1>
<div className="header-status">
{isPlayingAudio && (
<div className="audio-playing-indicator" title="Playing audio" />
)}
<div
className={`status-indicator ${
ws.isConnected ? "connected" : "disconnected"
}`}
>
{ws.isConnected ? "connected" : "disconnected"}
</div>
</div>
<ConversationSelector
currentConversationId={ws.conversationId}
onSelectConversation={handleSelectConversation}
/>
{isPlayingAudio && (
<div className="audio-playing-indicator" title="Playing audio" />
)}
</header>
<main className="main">
<div className="chat-interface">
<div className="activity-log">
<div className="log-entries">
{logs.map((log) => {
if (log.type === "tool_call") {
return (
<ToolCallCard
key={log.id}
toolName={log.toolName}
args={log.args}
result={log.result}
error={log.error}
status={log.status}
/>
);
}
{activeView === "orchestrator" && (
<>
<div className="activity-log">
<div className="log-entries">
{logs.map((log) => {
if (log.type === "tool_call") {
return (
<ToolCallCard
key={log.id}
toolName={log.toolName}
args={log.args}
result={log.result}
error={log.error}
status={log.status}
/>
);
}
if (log.type === "artifact") {
return (
<div
key={log.id}
className="log-entry artifact clickable"
onClick={() => handleOpenArtifact(log.artifactId)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleOpenArtifact(log.artifactId);
}
}}
>
<span className="log-message">
📋 {log.artifactType}: {log.title}
</span>
if (log.type === "artifact") {
return (
<div
key={log.id}
className="log-entry artifact clickable"
onClick={() => handleOpenArtifact(log.artifactId)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleOpenArtifact(log.artifactId);
}
}}
>
<span className="log-message">
📋 {log.artifactType}: {log.title}
</span>
</div>
);
}
return (
<div key={log.id} className={`log-entry ${log.type}`}>
<span className="log-message">{log.message}</span>
{log.metadata && (
<details className="log-metadata">
<summary>Details</summary>
<pre>{JSON.stringify(log.metadata, null, 2)}</pre>
</details>
)}
</div>
);
})}
{currentAssistantMessage && (
<div className="log-entry assistant streaming">
<span className="log-message">{currentAssistantMessage}</span>
<span className="streaming-indicator">...</span>
</div>
);
}
return (
<div key={log.id} className={`log-entry ${log.type}`}>
<span className="log-message">{log.message}</span>
{log.metadata && (
<details className="log-metadata">
<summary>Details</summary>
<pre>{JSON.stringify(log.metadata, null, 2)}</pre>
</details>
)}
</div>
);
})}
{currentAssistantMessage && (
<div className="log-entry assistant streaming">
<span className="log-message">{currentAssistantMessage}</span>
<span className="streaming-indicator">...</span>
)}
<div ref={logEndRef} />
</div>
)}
<div ref={logEndRef} />
</div>
</div>
</div>
<div className="message-form">
<textarea
ref={textareaRef}
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message"
disabled={!ws.isConnected || isRecording}
className="message-input"
rows={1}
<div className="message-form">
<textarea
ref={textareaRef}
value={userInput}
onChange={(e) => setUserInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message"
disabled={!ws.isConnected || isRecording}
className="message-input"
rows={1}
/>
<button
type="button"
onClick={handleToggleRealtimeMode}
disabled={!ws.isConnected || isRecording || isVADLoading}
className={`send-button realtime-button ${
isRealtimeMode ? "active" : ""
} ${isSpeechDetected ? "speech-detected" : ""} ${
isVADLoading ? "loading" : ""
}`}
title={
isRealtimeMode ? "Stop realtime mode" : "Start realtime mode"
}
>
{isVADLoading ? (
<span className="loading-indicator" />
) : (
<Radio size={20} />
)}
</button>
<button
type="button"
onClick={handleButtonClick}
disabled={!ws.isConnected}
className={`send-button ${isInProgress ? "cancelling" : ""} ${
isRecording ? "recording" : ""
} ${userInput.trim() ? "has-text" : ""}`}
>
{isInProgress ? (
<X size={20} />
) : isRecording ? (
<span className="recording-indicator" />
) : userInput.trim() ? (
<Send size={20} />
) : (
<Mic size={20} />
)}
</button>
</div>
</>
)}
{activeView === "agent" && activeProcessId && (
<AgentStreamView
agentId={activeProcessId}
agent={agents.get(activeProcessId)!}
updates={agentUpdates.get(activeProcessId) || []}
onBack={handleBackToOrchestrator}
onKillAgent={handleKillAgent}
onCancelAgent={handleCancelAgent}
/>
<button
type="button"
onClick={handleToggleRealtimeMode}
disabled={!ws.isConnected || isRecording || isVADLoading}
className={`send-button realtime-button ${
isRealtimeMode ? "active" : ""
} ${isSpeechDetected ? "speech-detected" : ""} ${
isVADLoading ? "loading" : ""
}`}
title={
isRealtimeMode ? "Stop realtime mode" : "Start realtime mode"
}
>
{isVADLoading ? (
<span className="loading-indicator" />
) : (
<Radio size={20} />
)}
</button>
<button
type="button"
onClick={handleButtonClick}
disabled={!ws.isConnected}
className={`send-button ${isInProgress ? "cancelling" : ""} ${
isRecording ? "recording" : ""
} ${userInput.trim() ? "has-text" : ""}`}
>
{isInProgress ? (
<X size={20} />
) : isRecording ? (
<span className="recording-indicator" />
) : userInput.trim() ? (
<Send size={20} />
) : (
<Mic size={20} />
)}
</button>
</div>
)}
<ActiveProcesses
terminals={[]}
agents={Array.from(agents.values())}
activeProcessId={activeProcessId}
activeProcessType={activeView === "orchestrator" ? null : activeView}
onSelectProcess={handleSelectProcess}
onBackToOrchestrator={handleBackToOrchestrator}
/>
</div>
</main>
@@ -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 (
<div className="active-processes-bar">
{hasActiveProcess && (
<button
className="back-to-orchestrator-button"
onClick={onBackToOrchestrator}
type="button"
>
Back to Chat
</button>
)}
<div className="processes-pills">
{terminals.map((terminal) => (
<button
key={`terminal-${terminal.id}`}
className={`process-pill terminal ${
activeProcessType === "terminal" && activeProcessId === terminal.id
? "active"
: ""
}`}
onClick={() => onSelectProcess(terminal.id, "terminal")}
type="button"
>
<Terminal size={14} />
<span className="process-name">{terminal.name}</span>
<span className="process-status-indicator running" />
</button>
))}
{agents.map((agent) => (
<button
key={`agent-${agent.id}`}
className={`process-pill agent ${
activeProcessType === "agent" && activeProcessId === agent.id
? "active"
: ""
}`}
onClick={() => onSelectProcess(agent.id, "agent")}
type="button"
data-status={agent.status}
>
<Bot size={14} />
<span className="process-name">
{agent.id.substring(0, 8)}
</span>
<span
className={`process-status-indicator ${agent.status}`}
style={{ backgroundColor: getAgentStatusColor(agent.status) }}
title={getAgentStatusLabel(agent.status)}
/>
</button>
))}
</div>
</div>
);
}
@@ -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 <Loader2 size={16} className="spinning" />;
case "processing":
return <Loader2 size={16} className="spinning" />;
case "ready":
return <CheckCircle size={16} />;
case "completed":
return <CheckCircle size={16} />;
case "failed":
return <XCircle size={16} />;
case "killed":
return <XCircle size={16} />;
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 (
<div className="agent-update message-chunk">
<div className="update-timestamp">{formatTimestamp(update.timestamp)}</div>
<div className="update-content">
<div className="message-text">{sessionUpdate.chunk || ""}</div>
</div>
</div>
);
}
// Handle tool calls
if (sessionUpdate.kind === "tool_call") {
return (
<div className="agent-update tool-call">
<div className="update-header" onClick={() => setIsExpanded(!isExpanded)}>
<div className="update-timestamp">{formatTimestamp(update.timestamp)}</div>
<div className="update-title">Tool Call: {sessionUpdate.toolName || "unknown"}</div>
</div>
{isExpanded && sessionUpdate.arguments && (
<div className="update-content">
<pre className="tool-arguments">
{JSON.stringify(sessionUpdate.arguments, null, 2)}
</pre>
</div>
)}
</div>
);
}
// Handle tool call updates
if (sessionUpdate.kind === "tool_call_update") {
return (
<div className="agent-update tool-call-update">
<div className="update-timestamp">{formatTimestamp(update.timestamp)}</div>
<div className="update-content">
<div className="tool-update-status">
{sessionUpdate.status || "updating"}
</div>
</div>
</div>
);
}
// Handle available commands
if (sessionUpdate.kind === "available_commands_update") {
const commands = sessionUpdate.commands || [];
return (
<div className="agent-update available-commands">
<div className="update-header" onClick={() => setIsExpanded(!isExpanded)}>
<div className="update-timestamp">{formatTimestamp(update.timestamp)}</div>
<div className="update-title">Available Commands ({commands.length})</div>
</div>
{isExpanded && (
<div className="update-content">
<ul className="commands-list">
{commands.map((cmd: any, idx: number) => (
<li key={idx}>{cmd.name || cmd}</li>
))}
</ul>
</div>
)}
</div>
);
}
// Generic session update
return (
<div className="agent-update session-update">
<div className="update-timestamp">{formatTimestamp(update.timestamp)}</div>
<div className="update-content">
<pre className="update-data">
{JSON.stringify(sessionUpdate, null, 2)}
</pre>
</div>
</div>
);
}
// Fallback for unknown notification types
return (
<div className="agent-update unknown">
<div className="update-timestamp">{formatTimestamp(update.timestamp)}</div>
<div className="update-content">
<pre className="update-data">
{JSON.stringify(notification, null, 2)}
</pre>
</div>
</div>
);
}
export function AgentStreamView({
agentId,
agent,
updates,
onBack,
onKillAgent,
onCancelAgent,
}: AgentStreamViewProps) {
const updatesEndRef = useRef<HTMLDivElement>(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 (
<div className="agent-stream-view">
<div className="agent-header">
<button
className="back-button"
onClick={onBack}
type="button"
>
<ArrowLeft size={20} />
<span>Back to Chat</span>
</button>
<div className="agent-info">
<div className="agent-id">
Agent: <span className="agent-id-value">{agentId.substring(0, 8)}</span>
</div>
<div
className="agent-status-badge"
style={{ backgroundColor: getStatusColor(agent.status) }}
>
{getStatusIcon(agent.status)}
<span>{agent.status}</span>
</div>
<div className="agent-created">
Created: {formatTimestamp(agent.createdAt)}
</div>
</div>
<div className="agent-controls">
{canCancel && (
<button
className="control-button cancel-button"
onClick={() => onCancelAgent(agentId)}
type="button"
>
<XCircle size={16} />
<span>Cancel</span>
</button>
)}
{canKill && (
<button
className="control-button kill-button"
onClick={() => onKillAgent(agentId)}
type="button"
>
<Skull size={16} />
<span>Kill</span>
</button>
)}
</div>
</div>
<div className="agent-updates-container">
<div className="agent-updates-list">
{updates.length === 0 ? (
<div className="no-updates">
<p>No updates yet. Waiting for agent activity...</p>
</div>
) : (
updates.map((update, idx) => (
<AgentUpdate key={idx} update={update} />
))
)}
<div ref={updatesEndRef} />
</div>
</div>
</div>
);
}
@@ -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<Conversation[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(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 (
<div className="conversation-selector" ref={dropdownRef}>
<button
className="conversation-selector-button"
onClick={() => setIsOpen(!isOpen)}
title="Manage conversations"
>
<MessageSquare size={18} />
<span className="conversation-selector-label">Conversations</span>
</button>
{isOpen && (
<div className="conversation-selector-dropdown">
<div className="conversation-selector-header">
<button
className="conversation-selector-new"
onClick={handleNewConversation}
>
<Plus size={16} />
New Conversation
</button>
</div>
{isLoading ? (
<div className="conversation-selector-loading">Loading...</div>
) : conversations.length > 0 ? (
<>
<div className="conversation-selector-list">
{conversations.map((conversation) => (
<div
key={conversation.id}
className={`conversation-selector-item ${
conversation.id === currentConversationId ? "active" : ""
}`}
onClick={() => handleSelectConversation(conversation.id)}
>
<div className="conversation-selector-item-info">
<div className="conversation-selector-item-title">
{conversation.messageCount} messages
</div>
<div className="conversation-selector-item-date">
{formatDate(conversation.lastUpdated)}
</div>
</div>
<button
className="conversation-selector-delete"
onClick={(e) =>
handleDeleteConversation(conversation.id, e)
}
title="Delete conversation"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
<div className="conversation-selector-footer">
<button
className="conversation-selector-clear-all"
onClick={handleClearAll}
>
<Trash2 size={14} />
Clear All
</button>
</div>
</>
) : (
<div className="conversation-selector-empty">
No saved conversations
</div>
)}
</div>
)}
</div>
);
}
@@ -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<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<Map<SessionOutboundMessage['type'], Set<(message: SessionOutboundMessage) => void>>>(new Map());
const reconnectTimeoutRef = useRef<number>();
@@ -25,7 +27,9 @@ export function useWebSocket(url: string): UseWebSocketReturn {
}
try {
const ws = new WebSocket(url);
// Add conversation ID to URL if provided
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WS] Connected to server');
@@ -56,6 +60,11 @@ export function useWebSocket(url: string): UseWebSocketReturn {
const sessionMessage = wsMessage.message;
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
// Track conversation ID when loaded
if (sessionMessage.type === 'conversation_loaded') {
setCurrentConversationId(sessionMessage.payload.conversationId);
}
// Call all registered handlers for this message type
const handlers = handlersRef.current.get(sessionMessage.type);
if (handlers) {
@@ -80,7 +89,7 @@ export function useWebSocket(url: string): UseWebSocketReturn {
} catch (err) {
console.error('[WS] Failed to create WebSocket:', err);
}
}, [url]);
}, [url, conversationId]);
useEffect(() => {
connect();
@@ -143,6 +152,7 @@ export function useWebSocket(url: string): UseWebSocketReturn {
return {
isConnected,
conversationId: currentConversationId,
send,
on,
sendPing,
@@ -0,0 +1,39 @@
/**
* Client-side conversation ID management using localStorage
*/
const CONVERSATION_ID_KEY = "voice-assistant-conversation-id";
/**
* Get current conversation ID from localStorage
*/
export function getCurrentConversationId(): string | null {
try {
return localStorage.getItem(CONVERSATION_ID_KEY);
} catch (error) {
console.error("[ConversationStorage] Failed to get conversation ID:", error);
return null;
}
}
/**
* Set current conversation ID in localStorage
*/
export function setCurrentConversationId(id: string): void {
try {
localStorage.setItem(CONVERSATION_ID_KEY, id);
} catch (error) {
console.error("[ConversationStorage] Failed to set conversation ID:", error);
}
}
/**
* Clear current conversation ID from localStorage
*/
export function clearCurrentConversationId(): void {
try {
localStorage.removeItem(CONVERSATION_ID_KEY);
} catch (error) {
console.error("[ConversationStorage] Failed to clear conversation ID:", error);
}
}