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'}
+
+ }
+ onClick={(e) => { e.stopPropagation(); onApproveAll(); }}
+ sx={{
+ minWidth: 0,
+ minHeight: 22,
+ px: 1,
+ py: 0,
+ fontSize: '0.62rem',
+ fontWeight: 700,
+ textTransform: 'none',
+ borderRadius: 50,
+ color: '#fff',
+ bgcolor: c.status.success,
+ '&:hover': { bgcolor: c.status.success, filter: 'brightness(0.85)' },
+ '& .MuiButton-startIcon': { mr: 0.25 },
+ }}
+ >
+ Approve
+
+
+
+ { 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;