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
+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>