From 809eacc0411cb1910add4d3a7ddea632738b7ce4 Mon Sep 17 00:00:00 2001 From: haikdc Date: Fri, 20 Mar 2026 23:41:20 -0700 Subject: [PATCH] [Haik]: round 2 of dynamic island redesign (collapsible completed tab) (approve/deny from collapsed state) (height limited and scrollable expanded state) --- backend/apps/agents/agent_manager.py | 5 + backend/apps/agents/ws_manager.py | 11 +- frontend/src/app/components/DynamicIsland.tsx | 264 ++++++++++++++++-- frontend/src/shared/state/agentsSlice.ts | 57 +++- 4 files changed, 301 insertions(+), 36 deletions(-) diff --git a/backend/apps/agents/agent_manager.py b/backend/apps/agents/agent_manager.py index c587db65..b2ed4a7f 100644 --- a/backend/apps/agents/agent_manager.py +++ b/backend/apps/agents/agent_manager.py @@ -1490,6 +1490,9 @@ class AgentManager: if session.status in ("running", "waiting_approval"): session.status = "stopped" session.closed_at = datetime.now() + + for req in list(session.pending_approvals): + ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Session closed"}) session.pending_approvals = [] if hasattr(session, '_cancel_event'): @@ -1619,6 +1622,8 @@ class AgentManager: for session_id, session in list(self.sessions.items()): if session.status in ("running", "waiting_approval"): session.status = "stopped" + for req in list(session.pending_approvals): + ws_manager.resolve_approval(req.id, {"behavior": "deny", "message": "Server shutting down"}) session.pending_approvals = [] doc_data = session.model_dump(mode="json") doc_data["search_text"] = self._build_search_text(session) diff --git a/backend/apps/agents/ws_manager.py b/backend/apps/agents/ws_manager.py index 213803a4..24690628 100644 --- a/backend/apps/agents/ws_manager.py +++ b/backend/apps/agents/ws_manager.py @@ -61,10 +61,12 @@ class ConnectionManager: pass async def send_approval_request( - self, session_id: str, request_id: str, tool_name: str, tool_input: dict + self, session_id: str, request_id: str, tool_name: str, tool_input: dict, + timeout: float = 600.0, ) -> dict: """Send an approval request and wait for the user's response. - Returns the approval decision dict.""" + Returns the approval decision dict. Times out after *timeout* seconds + (default 10 minutes) to prevent permanently stuck agents.""" future = asyncio.get_event_loop().create_future() self.pending_futures[request_id] = future @@ -75,8 +77,11 @@ class ConnectionManager: }) try: - result = await future + result = await asyncio.wait_for(future, timeout=timeout) return result + except asyncio.TimeoutError: + logger.warning("Approval %s for session %s timed out after %ss", request_id, session_id, timeout) + return {"behavior": "deny", "message": "Approval timed out"} finally: self.pending_futures.pop(request_id, None) diff --git a/frontend/src/app/components/DynamicIsland.tsx b/frontend/src/app/components/DynamicIsland.tsx index eea4233d..10a8183d 100644 --- a/frontend/src/app/components/DynamicIsland.tsx +++ b/frontend/src/app/components/DynamicIsland.tsx @@ -2,10 +2,15 @@ import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react' import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; import Tooltip from '@mui/material/Tooltip'; +import Collapse from '@mui/material/Collapse'; import SearchIcon from '@mui/icons-material/Search'; import StopCircleOutlinedIcon from '@mui/icons-material/StopCircleOutlined'; import CloseIcon from '@mui/icons-material/Close'; +import CheckIcon from '@mui/icons-material/Check'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import { motion, AnimatePresence } from 'framer-motion'; import { useNavigate } from 'react-router-dom'; import { useAppDispatch, useAppSelector } from '@/shared/hooks'; @@ -13,6 +18,7 @@ import { handleApproval, stopAgent, dismissAgentNotification, + dismissAllFinishedNotifications, ApprovalRequest, AgentSession, HistorySession, @@ -25,7 +31,7 @@ import { useClaudeTokens } from '@/shared/styles/ThemeContext'; // Types // --------------------------------------------------------------------------- -type IslandState = 'idle' | 'compact' | 'expanded'; +type IslandState = 'idle' | 'compact' | 'compact-actionable' | 'expanded'; interface SessionApprovalGroup { sessionId: string; @@ -257,14 +263,26 @@ const DynamicIsland: React.FC = () => { const hasApprovals = totalApprovals > 0; const hasAgents = trackedAgents.length > 0; + const hasOnlyQuestionApprovals = useMemo(() => { + if (!hasApprovals) return false; + const allApprovals = groups.flatMap((g) => g.approvals); + return allApprovals.every((a) => a.tool_name === 'AskUserQuestion'); + }, [hasApprovals, groups]); + + const nonQuestionApprovalCount = useMemo( + () => groups.reduce((sum, g) => sum + g.approvals.filter((a) => a.tool_name !== 'AskUserQuestion').length, 0), + [groups], + ); + // ---- Island state machine ---- const islandState: IslandState = useMemo(() => { - if (hasApprovals) return 'expanded'; - if (userExpanded && hasAgents) return 'expanded'; + if (userExpanded && (hasAgents || hasApprovals)) return 'expanded'; + if (hasApprovals && hasOnlyQuestionApprovals) return 'expanded'; + if (hasApprovals) return 'compact-actionable'; if (hasAgents) return 'compact'; return 'idle'; - }, [hasApprovals, userExpanded, hasAgents]); + }, [hasApprovals, hasOnlyQuestionApprovals, userExpanded, hasAgents]); useEffect(() => { if (!hasAgents && !hasApprovals) { @@ -278,12 +296,12 @@ const DynamicIsland: React.FC = () => { if (islandState !== 'expanded') return; const handler = (e: MouseEvent) => { if (islandRef.current && !islandRef.current.contains(e.target as Node)) { - if (!hasApprovals) setUserExpanded(false); + setUserExpanded(false); } }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); - }, [islandState, hasApprovals]); + }, [islandState]); // ---- Callbacks ---- @@ -319,13 +337,37 @@ const DynamicIsland: React.FC = () => { [navigate, dispatch], ); + const onApproveAllNonQuestion = useCallback(() => { + for (const g of groups) { + for (const req of g.approvals) { + if (req.tool_name !== 'AskUserQuestion') { + dispatch(handleApproval({ requestId: req.id, behavior: 'allow' })); + } + } + } + }, [dispatch, groups]); + + const onDenyAllNonQuestion = useCallback(() => { + for (const g of groups) { + for (const req of g.approvals) { + if (req.tool_name !== 'AskUserQuestion') { + dispatch(handleApproval({ requestId: req.id, behavior: 'deny' })); + } + } + } + }, [dispatch, groups]); + + const onClearAllFinished = useCallback(() => { + dispatch(dismissAllFinishedNotifications()); + }, [dispatch]); + const handleIslandClick = useCallback(() => { - if (islandState === 'compact') { + if (islandState === 'compact' || islandState === 'compact-actionable') { setUserExpanded(true); - } else if (islandState === 'expanded' && !hasApprovals) { + } else if (islandState === 'expanded') { setUserExpanded(false); } - }, [islandState, hasApprovals]); + }, [islandState]); // ---- Styling — uses the same neutral palette as the rest of the UI ---- @@ -333,7 +375,9 @@ const DynamicIsland: React.FC = () => { ? 200 : islandState === 'compact' ? 210 - : 400; + : islandState === 'compact-actionable' + ? 310 + : 400; const islandBorderRadius = islandState === 'expanded' ? 14 : 50; @@ -375,7 +419,7 @@ const DynamicIsland: React.FC = () => { // @ts-expect-error -- vendor prefix WebkitAppRegion: 'no-drag', }} - onClick={islandState !== 'expanded' ? handleIslandClick : undefined} + onClick={islandState !== 'expanded' && islandState !== 'compact-actionable' ? handleIslandClick : undefined} > { hasApprovals={hasApprovals} /> )} + {islandState === 'compact-actionable' && ( + setUserExpanded(true)} + /> + )} {islandState === 'expanded' && ( { onStopAgent={onStopAgent} onDismissAgent={onDismissAgent} onNavigateToDashboard={onNavigateToDashboard} + onClearAllFinished={onClearAllFinished} onCollapse={() => setUserExpanded(false)} /> )} @@ -521,6 +576,112 @@ const CompactPill: React.FC<{ ); +// --------------------------------------------------------------------------- +// Compact-actionable pill — inline approve/deny without expanding +// --------------------------------------------------------------------------- + +const CompactActionablePill: React.FC<{ + c: ReturnType; + approvalCount: number; + onApproveAll: () => void; + onDenyAll: () => void; + onExpand: () => void; +}> = ({ c, approvalCount, onApproveAll, onDenyAll, onExpand }) => ( + + + + + {approvalCount} {approvalCount === 1 ? 'approval' : 'approvals'} + + + + + { e.stopPropagation(); onExpand(); }} + sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }} + > + + + + + +); + // --------------------------------------------------------------------------- // Expanded card // --------------------------------------------------------------------------- @@ -538,12 +699,14 @@ const ExpandedCard: React.FC<{ onStopAgent: (id: string) => void; onDismissAgent: (id: string) => void; onNavigateToDashboard: (dashboardId: string, agentId: string) => void; + onClearAllFinished: () => void; onCollapse: () => void; }> = ({ c, groups, totalApprovals, activeAgents, finishedAgents, hasApprovals, hasAgents, - onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onCollapse, + onApprove, onDeny, onStopAgent, onDismissAgent, onNavigateToDashboard, onClearAllFinished, onCollapse, }) => { + const [completedExpanded, setCompletedExpanded] = useState(false); const headerTitle = hasApprovals && !hasAgents ? 'Approval Required' : hasAgents && !hasApprovals @@ -612,7 +775,7 @@ const ExpandedCard: React.FC<{ ))} - {finishedAgents.map((agent) => ( - - ))} + {finishedAgents.length > 0 && ( + <> + {activeAgents.length > 0 && ( + + )} + setCompletedExpanded((v) => !v)} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 1, + px: 2, + py: 0.5, + cursor: 'pointer', + userSelect: 'none', + '&:hover': { bgcolor: c.border.subtle }, + transition: 'background-color 0.15s', + }} + > + + Completed ({finishedAgents.length}) + + { e.stopPropagation(); onClearAllFinished(); }} + sx={{ + fontSize: '0.58rem', + fontWeight: 600, + color: c.text.ghost, + cursor: 'pointer', + '&:hover': { color: c.text.secondary }, + transition: 'color 0.15s', + }} + > + Clear all + + + {completedExpanded + ? + : } + + + + {finishedAgents.map((agent) => ( + + ))} + + + )} )} diff --git a/frontend/src/shared/state/agentsSlice.ts b/frontend/src/shared/state/agentsSlice.ts index f32732e8..1c01df98 100644 --- a/frontend/src/shared/state/agentsSlice.ts +++ b/frontend/src/shared/state/agentsSlice.ts @@ -318,11 +318,14 @@ export const handleApproval = createAsyncThunk( message?: string; updatedInput?: Record; }) => { - await fetch(`${AGENTS_API}/approval`, { + const res = await fetch(`${AGENTS_API}/approval`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ request_id: requestId, behavior, message, updated_input: updatedInput }), }); + if (!res.ok) { + throw new Error(`Approval request failed (${res.status})`); + } return { requestId, behavior }; } ); @@ -528,8 +531,15 @@ const agentsSlice = createSlice({ } } const existing = state.sessions[action.payload.id]; + // Preserve local pending_approvals if the server payload has none but + // the frontend has some (avoids race where backend clears approvals + // before the frontend processes the removal). + const mergedApprovals = existing?.pending_approvals?.length && !action.payload.pending_approvals?.length + ? existing.pending_approvals + : action.payload.pending_approvals ?? []; state.sessions[action.payload.id] = { ...action.payload, + pending_approvals: mergedApprovals, streamingMessage: existing?.streamingMessage ?? action.payload.streamingMessage ?? null, tool_group_meta: { ...existing?.tool_group_meta, ...action.payload.tool_group_meta }, }; @@ -717,6 +727,17 @@ const agentsSlice = createSlice({ (id) => id !== action.payload, ); }, + + dismissAllFinishedNotifications(state) { + const finishedStatuses = new Set(['completed', 'error', 'stopped']); + state.trackedNotificationIds = state.trackedNotificationIds.filter((id) => { + const session = state.sessions[id]; + if (session) return !finishedStatuses.has(session.status); + const hist = state.history[id]; + if (hist) return !finishedStatuses.has(hist.status); + return true; + }); + }, }, extraReducers: (builder) => { builder @@ -725,22 +746,32 @@ const agentsSlice = createSlice({ }) .addCase(fetchSessions.fulfilled, (state, action) => { state.loading = false; - const sessions: Record = {}; - const trackedSet = new Set(state.trackedNotificationIds); + const fetchedIds = new Set(action.payload.map((s) => s.id)); + const activeStatuses = new Set(['running', 'waiting_approval']); + + // Remove stale sessions that belong to this dashboard fetch but + // are no longer returned by the server — keep sessions from other + // dashboards, drafts, tracked notifications, and active sessions. for (const [id, existing] of Object.entries(state.sessions)) { - if (existing.status === 'draft' || trackedSet.has(id)) sessions[id] = existing; + if (fetchedIds.has(id)) continue; + if (existing.status === 'draft') continue; + if (state.trackedNotificationIds.includes(id)) continue; + if (activeStatuses.has(existing.status)) continue; + delete state.sessions[id]; } + + // Merge fetched sessions, preserving local-only fields for (const s of action.payload) { const existing = state.sessions[s.id]; - sessions[s.id] = { + state.sessions[s.id] = { ...s, + pending_approvals: existing?.pending_approvals?.length + ? existing.pending_approvals + : s.pending_approvals ?? [], streamingMessage: existing?.streamingMessage ?? s.streamingMessage ?? null, - tool_group_meta: s.tool_group_meta ?? {}, + tool_group_meta: { ...existing?.tool_group_meta, ...s.tool_group_meta }, }; - } - state.sessions = sessions; - for (const s of action.payload) { - if ((s.status === 'running' || s.status === 'waiting_approval') && !state.trackedNotificationIds.includes(s.id)) { + if (activeStatuses.has(s.status) && !state.trackedNotificationIds.includes(s.id)) { state.trackedNotificationIds.push(s.id); } } @@ -822,6 +853,11 @@ const agentsSlice = createSlice({ ); } }) + .addCase(handleApproval.rejected, (_state, action) => { + // Approval stays in state so the user can retry. + // The request was never delivered to the backend. + console.error('Approval request failed:', action.error.message); + }) .addCase(switchBranch.fulfilled, (state, action) => { const session = state.sessions[action.payload.sessionId]; if (session) { @@ -975,6 +1011,7 @@ export const { clearHistorySearch, trackAgentNotification, dismissAgentNotification, + dismissAllFinishedNotifications, } = agentsSlice.actions; export default agentsSlice.reducer;