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:
co-authored by
Claude
Happy
parent
37a363da6d
commit
4f2c2fc14a
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user