Based on the changes, I can see this is a significant feature addition that adds the ability to create agents and communicate with them through both text and audio, with a modal UI for agent creation and mode switching.
``` feat: add agent creation modal and agent-specific messaging - Add CreateAgentModal component for creating new agents with mode selection - Implement agent-specific text and audio message routing - Add mode selector modal with visual mode switching UI - Introduce unified stream reducer pattern for agent activity - Refactor AgentStreamView to use StreamItem[] instead of raw notifications - Update ActiveProcesses to show orchestrator pill and support mode switching - Add message types: send_agent_message, send_agent_audio, create_agent_request, set_agent_mode - Implement agent audio transcription and message forwarding in session handler - Add mode badge UI showing current agent mode - Update tmux capture-pane to allowed commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> ```
This commit is contained in:
+411
-138
@@ -9,6 +9,7 @@ import {
|
|||||||
ScrollView,
|
ScrollView,
|
||||||
Animated,
|
Animated,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
|
Modal,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||||
import ReanimatedAnimated, {
|
import ReanimatedAnimated, {
|
||||||
@@ -47,6 +48,8 @@ import { ActiveProcesses } from "@/components/active-processes";
|
|||||||
import { AgentStreamView } from "@/components/agent-stream-view";
|
import { AgentStreamView } from "@/components/agent-stream-view";
|
||||||
import { ConversationSelector } from "@/components/conversation-selector";
|
import { ConversationSelector } from "@/components/conversation-selector";
|
||||||
import { VolumeMeter } from "@/components/volume-meter";
|
import { VolumeMeter } from "@/components/volume-meter";
|
||||||
|
import { reduceStreamUpdate, type StreamItem } from "@/types/stream";
|
||||||
|
import { CreateAgentModal } from "@/components/create-agent-modal";
|
||||||
import {
|
import {
|
||||||
Settings,
|
Settings,
|
||||||
Mic,
|
Mic,
|
||||||
@@ -54,6 +57,8 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
AudioLines,
|
AudioLines,
|
||||||
MicOff,
|
MicOff,
|
||||||
|
Plus,
|
||||||
|
ChevronDown,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import type {
|
import type {
|
||||||
ActivityLogPayload,
|
ActivityLogPayload,
|
||||||
@@ -62,6 +67,7 @@ import type {
|
|||||||
} from "@server/server/messages";
|
} from "@server/server/messages";
|
||||||
import type { AgentStatus } from "@server/server/acp/types";
|
import type { AgentStatus } from "@server/server/acp/types";
|
||||||
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
import type { SessionNotification } from "@agentclientprotocol/sdk";
|
||||||
|
import { parseSessionUpdate } from "@/types/agent-activity";
|
||||||
|
|
||||||
type MessageEntry =
|
type MessageEntry =
|
||||||
| {
|
| {
|
||||||
@@ -277,12 +283,12 @@ export default function VoiceAssistantScreen() {
|
|||||||
onAudioSegment: (base64Audio: string) => {
|
onAudioSegment: (base64Audio: string) => {
|
||||||
console.log("[App] Sending audio segment, length:", base64Audio.length);
|
console.log("[App] Sending audio segment, length:", base64Audio.length);
|
||||||
|
|
||||||
// Send audio segment to server
|
// Send audio segment to server (realtime always goes to orchestrator)
|
||||||
try {
|
try {
|
||||||
ws.send({
|
ws.send({
|
||||||
type: "session",
|
type: "session",
|
||||||
message: {
|
message: {
|
||||||
type: "audio_chunk",
|
type: "realtime_audio_chunk",
|
||||||
audio: base64Audio,
|
audio: base64Audio,
|
||||||
format: "audio/wav",
|
format: "audio/wav",
|
||||||
isLast: true, // Complete segment
|
isLast: true, // Complete segment
|
||||||
@@ -314,6 +320,9 @@ export default function VoiceAssistantScreen() {
|
|||||||
const [messages, setMessages] = useState<MessageEntry[]>([]);
|
const [messages, setMessages] = useState<MessageEntry[]>([]);
|
||||||
const [currentAssistantMessage, setCurrentAssistantMessage] = useState("");
|
const [currentAssistantMessage, setCurrentAssistantMessage] = useState("");
|
||||||
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
|
const [isProcessingAudio, setIsProcessingAudio] = useState(false);
|
||||||
|
|
||||||
|
// Agent stream state - unified chronological stream using reducer pattern
|
||||||
|
const [agentStreamState, setAgentStreamState] = useState<Map<string, StreamItem[]>>(new Map());
|
||||||
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
|
const [isPlayingAudio, setIsPlayingAudio] = useState(false);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [userInput, setUserInput] = useState("");
|
const [userInput, setUserInput] = useState("");
|
||||||
@@ -326,6 +335,12 @@ export default function VoiceAssistantScreen() {
|
|||||||
const [viewMode, setViewMode] = useState<ViewMode>("orchestrator");
|
const [viewMode, setViewMode] = useState<ViewMode>("orchestrator");
|
||||||
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
const [activeAgentId, setActiveAgentId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Agent creation modal state
|
||||||
|
const [showCreateAgentModal, setShowCreateAgentModal] = useState(false);
|
||||||
|
|
||||||
|
// Mode selector modal state
|
||||||
|
const [showModeSelector, setShowModeSelector] = useState(false);
|
||||||
|
|
||||||
// Agent and command state
|
// Agent and command state
|
||||||
const [agents, setAgents] = useState<Map<string, Agent>>(new Map());
|
const [agents, setAgents] = useState<Map<string, Agent>>(new Map());
|
||||||
const [commands, setCommands] = useState<Map<string, Command>>(new Map());
|
const [commands, setCommands] = useState<Map<string, Command>>(new Map());
|
||||||
@@ -389,8 +404,11 @@ export default function VoiceAssistantScreen() {
|
|||||||
"commands"
|
"commands"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update agents
|
// Update agents (convert createdAt string to Date)
|
||||||
setAgents(new Map(agentsList.map((a) => [a.id, a as Agent])));
|
setAgents(new Map(agentsList.map((a) => [a.id, {
|
||||||
|
...a,
|
||||||
|
createdAt: new Date(a.createdAt),
|
||||||
|
} as Agent])));
|
||||||
|
|
||||||
// Update commands
|
// Update commands
|
||||||
setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
|
setCommands(new Map(commandsList.map((c) => [c.id, c as Command])));
|
||||||
@@ -402,7 +420,7 @@ export default function VoiceAssistantScreen() {
|
|||||||
const { agentId, status, type, currentModeId, availableModes } =
|
const { agentId, status, type, currentModeId, availableModes } =
|
||||||
message.payload;
|
message.payload;
|
||||||
|
|
||||||
console.log("[App] Agent created:", agentId);
|
console.log("[App] Agent created:", agentId, "currentModeId:", currentModeId, "availableModes:", availableModes);
|
||||||
|
|
||||||
const agent: Agent = {
|
const agent: Agent = {
|
||||||
id: agentId,
|
id: agentId,
|
||||||
@@ -415,6 +433,10 @@ export default function VoiceAssistantScreen() {
|
|||||||
|
|
||||||
setAgents((prev) => new Map(prev).set(agentId, agent));
|
setAgents((prev) => new Map(prev).set(agentId, agent));
|
||||||
setAgentUpdates((prev) => new Map(prev).set(agentId, []));
|
setAgentUpdates((prev) => new Map(prev).set(agentId, []));
|
||||||
|
|
||||||
|
// Auto-switch to agent view
|
||||||
|
setActiveAgentId(agentId);
|
||||||
|
setViewMode("agent");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Agent update handler
|
// Agent update handler
|
||||||
@@ -424,6 +446,7 @@ export default function VoiceAssistantScreen() {
|
|||||||
|
|
||||||
console.log("[App] Agent update:", agentId);
|
console.log("[App] Agent update:", agentId);
|
||||||
|
|
||||||
|
// Store raw update for agent stream view
|
||||||
setAgentUpdates((prev) => {
|
setAgentUpdates((prev) => {
|
||||||
const updated = new Map(prev);
|
const updated = new Map(prev);
|
||||||
const updates = updated.get(agentId) || [];
|
const updates = updated.get(agentId) || [];
|
||||||
@@ -436,6 +459,15 @@ export default function VoiceAssistantScreen() {
|
|||||||
]);
|
]);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Reduce stream update using unified reducer pattern
|
||||||
|
setAgentStreamState((prev) => {
|
||||||
|
const currentStream = prev.get(agentId) || [];
|
||||||
|
const nextStream = reduceStreamUpdate(currentStream, notification, new Date(timestamp));
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(agentId, nextStream);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Agent status handler
|
// Agent status handler
|
||||||
@@ -454,6 +486,8 @@ export default function VoiceAssistantScreen() {
|
|||||||
status: status as AgentStatus,
|
status: status as AgentStatus,
|
||||||
sessionId: info.sessionId,
|
sessionId: info.sessionId,
|
||||||
error: info.error,
|
error: info.error,
|
||||||
|
currentModeId: info.currentModeId,
|
||||||
|
availableModes: info.availableModes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
@@ -808,16 +842,44 @@ export default function VoiceAssistantScreen() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Send to server (properly typed)
|
// Route audio based on view mode
|
||||||
const audioMessage: WSInboundMessage = {
|
let audioMessage: WSInboundMessage;
|
||||||
type: "session",
|
if (isRealtimeMode) {
|
||||||
message: {
|
// Send as realtime audio chunk to orchestrator (speech-to-speech with TTS)
|
||||||
type: "audio_chunk",
|
audioMessage = {
|
||||||
audio: base64Audio,
|
type: "session",
|
||||||
format: format,
|
message: {
|
||||||
isLast: true,
|
type: "realtime_audio_chunk",
|
||||||
},
|
audio: base64Audio,
|
||||||
};
|
format: format,
|
||||||
|
isLast: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else if (viewMode === "agent" && activeAgentId) {
|
||||||
|
// Send as agent audio (will be transcribed, no TTS)
|
||||||
|
audioMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "send_agent_audio",
|
||||||
|
agentId: activeAgentId,
|
||||||
|
audio: base64Audio,
|
||||||
|
format: format,
|
||||||
|
isLast: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Send as regular audio to orchestrator (will be transcribed, no TTS)
|
||||||
|
audioMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "realtime_audio_chunk",
|
||||||
|
audio: base64Audio,
|
||||||
|
format: format,
|
||||||
|
isLast: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ws.send(audioMessage);
|
ws.send(audioMessage);
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
@@ -903,6 +965,43 @@ export default function VoiceAssistantScreen() {
|
|||||||
// TODO: Implement cancel agent API call
|
// TODO: Implement cancel agent API call
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent creation handler
|
||||||
|
function handleCreateAgent(workingDir: string, mode: string) {
|
||||||
|
console.log("[App] Creating agent in:", workingDir, "with mode:", mode);
|
||||||
|
|
||||||
|
// Send create agent request to server
|
||||||
|
const message: WSInboundMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "create_agent_request",
|
||||||
|
cwd: workingDir,
|
||||||
|
initialMode: mode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(message);
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
setShowCreateAgentModal(false);
|
||||||
|
|
||||||
|
// The agent_created event handler will switch to agent view automatically
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode change handler
|
||||||
|
function handleModeChange(modeId: string) {
|
||||||
|
if (!activeAgentId) return;
|
||||||
|
|
||||||
|
const message: WSInboundMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "set_agent_mode",
|
||||||
|
agentId: activeAgentId,
|
||||||
|
modeId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(message);
|
||||||
|
setShowModeSelector(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Text message handlers
|
// Text message handlers
|
||||||
function handleSendMessage() {
|
function handleSendMessage() {
|
||||||
if (!userInput.trim() || !ws.isConnected) return;
|
if (!userInput.trim() || !ws.isConnected) return;
|
||||||
@@ -911,8 +1010,44 @@ export default function VoiceAssistantScreen() {
|
|||||||
audioPlayer.stop();
|
audioPlayer.stop();
|
||||||
setIsPlayingAudio(false);
|
setIsPlayingAudio(false);
|
||||||
|
|
||||||
// Send message to server using the hook's method
|
// Route message based on view mode
|
||||||
ws.sendUserMessage(userInput);
|
if (isRealtimeMode) {
|
||||||
|
// Realtime mode always routes to orchestrator (handled by realtime audio)
|
||||||
|
ws.sendUserMessage(userInput);
|
||||||
|
} else if (viewMode === "agent" && activeAgentId) {
|
||||||
|
// Optimistically add user message to stream
|
||||||
|
setAgentStreamState((prev) => {
|
||||||
|
const currentStream = prev.get(activeAgentId) || [];
|
||||||
|
const nextStream = reduceStreamUpdate(
|
||||||
|
currentStream,
|
||||||
|
{
|
||||||
|
type: "sessionUpdate",
|
||||||
|
update: {
|
||||||
|
sessionUpdate: "user_message_chunk",
|
||||||
|
content: { type: "text", text: userInput },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(activeAgentId, nextStream);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to agent
|
||||||
|
const message: WSInboundMessage = {
|
||||||
|
type: "session",
|
||||||
|
message: {
|
||||||
|
type: "send_agent_message",
|
||||||
|
agentId: activeAgentId,
|
||||||
|
text: userInput,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(message);
|
||||||
|
} else {
|
||||||
|
// Send to orchestrator
|
||||||
|
ws.sendUserMessage(userInput);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear input and reset streaming state
|
// Clear input and reset streaming state
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
@@ -1022,7 +1157,9 @@ export default function VoiceAssistantScreen() {
|
|||||||
function handleSelectConversation(newConversationId: string | null) {
|
function handleSelectConversation(newConversationId: string | null) {
|
||||||
// Clear all state
|
// Clear all state
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setAgentMessages(new Map());
|
||||||
setCurrentAssistantMessage("");
|
setCurrentAssistantMessage("");
|
||||||
|
setCurrentAgentMessages(new Map());
|
||||||
setUserInput("");
|
setUserInput("");
|
||||||
setArtifacts(new Map());
|
setArtifacts(new Map());
|
||||||
setCurrentArtifact(null);
|
setCurrentArtifact(null);
|
||||||
@@ -1045,32 +1182,11 @@ export default function VoiceAssistantScreen() {
|
|||||||
setConversationId(newConversationId);
|
setConversationId(newConversationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render agent stream view
|
// Calculate agent data (used when viewMode === "agent")
|
||||||
if (viewMode === "agent" && activeAgentId) {
|
const agent = activeAgentId ? agents.get(activeAgentId) : null;
|
||||||
const agent = agents.get(activeAgentId);
|
const streamItems = activeAgentId ? (agentStreamState.get(activeAgentId) || []) : [];
|
||||||
const updates = agentUpdates.get(activeAgentId) || [];
|
|
||||||
|
|
||||||
if (!agent) {
|
// Render main view with shared structure
|
||||||
return (
|
|
||||||
<View style={styles.agentNotFoundContainer}>
|
|
||||||
<Text style={styles.agentNotFoundText}>Agent not found</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AgentStreamView
|
|
||||||
agentId={activeAgentId}
|
|
||||||
agent={agent}
|
|
||||||
updates={updates}
|
|
||||||
onBack={handleBackToOrchestrator}
|
|
||||||
onKillAgent={handleKillAgent}
|
|
||||||
onCancelAgent={handleCancelAgent}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render orchestrator view (main chat)
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
@@ -1086,6 +1202,16 @@ export default function VoiceAssistantScreen() {
|
|||||||
onSelectConversation={handleSelectConversation}
|
onSelectConversation={handleSelectConversation}
|
||||||
websocket={ws}
|
websocket={ws}
|
||||||
/>
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowCreateAgentModal(true)}
|
||||||
|
disabled={!ws.isConnected}
|
||||||
|
style={[
|
||||||
|
styles.settingsButton,
|
||||||
|
!ws.isConnected && styles.buttonDisabled,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Plus size={20} color="white" />
|
||||||
|
</Pressable>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => router.push("/settings")}
|
onPress={() => router.push("/settings")}
|
||||||
style={styles.settingsButton}
|
style={styles.settingsButton}
|
||||||
@@ -1100,10 +1226,10 @@ export default function VoiceAssistantScreen() {
|
|||||||
<ActiveProcesses
|
<ActiveProcesses
|
||||||
agents={Array.from(agents.values())}
|
agents={Array.from(agents.values())}
|
||||||
commands={Array.from(commands.values())}
|
commands={Array.from(commands.values())}
|
||||||
activeProcessId={activeAgentId}
|
viewMode={viewMode}
|
||||||
activeProcessType={activeAgentId ? "agent" : null}
|
activeAgentId={activeAgentId}
|
||||||
onSelectAgent={handleSelectAgent}
|
onSelectAgent={handleSelectAgent}
|
||||||
onBackToOrchestrator={handleBackToOrchestrator}
|
onSelectOrchestrator={handleBackToOrchestrator}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -1111,100 +1237,115 @@ export default function VoiceAssistantScreen() {
|
|||||||
<ReanimatedAnimated.View
|
<ReanimatedAnimated.View
|
||||||
style={[styles.contentArea, animatedKeyboardStyle]}
|
style={[styles.contentArea, animatedKeyboardStyle]}
|
||||||
>
|
>
|
||||||
{/* Scrollable Messages Area */}
|
{/* Conditionally render content based on view mode */}
|
||||||
<ScrollView
|
{viewMode === "agent" && activeAgentId && agent ? (
|
||||||
ref={scrollViewRef}
|
// Agent view - render AgentStreamView
|
||||||
style={styles.scrollView}
|
<AgentStreamView
|
||||||
contentContainerStyle={[
|
agentId={activeAgentId}
|
||||||
styles.scrollContent,
|
agent={agent}
|
||||||
messages.length === 0 && styles.emptyStateContainer,
|
streamItems={streamItems}
|
||||||
]}
|
/>
|
||||||
keyboardShouldPersistTaps="handled"
|
) : viewMode === "agent" && activeAgentId && !agent ? (
|
||||||
keyboardDismissMode="on-drag"
|
// Agent not found
|
||||||
>
|
<View style={styles.agentNotFoundContainer}>
|
||||||
{messages.length === 0 && !currentAssistantMessage && (
|
<Text style={styles.agentNotFoundText}>Agent not found</Text>
|
||||||
<View style={styles.emptyState}>
|
</View>
|
||||||
<Text style={styles.emptyStateTitle}>OnTheGo</Text>
|
) : (
|
||||||
<Text style={styles.emptyStateSubtitle}>
|
// Orchestrator view - render scrollable messages
|
||||||
What would you like to work on?
|
<ScrollView
|
||||||
</Text>
|
ref={scrollViewRef}
|
||||||
</View>
|
style={styles.scrollView}
|
||||||
)}
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
messages.length === 0 && styles.emptyStateContainer,
|
||||||
|
]}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
|
{messages.length === 0 && !currentAssistantMessage && (
|
||||||
|
<View style={styles.emptyState}>
|
||||||
|
<Text style={styles.emptyStateTitle}>OnTheGo</Text>
|
||||||
|
<Text style={styles.emptyStateSubtitle}>
|
||||||
|
What would you like to work on?
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{messages.map((msg) => {
|
{messages.map((msg) => {
|
||||||
if (msg.type === "user") {
|
if (msg.type === "user") {
|
||||||
return (
|
return (
|
||||||
<UserMessage
|
<UserMessage
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg.message}
|
message={msg.message}
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "assistant") {
|
if (msg.type === "assistant") {
|
||||||
return (
|
return (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
message={msg.message}
|
message={msg.message}
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "activity") {
|
if (msg.type === "activity") {
|
||||||
return (
|
return (
|
||||||
<ActivityLog
|
<ActivityLog
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
type={msg.activityType}
|
type={msg.activityType}
|
||||||
message={msg.message}
|
message={msg.message}
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
metadata={msg.metadata}
|
metadata={msg.metadata}
|
||||||
onArtifactClick={handleArtifactClick}
|
onArtifactClick={handleArtifactClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "artifact") {
|
if (msg.type === "artifact") {
|
||||||
return (
|
return (
|
||||||
<ActivityLog
|
<ActivityLog
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
type="artifact"
|
type="artifact"
|
||||||
message=""
|
message=""
|
||||||
timestamp={msg.timestamp}
|
timestamp={msg.timestamp}
|
||||||
artifactId={msg.artifactId}
|
artifactId={msg.artifactId}
|
||||||
artifactType={msg.artifactType}
|
artifactType={msg.artifactType}
|
||||||
title={msg.title}
|
title={msg.title}
|
||||||
onArtifactClick={handleArtifactClick}
|
onArtifactClick={handleArtifactClick}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === "tool_call") {
|
if (msg.type === "tool_call") {
|
||||||
return (
|
return (
|
||||||
<ToolCall
|
<ToolCall
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
toolName={msg.toolName}
|
toolName={msg.toolName}
|
||||||
args={msg.args}
|
args={msg.args}
|
||||||
result={msg.result}
|
result={msg.result}
|
||||||
error={msg.error}
|
error={msg.error}
|
||||||
status={msg.status}
|
status={msg.status}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Streaming assistant message */}
|
{/* Streaming assistant message */}
|
||||||
{currentAssistantMessage && (
|
{currentAssistantMessage && (
|
||||||
<AssistantMessage
|
<AssistantMessage
|
||||||
message={currentAssistantMessage}
|
message={currentAssistantMessage}
|
||||||
timestamp={Date.now()}
|
timestamp={Date.now()}
|
||||||
isStreaming={true}
|
isStreaming={true}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Fixed Footer */}
|
{/* Fixed Footer */}
|
||||||
<View
|
<View
|
||||||
@@ -1282,8 +1423,26 @@ export default function VoiceAssistantScreen() {
|
|||||||
editable={!isRecording && ws.isConnected}
|
editable={!isRecording && ws.isConnected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Mode badge and buttons row */}
|
||||||
<View style={styles.buttonRow}>
|
<View style={styles.controlsRow}>
|
||||||
|
{/* Session mode badge - only show in agent view */}
|
||||||
|
{viewMode === "agent" && activeAgentId && agent && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setShowModeSelector(true)}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.modeBadge,
|
||||||
|
pressed && styles.modeBadgePressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.modeBadgeText}>
|
||||||
|
{agent.availableModes?.find(m => m.id === agent.currentModeId)?.name || agent.currentModeId || 'default'}
|
||||||
|
</Text>
|
||||||
|
<ChevronDown size={14} color={defaultTheme.colors.palette.blue[400]} />
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
{userInput.trim().length > 0 ? (
|
{userInput.trim().length > 0 ? (
|
||||||
// Send button when text is entered
|
// Send button when text is entered
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -1340,6 +1499,7 @@ export default function VoiceAssistantScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -1351,6 +1511,53 @@ export default function VoiceAssistantScreen() {
|
|||||||
artifact={currentArtifact}
|
artifact={currentArtifact}
|
||||||
onClose={handleCloseArtifact}
|
onClose={handleCloseArtifact}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Create agent modal */}
|
||||||
|
<CreateAgentModal
|
||||||
|
isVisible={showCreateAgentModal}
|
||||||
|
onClose={() => setShowCreateAgentModal(false)}
|
||||||
|
onCreateAgent={handleCreateAgent}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Mode selector modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showModeSelector}
|
||||||
|
animationType="fade"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={() => setShowModeSelector(false)}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
onPress={() => setShowModeSelector(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.modeSelectorContent}>
|
||||||
|
{agent?.availableModes?.map((mode) => {
|
||||||
|
const isActive = mode.id === agent.currentModeId;
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={mode.id}
|
||||||
|
onPress={() => handleModeChange(mode.id)}
|
||||||
|
style={[
|
||||||
|
styles.modeItem,
|
||||||
|
isActive && styles.modeItemActive,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.modeName,
|
||||||
|
isActive && styles.modeNameActive,
|
||||||
|
]}>{mode.name}</Text>
|
||||||
|
{mode.description && (
|
||||||
|
<Text style={[
|
||||||
|
styles.modeDescription,
|
||||||
|
isActive && styles.modeDescriptionActive,
|
||||||
|
]}>{mode.description}</Text>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1362,12 +1569,13 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
agentNotFoundContainer: {
|
agentNotFoundContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.background,
|
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
padding: theme.spacing[6],
|
||||||
},
|
},
|
||||||
agentNotFoundText: {
|
agentNotFoundText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.mutedForeground,
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
@@ -1446,15 +1654,20 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
marginBottom: theme.spacing[3],
|
marginBottom: theme.spacing[3],
|
||||||
maxHeight: 128,
|
maxHeight: 128,
|
||||||
},
|
},
|
||||||
buttonRow: {
|
controlsRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[4],
|
paddingHorizontal: theme.spacing[4],
|
||||||
paddingBottom: theme.spacing[2],
|
paddingBottom: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
marginLeft: "auto",
|
||||||
|
},
|
||||||
realtimeButton: {
|
realtimeButton: {
|
||||||
width: 40,
|
width: 40,
|
||||||
height: 40,
|
height: 40,
|
||||||
@@ -1529,4 +1742,64 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
fontFamily: "monospace",
|
fontFamily: "monospace",
|
||||||
},
|
},
|
||||||
|
modeBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[3],
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.palette.blue[950],
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.palette.blue[800],
|
||||||
|
},
|
||||||
|
modeBadgePressed: {
|
||||||
|
backgroundColor: theme.colors.palette.blue[900],
|
||||||
|
borderColor: theme.colors.palette.blue[700],
|
||||||
|
},
|
||||||
|
modeBadgeText: {
|
||||||
|
color: theme.colors.palette.blue[400],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
modeSelectorContent: {
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
minWidth: 280,
|
||||||
|
maxWidth: 320,
|
||||||
|
},
|
||||||
|
modeItem: {
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.md,
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
backgroundColor: theme.colors.muted,
|
||||||
|
},
|
||||||
|
modeItemActive: {
|
||||||
|
backgroundColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
modeName: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
modeNameActive: {
|
||||||
|
color: theme.colors.primaryForeground,
|
||||||
|
},
|
||||||
|
modeDescription: {
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
modeDescriptionActive: {
|
||||||
|
color: theme.colors.primaryForeground,
|
||||||
|
opacity: theme.opacity[80],
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ export interface ActiveProcessesProps {
|
|||||||
isDead: boolean;
|
isDead: boolean;
|
||||||
exitCode: number | null;
|
exitCode: number | null;
|
||||||
}>;
|
}>;
|
||||||
activeProcessId: string | null;
|
viewMode: 'orchestrator' | 'agent';
|
||||||
activeProcessType: 'agent' | null;
|
activeAgentId: string | null;
|
||||||
onSelectAgent: (id: string) => void;
|
onSelectAgent: (id: string) => void;
|
||||||
onBackToOrchestrator: () => void;
|
onSelectOrchestrator: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAgentStatusColor(status: AgentStatus): string {
|
function getAgentStatusColor(status: AgentStatus): string {
|
||||||
@@ -122,6 +122,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
processTextActive: {
|
||||||
|
color: theme.colors.primaryForeground,
|
||||||
|
},
|
||||||
statusDot: {
|
statusDot: {
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
@@ -138,39 +141,43 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
export function ActiveProcesses({
|
export function ActiveProcesses({
|
||||||
agents,
|
agents,
|
||||||
commands,
|
commands,
|
||||||
activeProcessId,
|
viewMode,
|
||||||
activeProcessType,
|
activeAgentId,
|
||||||
onSelectAgent,
|
onSelectAgent,
|
||||||
onBackToOrchestrator,
|
onSelectOrchestrator,
|
||||||
}: ActiveProcessesProps) {
|
}: ActiveProcessesProps) {
|
||||||
const hasActiveProcess = activeProcessId !== null && activeProcessType !== null;
|
// Only show if there's at least one agent
|
||||||
|
if (agents.length === 0) {
|
||||||
if (agents.length === 0 && commands.length === 0) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{hasActiveProcess && (
|
|
||||||
<View style={styles.header}>
|
|
||||||
<Pressable
|
|
||||||
onPress={onBackToOrchestrator}
|
|
||||||
style={({ pressed }) => [styles.backButton, pressed && styles.backButtonActive]}
|
|
||||||
>
|
|
||||||
<Text style={styles.backButtonText}>Back to Chat</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={{ gap: 8 }}
|
contentContainerStyle={{ gap: 8 }}
|
||||||
>
|
>
|
||||||
|
{/* Orchestrator pill */}
|
||||||
|
<Pressable
|
||||||
|
onPress={onSelectOrchestrator}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.processItem,
|
||||||
|
viewMode === 'orchestrator' ? styles.processItemActive : styles.processItemInactive,
|
||||||
|
pressed && { opacity: 0.7 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.agentIcon} />
|
||||||
|
<Text style={[
|
||||||
|
styles.processText,
|
||||||
|
viewMode === 'orchestrator' && styles.processTextActive,
|
||||||
|
]}>Orchestrator</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{/* Agent pills */}
|
||||||
{agents.map((agent) => {
|
{agents.map((agent) => {
|
||||||
const modeName = getModeName(agent.currentModeId, agent.availableModes);
|
const isActive = viewMode === 'agent' && activeAgentId === agent.id;
|
||||||
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -184,7 +191,10 @@ export function ActiveProcesses({
|
|||||||
>
|
>
|
||||||
<View style={styles.agentIcon} />
|
<View style={styles.agentIcon} />
|
||||||
|
|
||||||
<Text style={styles.processText}>{agent.id.substring(0, 8)}</Text>
|
<Text style={[
|
||||||
|
styles.processText,
|
||||||
|
isActive && styles.processTextActive,
|
||||||
|
]}>{agent.id.substring(0, 8)}</Text>
|
||||||
|
|
||||||
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
|
<View style={[styles.statusDot, { backgroundColor: getAgentStatusColor(agent.status) }]} />
|
||||||
|
|
||||||
@@ -194,26 +204,6 @@ export function ActiveProcesses({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{commands.map((command) => {
|
|
||||||
const statusColor = command.isDead
|
|
||||||
? command.exitCode === 0
|
|
||||||
? '#22c55e'
|
|
||||||
: '#ef4444'
|
|
||||||
: '#3b82f6';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View key={`command-${command.id}`} style={[styles.processItem, styles.processItemInactive]}>
|
|
||||||
<View style={styles.commandIcon} />
|
|
||||||
|
|
||||||
<Text style={styles.processText} numberOfLines={1}>
|
|
||||||
{command.name || command.currentCommand.substring(0, 20)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<View style={[styles.statusDot, { backgroundColor: statusColor }]} />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
},
|
},
|
||||||
thoughtLabel: {
|
thoughtLabel: {
|
||||||
color: theme.colors.palette.purple[400],
|
color: theme.colors.palette.purple[500],
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.semibold,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
@@ -319,7 +319,7 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
statusText: {
|
statusText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.xs,
|
||||||
fontWeight: theme.fontWeight.medium,
|
fontWeight: theme.fontWeight.semibold,
|
||||||
},
|
},
|
||||||
expandIcon: {
|
expandIcon: {
|
||||||
color: theme.colors.mutedForeground,
|
color: theme.colors.mutedForeground,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { useEffect, useRef, useMemo } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { View, Text, ScrollView, Pressable } from 'react-native';
|
import { View, Text, ScrollView, Pressable } from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { StyleSheet } from 'react-native-unistyles';
|
import { StyleSheet } from 'react-native-unistyles';
|
||||||
import type { AgentStatus } from '@server/server/acp/types';
|
import type { AgentStatus } from '@server/server/acp/types';
|
||||||
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
import { AssistantMessage } from './message';
|
||||||
import { AgentActivityItem } from './agent-activity';
|
import type { StreamItem } from '@/types/stream';
|
||||||
import { parseSessionUpdate, groupActivities, type AgentActivity } from '@/types/agent-activity';
|
|
||||||
|
|
||||||
export interface AgentStreamViewProps {
|
export interface AgentStreamViewProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -15,13 +14,7 @@ export interface AgentStreamViewProps {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
type: 'claude';
|
type: 'claude';
|
||||||
};
|
};
|
||||||
updates: Array<{
|
streamItems: StreamItem[];
|
||||||
timestamp: Date;
|
|
||||||
notification: SessionNotification;
|
|
||||||
}>;
|
|
||||||
onBack: () => void;
|
|
||||||
onKillAgent: (agentId: string) => void;
|
|
||||||
onCancelAgent: (agentId: string) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusColor(status: AgentStatus): string {
|
function getStatusColor(status: AgentStatus): string {
|
||||||
@@ -60,6 +53,11 @@ function getStatusIcon(status: AgentStatus): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatTimestamp(date: Date): string {
|
function formatTimestamp(date: Date): string {
|
||||||
|
// Handle invalid dates gracefully
|
||||||
|
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
||||||
|
return '--:--:--';
|
||||||
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
hour: 'numeric',
|
hour: 'numeric',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
@@ -68,124 +66,138 @@ function formatTimestamp(date: Date): string {
|
|||||||
}).format(date);
|
}).format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserMessageCard({ text, timestamp }: { text: string; timestamp: Date }) {
|
||||||
|
return (
|
||||||
|
<View style={stylesheet.userMessageCard}>
|
||||||
|
<Text style={stylesheet.timestamp}>
|
||||||
|
{formatTimestamp(timestamp)}
|
||||||
|
</Text>
|
||||||
|
<Text style={stylesheet.userMessageLabel}>You</Text>
|
||||||
|
<Text style={stylesheet.userMessageText}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ThoughtCard({ text, timestamp }: { text: string; timestamp: Date }) {
|
||||||
|
return (
|
||||||
|
<View style={stylesheet.thoughtCard}>
|
||||||
|
<Text style={stylesheet.timestamp}>
|
||||||
|
{formatTimestamp(timestamp)}
|
||||||
|
</Text>
|
||||||
|
<Text style={stylesheet.thoughtLabel}>Thinking</Text>
|
||||||
|
<Text style={stylesheet.thoughtText}>{text}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolCallCard({
|
||||||
|
title,
|
||||||
|
status,
|
||||||
|
toolKind,
|
||||||
|
timestamp
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
toolKind?: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={stylesheet.toolCallCard}>
|
||||||
|
<Text style={stylesheet.timestamp}>
|
||||||
|
{formatTimestamp(timestamp)}
|
||||||
|
</Text>
|
||||||
|
<View style={stylesheet.toolCallHeader}>
|
||||||
|
<Text style={stylesheet.toolCallLabel}>{toolKind || 'Tool'}</Text>
|
||||||
|
<Text style={[stylesheet.toolCallStatus, { color: getStatusColor(status as any) }]}>
|
||||||
|
{status}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={stylesheet.toolCallTitle}>{title}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AgentStreamView({
|
export function AgentStreamView({
|
||||||
agentId,
|
agentId,
|
||||||
agent,
|
agent,
|
||||||
updates,
|
streamItems,
|
||||||
onBack,
|
|
||||||
onKillAgent,
|
|
||||||
onCancelAgent,
|
|
||||||
}: AgentStreamViewProps) {
|
}: AgentStreamViewProps) {
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
// Parse and group activities
|
// Auto-scroll to bottom when new items arrive
|
||||||
const groupedActivities = useMemo(() => {
|
|
||||||
// Parse all notifications into typed activities
|
|
||||||
const activities: AgentActivity[] = updates
|
|
||||||
.map((update) => {
|
|
||||||
const parsedUpdate = parseSessionUpdate(update.notification);
|
|
||||||
if (!parsedUpdate) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
timestamp: update.timestamp,
|
|
||||||
update: parsedUpdate,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((activity): activity is AgentActivity => activity !== null);
|
|
||||||
|
|
||||||
// Group consecutive text chunks and merge tool calls
|
|
||||||
return groupActivities(activities);
|
|
||||||
}, [updates]);
|
|
||||||
|
|
||||||
// Auto-scroll to bottom when new updates arrive
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
}, [groupedActivities]);
|
}, [streamItems]);
|
||||||
|
|
||||||
const canCancel = agent.status === 'processing';
|
|
||||||
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={stylesheet.container}>
|
<View style={stylesheet.container}>
|
||||||
{/* Header */}
|
{/* Content list */}
|
||||||
<View style={[stylesheet.header, { paddingTop: insets.top + 16 }]}>
|
|
||||||
{/* Back button */}
|
|
||||||
<Pressable
|
|
||||||
onPress={onBack}
|
|
||||||
style={stylesheet.backButton}
|
|
||||||
>
|
|
||||||
<Text style={stylesheet.backButtonText}>
|
|
||||||
← Back to Chat
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
|
|
||||||
{/* Agent info */}
|
|
||||||
<View style={stylesheet.agentInfoRow}>
|
|
||||||
<View style={stylesheet.agentIdRow}>
|
|
||||||
<Text style={stylesheet.agentLabel}>Agent:</Text>
|
|
||||||
<Text style={stylesheet.agentId}>
|
|
||||||
{agentId.substring(0, 8)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
stylesheet.statusBadge,
|
|
||||||
{ backgroundColor: getStatusColor(agent.status) }
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={stylesheet.statusIcon}>{getStatusIcon(agent.status)}</Text>
|
|
||||||
<Text style={stylesheet.statusText}>{agent.status}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Text style={stylesheet.createdText}>
|
|
||||||
Created: {formatTimestamp(agent.createdAt)}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Control buttons */}
|
|
||||||
{(canCancel || canKill) && (
|
|
||||||
<View style={stylesheet.controlButtons}>
|
|
||||||
{canCancel && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onCancelAgent(agentId)}
|
|
||||||
style={stylesheet.cancelButton}
|
|
||||||
>
|
|
||||||
<Text style={stylesheet.controlButtonText}>
|
|
||||||
Cancel
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
{canKill && (
|
|
||||||
<Pressable
|
|
||||||
onPress={() => onKillAgent(agentId)}
|
|
||||||
style={stylesheet.killButton}
|
|
||||||
>
|
|
||||||
<Text style={stylesheet.controlButtonText}>
|
|
||||||
Kill
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Updates list */}
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
style={stylesheet.scrollView}
|
style={stylesheet.scrollView}
|
||||||
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
|
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
|
||||||
>
|
>
|
||||||
{groupedActivities.length === 0 ? (
|
{streamItems.length === 0 ? (
|
||||||
<View style={stylesheet.emptyState}>
|
<View style={stylesheet.emptyState}>
|
||||||
<Text style={stylesheet.emptyStateText}>
|
<Text style={stylesheet.emptyStateText}>
|
||||||
No updates yet.{'\n'}Waiting for agent activity...
|
Start chatting with this agent...
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
groupedActivities.map((item, idx) => (
|
streamItems.map((item) => {
|
||||||
<AgentActivityItem key={idx} item={item} />
|
switch (item.kind) {
|
||||||
))
|
case 'user_message':
|
||||||
|
return (
|
||||||
|
<UserMessageCard
|
||||||
|
key={item.id}
|
||||||
|
text={item.text}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'assistant_message':
|
||||||
|
return (
|
||||||
|
<AssistantMessage
|
||||||
|
key={item.id}
|
||||||
|
message={item.text}
|
||||||
|
timestamp={item.timestamp.getTime()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'thought':
|
||||||
|
return (
|
||||||
|
<ThoughtCard
|
||||||
|
key={item.id}
|
||||||
|
text={item.text}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'tool_call':
|
||||||
|
return (
|
||||||
|
<ToolCallCard
|
||||||
|
key={item.id}
|
||||||
|
title={item.title}
|
||||||
|
status={item.status}
|
||||||
|
toolKind={item.toolKind}
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
// TODO: Render plan component
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'activity_log':
|
||||||
|
case 'artifact':
|
||||||
|
// These are orchestrator-only, skip for now
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
@@ -197,6 +209,78 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.background,
|
backgroundColor: theme.colors.background,
|
||||||
},
|
},
|
||||||
|
userMessageCard: {
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: theme.colors.primary,
|
||||||
|
},
|
||||||
|
userMessageLabel: {
|
||||||
|
color: theme.colors.primary,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
userMessageText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
timestamp: {
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
thoughtCard: {
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: theme.colors.palette.purple[500],
|
||||||
|
},
|
||||||
|
thoughtLabel: {
|
||||||
|
color: theme.colors.palette.purple[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
thoughtText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
toolCallCard: {
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
padding: theme.spacing[3],
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: theme.colors.palette.blue[500],
|
||||||
|
},
|
||||||
|
toolCallHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
toolCallLabel: {
|
||||||
|
color: theme.colors.palette.blue[500],
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
toolCallStatus: {
|
||||||
|
fontSize: theme.fontSize.xs,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
toolCallTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
header: {
|
header: {
|
||||||
backgroundColor: theme.colors.card,
|
backgroundColor: theme.colors.card,
|
||||||
borderBottomWidth: theme.borderWidth[1],
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
TextInput,
|
||||||
|
ScrollView,
|
||||||
|
KeyboardAvoidingView,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { X } from "lucide-react-native";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { theme as defaultTheme } from "@/styles/theme";
|
||||||
|
|
||||||
|
interface CreateAgentModalProps {
|
||||||
|
isVisible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreateAgent: (workingDir: string, mode: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODES = [
|
||||||
|
{
|
||||||
|
value: "plan",
|
||||||
|
label: "Plan",
|
||||||
|
description: "Plan and design before implementing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "bypassPermissions",
|
||||||
|
label: "Bypass Permissions",
|
||||||
|
description: "Skip permission prompts for faster execution",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function CreateAgentModal({
|
||||||
|
isVisible,
|
||||||
|
onClose,
|
||||||
|
onCreateAgent,
|
||||||
|
}: CreateAgentModalProps) {
|
||||||
|
const [workingDir, setWorkingDir] = useState("");
|
||||||
|
const [selectedMode, setSelectedMode] = useState("plan");
|
||||||
|
const [errorMessage, setErrorMessage] = useState("");
|
||||||
|
|
||||||
|
function handleCreate() {
|
||||||
|
if (!workingDir.trim()) {
|
||||||
|
setErrorMessage("Working directory is required");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCreateAgent(workingDir.trim(), selectedMode);
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setWorkingDir("");
|
||||||
|
setSelectedMode("plan");
|
||||||
|
setErrorMessage("");
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={isVisible}
|
||||||
|
animationType="slide"
|
||||||
|
transparent={true}
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.modalBackdrop} onPress={handleClose} />
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalInner}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>Create New Agent</Text>
|
||||||
|
<Pressable onPress={handleClose}>
|
||||||
|
<X size={24} color="white" />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
|
>
|
||||||
|
{/* Working Directory Input */}
|
||||||
|
<View style={styles.formSection}>
|
||||||
|
<Text style={styles.label}>Working Directory</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
placeholder="/path/to/project"
|
||||||
|
placeholderTextColor={defaultTheme.colors.mutedForeground}
|
||||||
|
value={workingDir}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setWorkingDir(text);
|
||||||
|
setErrorMessage("");
|
||||||
|
}}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
{errorMessage ? (
|
||||||
|
<Text style={styles.errorText}>{errorMessage}</Text>
|
||||||
|
) : (
|
||||||
|
<Text style={styles.helperText}>
|
||||||
|
Absolute path to the project directory
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Mode Selector */}
|
||||||
|
<View style={styles.formSection}>
|
||||||
|
<Text style={styles.label}>Mode</Text>
|
||||||
|
<View style={styles.modeContainer}>
|
||||||
|
{MODES.map((mode) => (
|
||||||
|
<Pressable
|
||||||
|
key={mode.value}
|
||||||
|
onPress={() => setSelectedMode(mode.value)}
|
||||||
|
style={[
|
||||||
|
styles.modeOption,
|
||||||
|
selectedMode === mode.value &&
|
||||||
|
styles.modeOptionSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.modeOptionContent}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.radioOuter,
|
||||||
|
selectedMode === mode.value
|
||||||
|
? styles.radioOuterSelected
|
||||||
|
: styles.radioOuterUnselected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{selectedMode === mode.value && (
|
||||||
|
<View style={styles.radioInner} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.modeTextContainer}>
|
||||||
|
<Text style={styles.modeLabel}>{mode.label}</Text>
|
||||||
|
<Text style={styles.modeDescription}>
|
||||||
|
{mode.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<Pressable
|
||||||
|
style={[styles.createButton, !workingDir.trim() && styles.createButtonDisabled]}
|
||||||
|
onPress={handleCreate}
|
||||||
|
disabled={!workingDir.trim()}
|
||||||
|
>
|
||||||
|
<Text style={styles.createButtonText}>Create Agent</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable style={styles.cancelButton} onPress={handleClose}>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0,0,0,0.5)",
|
||||||
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
borderTopLeftRadius: theme.spacing[6],
|
||||||
|
borderTopRightRadius: theme.spacing[6],
|
||||||
|
height: "75%",
|
||||||
|
},
|
||||||
|
modalInner: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
borderBottomWidth: theme.borderWidth[1],
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
padding: theme.spacing[6],
|
||||||
|
paddingBottom: theme.spacing[4] * 5,
|
||||||
|
},
|
||||||
|
formSection: {
|
||||||
|
marginBottom: theme.spacing[6],
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginBottom: theme.spacing[2],
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
helperText: {
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: theme.colors.destructive,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
marginTop: theme.spacing[2],
|
||||||
|
},
|
||||||
|
modeContainer: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
modeOption: {
|
||||||
|
backgroundColor: theme.colors.background,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
},
|
||||||
|
modeOptionSelected: {
|
||||||
|
borderColor: theme.colors.palette.blue[500],
|
||||||
|
backgroundColor: theme.colors.muted,
|
||||||
|
},
|
||||||
|
modeOptionContent: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
radioOuter: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: theme.borderWidth[2],
|
||||||
|
marginRight: theme.spacing[3],
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
radioOuterSelected: {
|
||||||
|
borderColor: theme.colors.palette.blue[500],
|
||||||
|
},
|
||||||
|
radioOuterUnselected: {
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
radioInner: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
backgroundColor: theme.colors.palette.blue[500],
|
||||||
|
},
|
||||||
|
modeTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
modeLabel: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
marginBottom: theme.spacing[1],
|
||||||
|
},
|
||||||
|
modeDescription: {
|
||||||
|
color: theme.colors.mutedForeground,
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
marginTop: theme.spacing[4],
|
||||||
|
},
|
||||||
|
createButton: {
|
||||||
|
backgroundColor: theme.colors.palette.blue[500],
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
createButtonDisabled: {
|
||||||
|
backgroundColor: theme.colors.palette.blue[900],
|
||||||
|
opacity: theme.opacity[50],
|
||||||
|
},
|
||||||
|
createButtonText: {
|
||||||
|
color: theme.colors.palette.white,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: "transparent",
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: theme.borderWidth[1],
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: theme.colors.foreground,
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.semibold,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import type { SessionNotification } from '@agentclientprotocol/sdk';
|
||||||
|
|
||||||
|
// Simple ID generator that works in React Native
|
||||||
|
let idCounter = 0;
|
||||||
|
function generateId(): string {
|
||||||
|
return `stream_${Date.now()}_${idCounter++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified stream item types for both orchestrator and agent views
|
||||||
|
*/
|
||||||
|
export type StreamItem =
|
||||||
|
| UserMessageItem
|
||||||
|
| AssistantMessageItem
|
||||||
|
| ThoughtItem
|
||||||
|
| ToolCallItem
|
||||||
|
| PlanItem
|
||||||
|
| ActivityLogItem
|
||||||
|
| ArtifactItem;
|
||||||
|
|
||||||
|
export interface UserMessageItem {
|
||||||
|
kind: 'user_message';
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssistantMessageItem {
|
||||||
|
kind: 'assistant_message';
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThoughtItem {
|
||||||
|
kind: 'thought';
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallItem {
|
||||||
|
kind: 'tool_call';
|
||||||
|
id: string;
|
||||||
|
toolCallId: string;
|
||||||
|
title: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed' | 'failed';
|
||||||
|
toolKind?: 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'think' | 'fetch' | 'switch_mode' | 'other';
|
||||||
|
rawInput?: Record<string, unknown>;
|
||||||
|
rawOutput?: Record<string, unknown>;
|
||||||
|
content?: unknown[];
|
||||||
|
locations?: unknown[];
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlanItem {
|
||||||
|
kind: 'plan';
|
||||||
|
id: string;
|
||||||
|
entries: Array<{
|
||||||
|
content: string;
|
||||||
|
status: 'pending' | 'in_progress' | 'completed';
|
||||||
|
priority: 'high' | 'medium' | 'low';
|
||||||
|
}>;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActivityLogItem {
|
||||||
|
kind: 'activity_log';
|
||||||
|
id: string;
|
||||||
|
activityType: 'system' | 'transcript' | 'assistant' | 'tool_call' | 'tool_result' | 'error';
|
||||||
|
message: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ArtifactItem {
|
||||||
|
kind: 'artifact';
|
||||||
|
id: string;
|
||||||
|
artifactId: string;
|
||||||
|
artifactType: 'markdown' | 'diff' | 'image' | 'code';
|
||||||
|
title: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsed notification types (internal representation before converting to StreamItem)
|
||||||
|
*/
|
||||||
|
type ParsedNotification =
|
||||||
|
| { kind: 'user_message_chunk'; text: string }
|
||||||
|
| { kind: 'agent_message_chunk'; text: string }
|
||||||
|
| { kind: 'agent_thought_chunk'; text: string }
|
||||||
|
| { kind: 'tool_call'; toolCallId: string; title: string; status?: string; toolKind?: string; rawInput?: any; rawOutput?: any; content?: any[]; locations?: any[] }
|
||||||
|
| { kind: 'tool_call_update'; toolCallId: string; title?: string | null; status?: string | null; toolKind?: string | null; rawInput?: any; rawOutput?: any; content?: any[] | null; locations?: any[] | null }
|
||||||
|
| { kind: 'plan'; entries: Array<{ content: string; status: string; priority: string }> }
|
||||||
|
| { kind: 'current_mode_update'; currentModeId: string }
|
||||||
|
| { kind: 'available_commands_update'; availableCommands: Array<{ name: string; description: string }> }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SessionNotification into typed ParsedNotification
|
||||||
|
*/
|
||||||
|
function parseNotification(notification: SessionNotification | any): ParsedNotification {
|
||||||
|
const update = (notification as any).update;
|
||||||
|
|
||||||
|
if (!update || !update.sessionUpdate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kind = update.sessionUpdate;
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'user_message_chunk':
|
||||||
|
return {
|
||||||
|
kind: 'user_message_chunk',
|
||||||
|
text: update.content?.text || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'agent_message_chunk':
|
||||||
|
return {
|
||||||
|
kind: 'agent_message_chunk',
|
||||||
|
text: update.content?.text || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'agent_thought_chunk':
|
||||||
|
return {
|
||||||
|
kind: 'agent_thought_chunk',
|
||||||
|
text: update.content?.text || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'tool_call':
|
||||||
|
return {
|
||||||
|
kind: 'tool_call',
|
||||||
|
toolCallId: update.toolCallId,
|
||||||
|
title: update.title,
|
||||||
|
status: update.status,
|
||||||
|
toolKind: update.kind,
|
||||||
|
rawInput: update.rawInput,
|
||||||
|
rawOutput: update.rawOutput,
|
||||||
|
content: update.content,
|
||||||
|
locations: update.locations,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'tool_call_update':
|
||||||
|
return {
|
||||||
|
kind: 'tool_call_update',
|
||||||
|
toolCallId: update.toolCallId,
|
||||||
|
title: update.title,
|
||||||
|
status: update.status,
|
||||||
|
toolKind: update.kind,
|
||||||
|
rawInput: update.rawInput,
|
||||||
|
rawOutput: update.rawOutput,
|
||||||
|
content: update.content,
|
||||||
|
locations: update.locations,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'plan':
|
||||||
|
return {
|
||||||
|
kind: 'plan',
|
||||||
|
entries: update.entries,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'current_mode_update':
|
||||||
|
return {
|
||||||
|
kind: 'current_mode_update',
|
||||||
|
currentModeId: update.currentModeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'available_commands_update':
|
||||||
|
return {
|
||||||
|
kind: 'available_commands_update',
|
||||||
|
availableCommands: update.availableCommands,
|
||||||
|
};
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core reducer function that processes stream updates one at a time
|
||||||
|
*
|
||||||
|
* This function handles:
|
||||||
|
* - Message chunks (accumulates text into single message)
|
||||||
|
* - Tool call updates (finds and merges with existing)
|
||||||
|
* - New items (appends to stream)
|
||||||
|
*
|
||||||
|
* Can be used for:
|
||||||
|
* - Hydration: updates.reduce(reduceStreamUpdate, [])
|
||||||
|
* - Real-time: setState(prev => reduceStreamUpdate(prev, update))
|
||||||
|
*/
|
||||||
|
export function reduceStreamUpdate(
|
||||||
|
state: StreamItem[],
|
||||||
|
notification: SessionNotification | any,
|
||||||
|
timestamp: Date
|
||||||
|
): StreamItem[] {
|
||||||
|
const parsed = parseNotification(notification);
|
||||||
|
|
||||||
|
if (!parsed) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User message chunks - always create new message (they're complete from server)
|
||||||
|
if (parsed.kind === 'user_message_chunk') {
|
||||||
|
return [...state, {
|
||||||
|
kind: 'user_message',
|
||||||
|
id: generateId(),
|
||||||
|
text: parsed.text,
|
||||||
|
timestamp,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent message chunks - accumulate into last assistant message or create new
|
||||||
|
if (parsed.kind === 'agent_message_chunk') {
|
||||||
|
const last = state[state.length - 1];
|
||||||
|
if (last?.kind === 'assistant_message') {
|
||||||
|
// Append to existing message
|
||||||
|
return [
|
||||||
|
...state.slice(0, -1),
|
||||||
|
{
|
||||||
|
...last,
|
||||||
|
text: last.text + parsed.text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Create new message
|
||||||
|
return [...state, {
|
||||||
|
kind: 'assistant_message',
|
||||||
|
id: generateId(),
|
||||||
|
text: parsed.text,
|
||||||
|
timestamp,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thought chunks - accumulate into last thought or create new
|
||||||
|
if (parsed.kind === 'agent_thought_chunk') {
|
||||||
|
const last = state[state.length - 1];
|
||||||
|
if (last?.kind === 'thought') {
|
||||||
|
// Append to existing thought
|
||||||
|
return [
|
||||||
|
...state.slice(0, -1),
|
||||||
|
{
|
||||||
|
...last,
|
||||||
|
text: last.text + parsed.text,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
// Create new thought
|
||||||
|
return [...state, {
|
||||||
|
kind: 'thought',
|
||||||
|
id: generateId(),
|
||||||
|
text: parsed.text,
|
||||||
|
timestamp,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tool call updates - find existing and merge
|
||||||
|
if (parsed.kind === 'tool_call_update') {
|
||||||
|
return state.map(item => {
|
||||||
|
if (item.kind === 'tool_call' && item.toolCallId === parsed.toolCallId) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
...(parsed.title !== undefined && parsed.title !== null ? { title: parsed.title } : {}),
|
||||||
|
...(parsed.status !== undefined && parsed.status !== null ? { status: parsed.status as any } : {}),
|
||||||
|
...(parsed.toolKind !== undefined && parsed.toolKind !== null ? { toolKind: parsed.toolKind as any } : {}),
|
||||||
|
...(parsed.rawInput ? { rawInput: { ...item.rawInput, ...parsed.rawInput } } : {}),
|
||||||
|
...(parsed.rawOutput ? { rawOutput: { ...item.rawOutput, ...parsed.rawOutput } } : {}),
|
||||||
|
...(parsed.content !== undefined && parsed.content !== null ? { content: parsed.content } : {}),
|
||||||
|
...(parsed.locations !== undefined && parsed.locations !== null ? { locations: parsed.locations } : {}),
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// New tool call
|
||||||
|
if (parsed.kind === 'tool_call') {
|
||||||
|
return [...state, {
|
||||||
|
kind: 'tool_call',
|
||||||
|
id: parsed.toolCallId,
|
||||||
|
toolCallId: parsed.toolCallId,
|
||||||
|
title: parsed.title,
|
||||||
|
status: (parsed.status as any) || 'pending',
|
||||||
|
toolKind: parsed.toolKind as any,
|
||||||
|
rawInput: parsed.rawInput,
|
||||||
|
rawOutput: parsed.rawOutput,
|
||||||
|
content: parsed.content,
|
||||||
|
locations: parsed.locations,
|
||||||
|
timestamp,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan - replace existing or create new
|
||||||
|
if (parsed.kind === 'plan') {
|
||||||
|
const withoutPlan = state.filter(item => item.kind !== 'plan');
|
||||||
|
return [...withoutPlan, {
|
||||||
|
kind: 'plan',
|
||||||
|
id: generateId(),
|
||||||
|
entries: parsed.entries as any,
|
||||||
|
timestamp,
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mode updates and other types don't create stream items
|
||||||
|
// They're handled separately in state management
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hydrate stream state from batch of notifications
|
||||||
|
*/
|
||||||
|
export function hydrateStreamState(
|
||||||
|
notifications: Array<{ timestamp: Date; notification: SessionNotification }>
|
||||||
|
): StreamItem[] {
|
||||||
|
return notifications.reduce(
|
||||||
|
(state, { notification, timestamp }) => reduceStreamUpdate(state, notification, timestamp),
|
||||||
|
[] as StreamItem[]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -180,13 +180,15 @@ export class AgentManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Create a new session
|
// Create a new session
|
||||||
const sessionResponse = await agent.connection.newSession({
|
const sessionParams = {
|
||||||
cwd,
|
cwd,
|
||||||
mcpServers: [],
|
mcpServers: [],
|
||||||
...(options.initialMode ? { initialMode: options.initialMode } : {}),
|
};
|
||||||
});
|
console.log(`[Agent ${agentId}] newSession params:`, sessionParams);
|
||||||
|
const sessionResponse = await agent.connection.newSession(sessionParams);
|
||||||
|
|
||||||
agent.sessionId = sessionResponse.sessionId;
|
agent.sessionId = sessionResponse.sessionId;
|
||||||
|
console.log(`[Agent ${agentId}] newSession response:`, JSON.stringify(sessionResponse, null, 2));
|
||||||
|
|
||||||
// Store session modes from response
|
// Store session modes from response
|
||||||
if (sessionResponse.modes) {
|
if (sessionResponse.modes) {
|
||||||
@@ -200,6 +202,12 @@ export class AgentManager {
|
|||||||
console.log(`[Agent ${agentId}] Session created:`, sessionResponse.sessionId);
|
console.log(`[Agent ${agentId}] Session created:`, sessionResponse.sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set initial mode if requested (must be done after session creation)
|
||||||
|
if (options.initialMode && agent.sessionId) {
|
||||||
|
console.log(`[Agent ${agentId}] Setting initial mode to: ${options.initialMode}`);
|
||||||
|
await this.setSessionMode(agentId, options.initialMode);
|
||||||
|
}
|
||||||
|
|
||||||
agent.status = "ready";
|
agent.status = "ready";
|
||||||
|
|
||||||
// If an initial prompt was provided, send it
|
// If an initial prompt was provided, send it
|
||||||
|
|||||||
@@ -7,11 +7,10 @@ import { z } from "zod";
|
|||||||
export const UserTextMessageSchema = z.object({
|
export const UserTextMessageSchema = z.object({
|
||||||
type: z.literal("user_text"),
|
type: z.literal("user_text"),
|
||||||
text: z.string(),
|
text: z.string(),
|
||||||
disableTTS: z.boolean().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const AudioChunkMessageSchema = z.object({
|
export const RealtimeAudioChunkMessageSchema = z.object({
|
||||||
type: z.literal("audio_chunk"),
|
type: z.literal("realtime_audio_chunk"),
|
||||||
audio: z.string(), // base64 encoded
|
audio: z.string(), // base64 encoded
|
||||||
format: z.string(),
|
format: z.string(),
|
||||||
isLast: z.boolean(),
|
isLast: z.boolean(),
|
||||||
@@ -45,15 +44,45 @@ export const SetRealtimeModeMessageSchema = z.object({
|
|||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const SendAgentMessageSchema = z.object({
|
||||||
|
type: z.literal("send_agent_message"),
|
||||||
|
agentId: z.string(),
|
||||||
|
text: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SendAgentAudioSchema = z.object({
|
||||||
|
type: z.literal("send_agent_audio"),
|
||||||
|
agentId: z.string(),
|
||||||
|
audio: z.string(), // base64 encoded
|
||||||
|
format: z.string(),
|
||||||
|
isLast: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateAgentRequestMessageSchema = z.object({
|
||||||
|
type: z.literal("create_agent_request"),
|
||||||
|
cwd: z.string(),
|
||||||
|
initialMode: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SetAgentModeMessageSchema = z.object({
|
||||||
|
type: z.literal("set_agent_mode"),
|
||||||
|
agentId: z.string(),
|
||||||
|
modeId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||||
UserTextMessageSchema,
|
UserTextMessageSchema,
|
||||||
AudioChunkMessageSchema,
|
RealtimeAudioChunkMessageSchema,
|
||||||
AbortRequestMessageSchema,
|
AbortRequestMessageSchema,
|
||||||
AudioPlayedMessageSchema,
|
AudioPlayedMessageSchema,
|
||||||
LoadConversationRequestMessageSchema,
|
LoadConversationRequestMessageSchema,
|
||||||
ListConversationsRequestMessageSchema,
|
ListConversationsRequestMessageSchema,
|
||||||
DeleteConversationRequestMessageSchema,
|
DeleteConversationRequestMessageSchema,
|
||||||
SetRealtimeModeMessageSchema,
|
SetRealtimeModeMessageSchema,
|
||||||
|
SendAgentMessageSchema,
|
||||||
|
SendAgentAudioSchema,
|
||||||
|
CreateAgentRequestMessageSchema,
|
||||||
|
SetAgentModeMessageSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
export type SessionInboundMessage = z.infer<typeof SessionInboundMessageSchema>;
|
||||||
@@ -274,6 +303,14 @@ export type DeleteConversationResponseMessage = z.infer<typeof DeleteConversatio
|
|||||||
// Type exports for payload types
|
// Type exports for payload types
|
||||||
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
export type ActivityLogPayload = z.infer<typeof ActivityLogPayloadSchema>;
|
||||||
|
|
||||||
|
// Type exports for inbound message types
|
||||||
|
export type UserTextMessage = z.infer<typeof UserTextMessageSchema>;
|
||||||
|
export type RealtimeAudioChunkMessage = z.infer<typeof RealtimeAudioChunkMessageSchema>;
|
||||||
|
export type SendAgentMessage = z.infer<typeof SendAgentMessageSchema>;
|
||||||
|
export type SendAgentAudio = z.infer<typeof SendAgentAudioSchema>;
|
||||||
|
export type CreateAgentRequestMessage = z.infer<typeof CreateAgentRequestMessageSchema>;
|
||||||
|
export type SetAgentModeMessage = z.infer<typeof SetAgentModeMessageSchema>;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WebSocket Level Messages (wraps session messages)
|
// WebSocket Level Messages (wraps session messages)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export class Session {
|
|||||||
await this.handleUserText(msg.text);
|
await this.handleUserText(msg.text);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "audio_chunk":
|
case "realtime_audio_chunk":
|
||||||
await this.handleAudioChunk(msg);
|
await this.handleAudioChunk(msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -313,6 +313,22 @@ export class Session {
|
|||||||
case "set_realtime_mode":
|
case "set_realtime_mode":
|
||||||
this.handleSetRealtimeMode(msg.enabled);
|
this.handleSetRealtimeMode(msg.enabled);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "send_agent_message":
|
||||||
|
await this.handleSendAgentMessage(msg.agentId, msg.text);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "send_agent_audio":
|
||||||
|
await this.handleSendAgentAudio(msg);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "create_agent_request":
|
||||||
|
await this.handleCreateAgentRequest(msg.cwd, msg.initialMode);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "set_agent_mode":
|
||||||
|
await this.handleSetAgentMode(msg.agentId, msg.modeId);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -423,6 +439,219 @@ export class Session {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle text message to agent
|
||||||
|
*/
|
||||||
|
private async handleSendAgentMessage(agentId: string, text: string): Promise<void> {
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Emit user message notification before sending to agent
|
||||||
|
// (Claude Code ACP doesn't echo user messages, so we do it manually)
|
||||||
|
this.emit({
|
||||||
|
type: "agent_update",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
notification: {
|
||||||
|
type: "sessionUpdate",
|
||||||
|
update: {
|
||||||
|
sessionUpdate: "user_message_chunk",
|
||||||
|
content: {
|
||||||
|
type: "text",
|
||||||
|
text: text,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.agentManager.sendPrompt(agentId, text);
|
||||||
|
console.log(`[Session ${this.clientId}] Sent text to agent ${agentId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] Failed to send text to agent ${agentId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "activity_log",
|
||||||
|
payload: {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: "error",
|
||||||
|
content: `Failed to send message to agent: ${error.message}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle audio message to agent (transcribe then send)
|
||||||
|
*/
|
||||||
|
private async handleSendAgentAudio(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "send_agent_audio" }>
|
||||||
|
): Promise<void> {
|
||||||
|
const { agentId, audio, format, isLast } = msg;
|
||||||
|
|
||||||
|
// Decode base64
|
||||||
|
const audioBuffer = Buffer.from(audio, "base64");
|
||||||
|
|
||||||
|
// For now, we'll process each audio segment immediately
|
||||||
|
// In the future, we might want to buffer chunks similar to realtime audio
|
||||||
|
if (!isLast) {
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Buffering agent audio chunk for agent ${agentId}`
|
||||||
|
);
|
||||||
|
// TODO: Implement buffering if needed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Transcribing audio for agent ${agentId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Transcribe the audio
|
||||||
|
const result = await this.sttManager.transcribe(audioBuffer, format);
|
||||||
|
|
||||||
|
const transcriptText = result.text.trim();
|
||||||
|
if (!transcriptText) {
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Empty transcription for agent ${agentId}, ignoring`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Transcribed audio for agent ${agentId}: ${transcriptText}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send transcribed text to agent
|
||||||
|
await this.agentManager.sendPrompt(agentId, transcriptText);
|
||||||
|
console.log(`[Session ${this.clientId}] Sent transcribed text to agent ${agentId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] Failed to process audio for agent ${agentId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "activity_log",
|
||||||
|
payload: {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: "error",
|
||||||
|
content: `Failed to process audio for agent: ${error.message}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle create agent request
|
||||||
|
*/
|
||||||
|
private async handleCreateAgentRequest(cwd: string, initialMode?: string): Promise<void> {
|
||||||
|
console.log(
|
||||||
|
`[Session ${this.clientId}] Creating agent in ${cwd} with mode ${initialMode || "default"}`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agentId = await this.agentManager.createAgent({
|
||||||
|
cwd,
|
||||||
|
initialMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[Session ${this.clientId}] Created agent ${agentId}`);
|
||||||
|
|
||||||
|
// Get agent info
|
||||||
|
const agentInfo = this.agentManager.listAgents().find(a => a.id === agentId);
|
||||||
|
console.log(`[Session ${this.clientId}] Agent info:`, {
|
||||||
|
currentModeId: agentInfo?.currentModeId,
|
||||||
|
availableModes: agentInfo?.availableModes
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subscribe to agent updates
|
||||||
|
this.subscribeToAgent(agentId);
|
||||||
|
|
||||||
|
// Emit agent_created message
|
||||||
|
this.emit({
|
||||||
|
type: "agent_created",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
status: agentInfo?.status || "initializing",
|
||||||
|
type: "claude",
|
||||||
|
currentModeId: agentInfo?.currentModeId,
|
||||||
|
availableModes: agentInfo?.availableModes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`[Session ${this.clientId}] Emitted agent_created with currentModeId:`, agentInfo?.currentModeId);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(
|
||||||
|
`[Session ${this.clientId}] Failed to create agent:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
this.emit({
|
||||||
|
type: "activity_log",
|
||||||
|
payload: {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: "error",
|
||||||
|
content: `Failed to create agent: ${error.message}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle set agent mode request
|
||||||
|
*/
|
||||||
|
private async handleSetAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||||
|
console.log(`[Session ${this.clientId}] Setting agent ${agentId} mode to ${modeId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.agentManager.setSessionMode(agentId, modeId);
|
||||||
|
console.log(`[Session ${this.clientId}] Agent ${agentId} mode set to ${modeId}`);
|
||||||
|
|
||||||
|
// Emit agent_status to notify client of mode change
|
||||||
|
const info = this.agentManager.listAgents().find(a => a.id === agentId);
|
||||||
|
if (info) {
|
||||||
|
this.emit({
|
||||||
|
type: "agent_status",
|
||||||
|
payload: {
|
||||||
|
agentId,
|
||||||
|
status: info.status,
|
||||||
|
info: {
|
||||||
|
id: info.id,
|
||||||
|
status: info.status,
|
||||||
|
createdAt: info.createdAt,
|
||||||
|
type: info.type,
|
||||||
|
sessionId: info.sessionId ?? undefined,
|
||||||
|
error: info.error ?? undefined,
|
||||||
|
currentModeId: info.currentModeId ?? undefined,
|
||||||
|
availableModes: info.availableModes ?? undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[Session ${this.clientId}] Failed to set agent mode:`, error);
|
||||||
|
this.emit({
|
||||||
|
type: "activity_log",
|
||||||
|
payload: {
|
||||||
|
id: uuidv4(),
|
||||||
|
timestamp: new Date(),
|
||||||
|
type: "error",
|
||||||
|
content: `Failed to set agent mode: ${error.message}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send current session state (live agents and commands) to client
|
* Send current session state (live agents and commands) to client
|
||||||
*/
|
*/
|
||||||
@@ -431,6 +660,11 @@ export class Session {
|
|||||||
// Get live agents with session modes
|
// Get live agents with session modes
|
||||||
const agents = this.agentManager?.listAgents() || [];
|
const agents = this.agentManager?.listAgents() || [];
|
||||||
|
|
||||||
|
// Subscribe to all existing agents (in case of reconnection)
|
||||||
|
for (const agent of agents) {
|
||||||
|
this.subscribeToAgent(agent.id);
|
||||||
|
}
|
||||||
|
|
||||||
// Get live commands from terminal manager
|
// Get live commands from terminal manager
|
||||||
let commands: any[] = [];
|
let commands: any[] = [];
|
||||||
if (this.terminalManager) {
|
if (this.terminalManager) {
|
||||||
@@ -503,7 +737,7 @@ export class Session {
|
|||||||
* Handle audio chunk for buffering and transcription
|
* Handle audio chunk for buffering and transcription
|
||||||
*/
|
*/
|
||||||
private async handleAudioChunk(
|
private async handleAudioChunk(
|
||||||
msg: Extract<SessionInboundMessage, { type: "audio_chunk" }>
|
msg: Extract<SessionInboundMessage, { type: "realtime_audio_chunk" }>
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Decode base64
|
// Decode base64
|
||||||
const audioBuffer = Buffer.from(msg.audio, "base64");
|
const audioBuffer = Buffer.from(msg.audio, "base64");
|
||||||
|
|||||||
Reference in New Issue
Block a user