diff --git a/packages/app/src/app/agent/[id].tsx b/packages/app/src/app/agent/[id].tsx index 2d663bc59..5b4aca215 100644 --- a/packages/app/src/app/agent/[id].tsx +++ b/packages/app/src/app/agent/[id].tsx @@ -18,10 +18,55 @@ import { BackHeader } from "@/components/headers/back-header"; import { AgentStreamView } from "@/components/agent-stream-view"; import { AgentInputArea } from "@/components/agent-input-area"; import { useSession } from "@/contexts/session-context"; +import type { Agent } from "@/contexts/session-context"; import { useFooterControls } from "@/contexts/footer-controls-context"; +import { generateMessageId } from "@/types/stream"; const DROPDOWN_WIDTH = 220; +type BranchStatus = "idle" | "loading" | "ready" | "error"; + +function extractAgentModel(agent?: Agent | null): string | null { + if (!agent) { + return null; + } + + const directModel = typeof agent.model === "string" ? agent.model.trim() : ""; + if (directModel.length > 0) { + return directModel; + } + + const metadata = agent.persistence?.metadata; + if (!metadata || typeof metadata !== "object") { + return null; + } + + const persistedModel = (metadata as Record).model; + if (typeof persistedModel === "string" && persistedModel.trim().length > 0) { + return persistedModel.trim(); + } + + const extra = (metadata as Record).extra; + if (!extra || typeof extra !== "object") { + return null; + } + + const getModelFrom = (source: unknown) => { + if (!source || typeof source !== "object") { + return null; + } + const candidate = (source as Record).model; + return typeof candidate === "string" && candidate.trim().length > 0 + ? candidate.trim() + : null; + }; + + return ( + getModelFrom((extra as Record).codex) ?? + getModelFrom((extra as Record).claude) + ); +} + export default function AgentScreen() { const { theme } = useUnistyles(); const insets = useSafeAreaInsets(); @@ -36,6 +81,7 @@ export default function AgentScreen() { initializeAgent, refreshAgent, setFocusedAgentId, + ws, } = useSession(); const { registerFooterControls, unregisterFooterControls } = useFooterControls(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); @@ -43,6 +89,10 @@ export default function AgentScreen() { const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const [menuContentHeight, setMenuContentHeight] = useState(0); const menuButtonRef = useRef(null); + const [branchStatus, setBranchStatus] = useState("idle"); + const [branchLabel, setBranchLabel] = useState(null); + const [branchError, setBranchError] = useState(null); + const repoInfoRequestIdRef = useRef(null); // Keyboard animation const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); @@ -66,6 +116,86 @@ export default function AgentScreen() { const agentPermissions = new Map( Array.from(pendingPermissions.entries()).filter(([_, perm]) => perm.agentId === id) ); + const agentModel = extractAgentModel(agent); + const modelDisplayValue = agentModel ?? "Unknown"; + + const resetBranchState = useCallback(() => { + repoInfoRequestIdRef.current = null; + setBranchStatus("idle"); + setBranchLabel(null); + setBranchError(null); + }, []); + + const sendGitRepoInfoRequest = useCallback( + (cwd: string) => { + if (!cwd) { + resetBranchState(); + return; + } + + const requestId = generateMessageId(); + repoInfoRequestIdRef.current = requestId; + setBranchStatus("loading"); + setBranchLabel(null); + setBranchError(null); + + ws.send({ + type: "session", + message: { + type: "git_repo_info_request", + cwd, + requestId, + }, + }); + }, + [resetBranchState, ws] + ); + + useEffect(() => { + if (!agent?.cwd) { + resetBranchState(); + return; + } + + sendGitRepoInfoRequest(agent.cwd); + }, [agent?.cwd, resetBranchState, sendGitRepoInfoRequest]); + + useEffect(() => { + const unsubscribe = ws.on("git_repo_info_response", (message) => { + if (message.type !== "git_repo_info_response") { + return; + } + + if ( + repoInfoRequestIdRef.current && + message.payload.requestId && + message.payload.requestId !== repoInfoRequestIdRef.current + ) { + return; + } + + if (agent?.cwd && message.payload.cwd && message.payload.cwd !== agent.cwd) { + return; + } + + repoInfoRequestIdRef.current = null; + + if (message.payload.error) { + setBranchStatus("error"); + setBranchError(message.payload.error); + setBranchLabel(null); + return; + } + + setBranchStatus("ready"); + setBranchError(null); + setBranchLabel(message.payload.currentBranch ?? null); + }); + + return () => { + unsubscribe(); + }; + }, [agent?.cwd, ws]); useEffect(() => { if (!id) { @@ -169,10 +299,14 @@ export default function AgentScreen() { ); const handleOpenMenu = useCallback(() => { + if (agent?.cwd) { + sendGitRepoInfoRequest(agent.cwd); + } + recalculateMenuPosition(() => { setMenuVisible(true); }); - }, [recalculateMenuPosition]); + }, [agent?.cwd, recalculateMenuPosition, sendGitRepoInfoRequest]); const handleCloseMenu = useCallback(() => { setMenuVisible(false); @@ -192,6 +326,10 @@ export default function AgentScreen() { setMenuContentHeight((current) => (current === height ? current : height)); }, []); + const handleBackToHome = useCallback(() => { + router.replace("/"); + }, [router]); + const handleViewChanges = useCallback(() => { handleCloseMenu(); if (id) { @@ -214,10 +352,15 @@ export default function AgentScreen() { refreshAgent({ agentId: id }); }, [handleCloseMenu, id, refreshAgent]); + const branchDisplayValue = + branchStatus === "error" + ? branchError ?? "Unavailable" + : branchLabel ?? "Unknown"; + if (!agent) { return ( - + Agent not found @@ -230,6 +373,7 @@ export default function AgentScreen() { {/* Header */} @@ -282,6 +426,58 @@ export default function AgentScreen() { ]} onLayout={handleMenuLayout} > + + + Directory + + {agent.cwd} + + + + + Model + + {modelDisplayValue} + + + + + Branch + + {branchStatus === "loading" ? ( + <> + + Fetching… + + ) : ( + + {branchDisplayValue} + + )} + + + + + + View Changes @@ -368,6 +564,40 @@ const styles = StyleSheet.create((theme) => ({ shadowRadius: 8, elevation: 5, }, + menuMetaContainer: { + gap: theme.spacing[2], + marginBottom: theme.spacing[2], + }, + menuMetaRow: { + gap: theme.spacing[1], + }, + menuMetaLabel: { + fontSize: theme.fontSize.xs, + color: theme.colors.mutedForeground, + letterSpacing: 0.5, + textTransform: "uppercase", + }, + menuMetaValue: { + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + menuMetaValueRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + menuMetaPendingText: { + fontSize: theme.fontSize.sm, + color: theme.colors.mutedForeground, + }, + menuMetaValueError: { + color: theme.colors.destructive, + }, + menuDivider: { + height: StyleSheet.hairlineWidth, + backgroundColor: theme.colors.border, + marginVertical: theme.spacing[2], + }, menuItem: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 6205c9eb3..f86c44b6e 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -7,7 +7,6 @@ import type { ActivityLogPayload, AgentSnapshotPayload, AgentStreamEventPayload, - SessionInboundMessage, WSInboundMessage, GitSetupOptions, } from "@server/server/messages"; @@ -104,6 +103,7 @@ export interface Agent { lastError?: string | null; title: string | null; cwd: string; + model: string | null; } export interface Command { @@ -178,6 +178,14 @@ const pushHistory = (history: string[], path: string): string[] => { return [...normalizedHistory, path]; }; +type PendingAgentLifecycleRequest = { + kind: "initialize" | "refresh"; + params: { + agentId: string; + requestId?: string; + }; +}; + function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent { const createdAt = new Date(snapshot.createdAt); const updatedAt = new Date(snapshot.updatedAt); @@ -200,6 +208,7 @@ function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload): Agent { lastError: snapshot.lastError ?? null, title: snapshot.title ?? null, cwd: snapshot.cwd, + model: snapshot.model ?? null, }; } @@ -293,6 +302,7 @@ interface SessionProviderProps { export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const ws = useWebSocket(serverUrl); + const wsIsConnected = ws.isConnected; // State for voice detection flags (will be set by RealtimeContext) const isDetectingRef = useRef(false); @@ -322,6 +332,7 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { const activeAudioGroupsRef = useRef>(new Set()); const previousAgentStatusRef = useRef>(new Map()); const providerModelRequestIdsRef = useRef>(new Map()); + const pendingAgentLifecycleRequestsRef = useRef([]); // Buffer for streaming audio chunks interface AudioChunk { @@ -377,6 +388,41 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { [] ); + const sendAgentLifecycleRequest = useCallback( + (request: PendingAgentLifecycleRequest) => { + const { kind, params } = request; + const messageType = kind === "initialize" ? "initialize_agent_request" : "refresh_agent_request"; + + const msg: WSInboundMessage = { + type: "session", + message: { + type: messageType, + agentId: params.agentId, + requestId: params.requestId, + }, + }; + ws.send(msg); + }, + [ws] + ); + + useEffect(() => { + if (!wsIsConnected) { + return; + } + + if (pendingAgentLifecycleRequestsRef.current.length === 0) { + return; + } + + const pending = [...pendingAgentLifecycleRequestsRef.current]; + pendingAgentLifecycleRequestsRef.current = []; + + for (const request of pending) { + sendAgentLifecycleRequest(request); + } + }, [wsIsConnected, sendAgentLifecycleRequest]); + // WebSocket message handlers useEffect(() => { // Session state - initial agents/commands @@ -484,6 +530,16 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { return next; }); + setInitializingAgents((prev) => { + const currentState = prev.get(agentId); + if (currentState === false) { + return prev; + } + const next = new Map(prev); + next.set(agentId, false); + return next; + }); + setAgents((prev) => { const existing = prev.get(agentId); if (!existing) { @@ -1016,16 +1072,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { return next; }); - const msg: WSInboundMessage = { - type: "session", - message: { - type: "initialize_agent_request", - agentId, - requestId, - }, - }; - ws.send(msg); - }, [ws]); + if (!wsIsConnected) { + pendingAgentLifecycleRequestsRef.current.push({ + kind: "initialize", + params: { agentId, requestId }, + }); + return; + } + + sendAgentLifecycleRequest({ + kind: "initialize", + params: { agentId, requestId }, + }); + }, [wsIsConnected, sendAgentLifecycleRequest]); const refreshAgent = useCallback(({ agentId, requestId }: { agentId: string; requestId?: string }) => { setInitializingAgents((prev) => { @@ -1040,16 +1099,19 @@ export function SessionProvider({ children, serverUrl }: SessionProviderProps) { return next; }); - const msg: WSInboundMessage = { - type: "session", - message: { - type: "refresh_agent_request", - agentId, - requestId, - }, - }; - ws.send(msg); - }, [ws]); + if (!wsIsConnected) { + pendingAgentLifecycleRequestsRef.current.push({ + kind: "refresh", + params: { agentId, requestId }, + }); + return; + } + + sendAgentLifecycleRequest({ + kind: "refresh", + params: { agentId, requestId }, + }); + }, [wsIsConnected, sendAgentLifecycleRequest]); const requestProviderModels = useCallback((provider: AgentProvider, options?: { cwd?: string }) => { const requestId = generateMessageId();