diff --git a/packages/app/src/components/agent-input-area.tsx b/packages/app/src/components/agent-input-area.tsx index 2dc36706e..2b46a6b9d 100644 --- a/packages/app/src/components/agent-input-area.tsx +++ b/packages/app/src/components/agent-input-area.tsx @@ -6,6 +6,7 @@ import { TextInputContentSizeChangeEventData, Image, Platform, + Text, } from "react-native"; import { useState, useEffect, useRef } from "react"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; @@ -42,8 +43,8 @@ const REALTIME_FADE_OUT = SHOULD_DISABLE_ENTRY_EXIT_ANIMATIONS ? undefined : Fad export function AgentInputArea({ agentId }: AgentInputAreaProps) { const { theme } = useUnistyles(); - const { ws, sendAgentMessage, sendAgentAudio } = useSession(); - const { startRealtime, isRealtimeMode } = useRealtime(); + const { ws, sendAgentMessage, sendAgentAudio, agents, cancelAgentRun } = useSession(); + const { startRealtime, stopRealtime, isRealtimeMode } = useRealtime(); const [userInput, setUserInput] = useState(""); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); @@ -106,6 +107,9 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { // This shouldn't happen as button is hidden when recording return; } + if (isRealtimeMode) { + return; + } // Start recording try { @@ -232,8 +236,13 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { }); } + const agent = agents.get(agentId); + const isAgentRunning = agent?.status === "running"; const hasText = userInput.trim().length > 0; const hasImages = selectedImages.length > 0; + const hasSendableContent = hasText || hasImages; + const shouldShowSendButton = !isAgentRunning && hasSendableContent; + const shouldShowDictateButton = !isAgentRunning && !hasSendableContent; const overlayAnimatedStyle = useAnimatedStyle(() => { return { @@ -250,6 +259,49 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { }; }); + async function handleRealtimePress() { + try { + if (isRealtimeMode) { + await stopRealtime(); + } else { + if (!ws.isConnected) { + return; + } + await startRealtime(); + } + } catch (error) { + console.error("[AgentInput] Failed to toggle realtime mode:", error); + } + } + + function handleCancelAgent() { + if (!agent || agent.status !== "running") { + return; + } + if (!ws.isConnected) { + return; + } + cancelAgentRun(agentId); + } + + const realtimeButton = ( + + {isRealtimeMode ? ( + + ) : ( + + )} + + ); + return ( {/* Border separator */} @@ -319,25 +371,42 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { {/* Right button group */} - {hasText || hasImages ? ( - - - - ) : !isRealtimeMode ? ( + {isAgentRunning ? ( + <> + {realtimeButton} + + Cancel + + + ) : shouldShowSendButton ? ( + <> + + + + {realtimeButton} + + ) : shouldShowDictateButton ? ( <> @@ -347,17 +416,7 @@ export function AgentInputArea({ agentId }: AgentInputAreaProps) { )} - - - - + {realtimeButton} ) : null} @@ -490,6 +549,23 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", }, + realtimeButtonActive: { + backgroundColor: theme.colors.palette.blue[600], + }, + cancelButton: { + minWidth: 92, + paddingHorizontal: theme.spacing[4], + height: 40, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.palette.red[500], + alignItems: "center", + justifyContent: "center", + }, + cancelButtonText: { + color: theme.colors.background, + fontSize: theme.fontSize.sm, + fontWeight: theme.fontWeight.semibold, + }, buttonDisabled: { opacity: 0.5, }, diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 274b2e579..debcfd65e 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -1,7 +1,9 @@ -import { View, Text, Pressable, ScrollView } from "react-native"; +import { View, Text, Pressable, ScrollView, Modal } from "react-native"; +import { useCallback, useMemo, useState } from "react"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { Agent } from "@/contexts/session-context"; +import { useSession } from "@/contexts/session-context"; import { formatTimeAgo } from "@/utils/time"; import { getAgentStatusColor, getAgentStatusLabel } from "@/utils/agent-status"; import { getAgentProviderDefinition } from "@server/server/agent/provider-manifest"; @@ -12,80 +14,139 @@ interface AgentListProps { export function AgentList({ agents }: AgentListProps) { const { theme } = useUnistyles(); + const { deleteAgent } = useSession(); + const [actionAgent, setActionAgent] = useState(null); + const isActionSheetVisible = actionAgent !== null; // Sort agents by lastActivityAt (most recent first) - const agentArray = Array.from(agents.values()).sort((a, b) => { - return b.lastActivityAt.getTime() - a.lastActivityAt.getTime(); - }); + const agentArray = useMemo(() => { + return Array.from(agents.values()).sort((a, b) => { + return b.lastActivityAt.getTime() - a.lastActivityAt.getTime(); + }); + }, [agents]); - function handleAgentPress(agentId: string) { + const handleAgentPress = useCallback((agentId: string) => { + if (isActionSheetVisible) { + return; + } router.push(`/agent/${agentId}`); - } + }, [isActionSheetVisible]); + + const handleAgentLongPress = useCallback((agent: Agent) => { + setActionAgent(agent); + }, []); + + const handleCloseActionSheet = useCallback(() => { + setActionAgent(null); + }, []); + + const handleDeleteAgent = useCallback(() => { + if (!actionAgent) { + return; + } + deleteAgent(actionAgent.id); + setActionAgent(null); + }, [actionAgent, deleteAgent]); return ( - - {agentArray.map((agent) => { - const statusColor = getAgentStatusColor(agent.status); - const statusLabel = getAgentStatusLabel(agent.status); - const timeAgo = formatTimeAgo(agent.lastActivityAt); - const providerLabel = getAgentProviderDefinition(agent.provider).label; + <> + + {agentArray.map((agent) => { + const statusColor = getAgentStatusColor(agent.status); + const statusLabel = getAgentStatusLabel(agent.status); + const timeAgo = formatTimeAgo(agent.lastActivityAt); + const providerLabel = getAgentProviderDefinition(agent.provider).label; - return ( - [ - styles.agentItem, - pressed && styles.agentItemPressed, - ]} - onPress={() => handleAgentPress(agent.id)} - > - - - {agent.title || "New Agent"} - - - - {agent.cwd} - - - - - - - {providerLabel} - - - - - - - {statusLabel} - - - - - - {timeAgo} + return ( + [ + styles.agentItem, + pressed && styles.agentItemPressed, + ]} + onPress={() => handleAgentPress(agent.id)} + onLongPress={() => handleAgentLongPress(agent)} + > + + + {agent.title || "New Agent"} + + + {agent.cwd} + + + + + + + {providerLabel} + + + + + + + {statusLabel} + + + + + + {timeAgo} + + - - - ); - })} - + + ); + })} + + + + + + + + + {actionAgent?.title || "Delete agent"} + + + Removing this agent only deletes it from Voice Dev. Claude/Codex keeps the original project. + + + Delete agent + + + Cancel + + + + + ); } @@ -157,4 +218,61 @@ const styles = StyleSheet.create((theme) => ({ fontSize: theme.fontSize.xs, color: theme.colors.mutedForeground, }, + sheetOverlay: { + flex: 1, + justifyContent: "flex-end", + }, + sheetBackdrop: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + backgroundColor: "rgba(0,0,0,0.35)", + }, + sheetContainer: { + backgroundColor: theme.colors.card, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + paddingHorizontal: theme.spacing[6], + paddingTop: theme.spacing[4], + paddingBottom: theme.spacing[6], + gap: theme.spacing[4], + }, + sheetHandle: { + alignSelf: "center", + width: 40, + height: 4, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.muted, + }, + sheetTitle: { + fontSize: theme.fontSize.lg, + fontWeight: theme.fontWeight.semibold, + color: theme.colors.foreground, + }, + sheetSubtitle: { + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + }, + sheetButton: { + borderRadius: theme.borderRadius.lg, + paddingVertical: theme.spacing[3], + alignItems: "center", + justifyContent: "center", + }, + sheetDeleteButton: { + backgroundColor: theme.colors.destructive, + }, + sheetDeleteText: { + color: theme.colors.destructiveForeground, + fontWeight: theme.fontWeight.semibold, + }, + sheetCancelButton: { + backgroundColor: theme.colors.muted, + }, + sheetCancelText: { + color: theme.colors.foreground, + fontWeight: theme.fontWeight.semibold, + }, })); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 05cc94e4e..8c8adcde6 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -222,8 +222,10 @@ interface SessionContextValue { // Helpers initializeAgent: (params: { agentId: string; requestId?: string }) => void; refreshAgent: (params: { agentId: string; requestId?: string }) => void; + cancelAgentRun: (agentId: string) => void; sendAgentMessage: (agentId: string, message: string, imageUris?: string[]) => Promise; sendAgentAudio: (agentId: string, audioBlob: Blob, requestId?: string) => Promise; + deleteAgent: (agentId: string) => void; createAgent: (options: { config: AgentSessionConfig; worktreeName?: string; requestId?: string }) => void; resumeAgent: (options: { handle: AgentPersistenceHandle; overrides?: Partial; requestId?: string }) => void; setAgentMode: (agentId: string, modeId: string) => void; @@ -712,6 +714,71 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { }); }); + const unsubAgentDeleted = ws.on("agent_deleted", (message) => { + if (message.type !== "agent_deleted") { + return; + } + const { agentId } = message.payload; + console.log("[Session] Agent deleted:", agentId); + + setAgents((prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.delete(agentId); + return next; + }); + + setAgentStreamState((prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.delete(agentId); + return next; + }); + + setPendingPermissions((prev) => { + let changed = false; + const next = new Map(prev); + for (const [key, pending] of prev.entries()) { + if (pending.agentId === agentId) { + next.delete(key); + changed = true; + } + } + return changed ? next : prev; + }); + + setInitializingAgents((prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.delete(agentId); + return next; + }); + + setGitDiffs((prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.delete(agentId); + return next; + }); + + setFileExplorer((prev) => { + if (!prev.has(agentId)) { + return prev; + } + const next = new Map(prev); + next.delete(agentId); + return next; + }); + }); + return () => { unsubSessionState(); unsubAgentState(); @@ -726,6 +793,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { unsubTranscription(); unsubGitDiff(); unsubFileExplorer(); + unsubAgentDeleted(); }; }, [ws, audioPlayer, setIsPlayingAudio, updateExplorerState]); @@ -777,6 +845,28 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { ws.send(msg); }, [ws]); + const cancelAgentRun = useCallback((agentId: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "cancel_agent_request", + agentId, + }, + }; + ws.send(msg); + }, [ws]); + + const deleteAgent = useCallback((agentId: string) => { + const msg: WSInboundMessage = { + type: "session", + message: { + type: "delete_agent_request", + agentId, + }, + }; + ws.send(msg); + }, [ws]); + const sendAgentMessage = useCallback(async (agentId: string, message: string, imageUris?: string[]) => { // Generate unique message ID for deduplication const messageId = generateMessageId(); @@ -1003,6 +1093,8 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { requestFilePreview, initializeAgent, refreshAgent, + cancelAgentRun, + deleteAgent, sendAgentMessage, sendAgentAudio, createAgent, diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index 73ebcefe6..02341169e 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -293,6 +293,11 @@ export const DeleteConversationRequestMessageSchema = z.object({ conversationId: z.string(), }); +export const DeleteAgentRequestMessageSchema = z.object({ + type: z.literal("delete_agent_request"), + agentId: z.string(), +}); + export const SetRealtimeModeMessageSchema = z.object({ type: z.literal("set_realtime_mode"), enabled: z.boolean(), @@ -338,6 +343,11 @@ export const RefreshAgentRequestMessageSchema = z.object({ requestId: z.string().optional(), }); +export const CancelAgentRequestMessageSchema = z.object({ + type: z.literal("cancel_agent_request"), + agentId: z.string(), +}); + export const ListPersistedAgentsRequestMessageSchema = z.object({ type: z.literal("list_persisted_agents_request"), provider: AgentProviderSchema.optional(), @@ -406,12 +416,14 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ LoadConversationRequestMessageSchema, ListConversationsRequestMessageSchema, DeleteConversationRequestMessageSchema, + DeleteAgentRequestMessageSchema, SetRealtimeModeMessageSchema, SendAgentMessageSchema, SendAgentAudioSchema, CreateAgentRequestMessageSchema, ResumeAgentRequestMessageSchema, RefreshAgentRequestMessageSchema, + CancelAgentRequestMessageSchema, InitializeAgentRequestMessageSchema, SetAgentModeMessageSchema, AgentPermissionResponseMessageSchema, @@ -593,6 +605,13 @@ export const AgentPermissionResolvedMessageSchema = z.object({ }), }); +export const AgentDeletedMessageSchema = z.object({ + type: z.literal("agent_deleted"), + payload: z.object({ + agentId: z.string(), + }), +}); + const PersistedAgentDescriptorPayloadSchema = z.object({ provider: AgentProviderSchema, sessionId: z.string(), @@ -649,6 +668,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ DeleteConversationResponseMessageSchema, AgentPermissionRequestMessageSchema, AgentPermissionResolvedMessageSchema, + AgentDeletedMessageSchema, ListPersistedAgentsResponseSchema, GitDiffResponseSchema, FileExplorerResponseSchema, @@ -677,6 +697,7 @@ export type ListConversationsResponseMessage = z.infer; export type AgentPermissionRequestMessage = z.infer; export type AgentPermissionResolvedMessage = z.infer; +export type AgentDeletedMessage = z.infer; export type ListPersistedAgentsResponseMessage = z.infer; // Type exports for payload types @@ -689,6 +710,7 @@ export type SendAgentMessage = z.infer; export type SendAgentAudio = z.infer; export type CreateAgentRequestMessage = z.infer; export type ResumeAgentRequestMessage = z.infer; +export type DeleteAgentRequestMessage = z.infer; export type ListPersistedAgentsRequestMessage = z.infer; export type InitializeAgentRequestMessage = z.infer; export type SetAgentModeMessage = z.infer; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 077d2fb89..83e4f86be 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -529,6 +529,10 @@ export class Session { await this.handleDeleteConversation(msg.conversationId); break; + case "delete_agent_request": + await this.handleDeleteAgentRequest(msg.agentId); + break; + case "set_realtime_mode": this.handleSetRealtimeMode(msg.enabled); break; @@ -558,6 +562,10 @@ export class Session { await this.handleRefreshAgentRequest(msg); break; + case "cancel_agent_request": + await this.handleCancelAgentRequest(msg.agentId); + break; + case "initialize_agent_request": await this.handleInitializeAgentRequest(msg.agentId, msg.requestId); break; @@ -685,6 +693,38 @@ export class Session { } } + private async handleDeleteAgentRequest(agentId: string): Promise { + console.log( + `[Session ${this.clientId}] Deleting agent ${agentId} from registry` + ); + + try { + await this.agentManager.closeAgent(agentId); + } catch (error: any) { + console.warn( + `[Session ${this.clientId}] Failed to close agent ${agentId} during delete:`, + error + ); + } + + try { + await this.agentRegistry.remove(agentId); + } catch (error: any) { + console.error( + `[Session ${this.clientId}] Failed to remove agent ${agentId} from registry:`, + error + ); + } + + this.agentTitleCache.delete(agentId); + this.emit({ + type: "agent_deleted", + payload: { + agentId, + }, + }); + } + /** * Handle realtime mode toggle */ @@ -1053,6 +1093,22 @@ export class Session { } } + private async handleCancelAgentRequest(agentId: string): Promise { + console.log( + `[Session ${this.clientId}] Cancel request received for agent ${agentId}` + ); + + try { + await this.interruptAgentIfRunning(agentId); + } catch (error) { + this.handleAgentRunError( + agentId, + error, + "Failed to cancel running agent on request" + ); + } + } + private async buildAgentSessionConfig( config: AgentSessionConfig, worktreeName?: string diff --git a/plan.md b/plan.md index e943c51ac..35708cf88 100644 --- a/plan.md +++ b/plan.md @@ -45,6 +45,8 @@ - Forced the app Vitest config to use the `forks` pool (keeps `process.send` intact for Expo/xcode helpers) so the suite boots reliably; `npm test --workspace=@voice-dev/app` now runs the harness tests and reproduces the expected hydration failure instead of crashing ahead of collection. - [x] WARN [expo-image-picker] `ImagePicker.MediaTypeOptions` have been deprecated. Use `ImagePicker.MediaType` or an array of `ImagePicker.MediaType` instead. - Replaced the deprecated `MediaTypeOptions.Images` flag with the new literal `["images"]` media type array in `packages/app/src/hooks/use-image-attachment-picker.ts`, silencing the Expo warning when attaching photos. `npm run typecheck --workspace=@voice-dev/app` still fails in the pre-existing stream harness files (unchanged from before this fix). -- [ ] Update the AgentInput controls so the buttons reflect agent state: when idle show `Dictate` + `Realtime` by default (switch to `Send` when the text box has content); when an agent is running show `Realtime` + `Cancel` regardless of input so you can interrupt without typing. Realtime toggle must remain available in both states. -- [ ] Implement long press on the agent list item to opena a light bottom sheet with some actions, for now: "Delete agent" all this will do is remove it from our own persistence, but the agent will still exist in the claude/codex persistance and thats out of scope to remove. Its esentially just to remove it from the list, disown it. +- [x] Update the AgentInput controls so the buttons reflect agent state: when idle show `Dictate` + `Realtime` by default (switch to `Send` when the text box has content); when an agent is running show `Realtime` + `Cancel` regardless of input so you can interrupt without typing. Realtime toggle must remain available in both states. + - AgentInput now inspects the agent’s status and swaps the right-hand controls accordingly: send replaces dictate once text/images exist, running agents expose a new Cancel pill, and the realtime toggle is always available (it now starts/stops realtime rather than disappearing). Cancel dispatches a new `cancel_agent_request` websocket message that the server routes through `interruptAgentIfRunning`, so make sure any future API clients send that message when wiring similar controls. (FYI `npm run typecheck --workspace=@voice-dev/app` still fails in the existing stream harness suites for unrelated reasons.) +- [x] Implement long press on the agent list item to opena a light bottom sheet with some actions, for now: "Delete agent" all this will do is remove it from our own persistence, but the agent will still exist in the claude/codex persistance and thats out of scope to remove. Its esentially just to remove it from the list, disown it. + - Added a `delete_agent_request` flow: the session now closes the agent, removes its registry entry, and emits a dedicated `agent_deleted` event that the client listens for to prune the agents map/stream state. Long-pressing any agent row opens a lightweight bottom sheet with a Delete action wired to that new context method, so deleting disowns the agent locally without touching the Claude/Codex session. - [ ] Implement a function `ensureValidJson` that walks an object and validates that all values are valid JSON (no undefined, null, or non-string values). Use this for all return valies in the MCP server tools.