[Haik]: round 2 of dynamic island redesign (collapsible completed tab) (approve/deny from collapsed state) (height limited and scrollable expanded state)

This commit is contained in:
haikdc
2026-03-20 23:41:20 -07:00
parent 37e5d7b0d9
commit 809eacc041
4 changed files with 301 additions and 36 deletions
+5
View File
@@ -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)
+8 -3
View File
@@ -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)
+241 -23
View File
@@ -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}
>
<motion.div
layout
@@ -401,6 +445,16 @@ const DynamicIsland: React.FC = () => {
hasApprovals={hasApprovals}
/>
)}
{islandState === 'compact-actionable' && (
<CompactActionablePill
key="compact-actionable"
c={c}
approvalCount={nonQuestionApprovalCount}
onApproveAll={onApproveAllNonQuestion}
onDenyAll={onDenyAllNonQuestion}
onExpand={() => setUserExpanded(true)}
/>
)}
{islandState === 'expanded' && (
<ExpandedCard
key="expanded"
@@ -416,6 +470,7 @@ const DynamicIsland: React.FC = () => {
onStopAgent={onStopAgent}
onDismissAgent={onDismissAgent}
onNavigateToDashboard={onNavigateToDashboard}
onClearAllFinished={onClearAllFinished}
onCollapse={() => setUserExpanded(false)}
/>
)}
@@ -521,6 +576,112 @@ const CompactPill: React.FC<{
</motion.div>
);
// ---------------------------------------------------------------------------
// Compact-actionable pill — inline approve/deny without expanding
// ---------------------------------------------------------------------------
const CompactActionablePill: React.FC<{
c: ReturnType<typeof useClaudeTokens>;
approvalCount: number;
onApproveAll: () => void;
onDenyAll: () => void;
onExpand: () => void;
}> = ({ c, approvalCount, onApproveAll, onDenyAll, onExpand }) => (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={SPRING_BOUNCE}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1.5,
height: 32,
userSelect: 'none',
}}
>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: c.status.warning,
flexShrink: 0,
animation: 'islandPulse 2s ease-in-out infinite',
'@keyframes islandPulse': {
'0%, 100%': { opacity: 0.8, transform: 'scale(1)' },
'50%': { opacity: 0.4, transform: 'scale(1.3)' },
},
}}
/>
<Typography
sx={{
fontSize: '0.68rem',
fontWeight: 600,
color: c.text.tertiary,
flex: 1,
whiteSpace: 'nowrap',
}}
>
{approvalCount} {approvalCount === 1 ? 'approval' : 'approvals'}
</Typography>
<Button
size="small"
startIcon={<CheckIcon sx={{ fontSize: '0.75rem !important' }} />}
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
</Button>
<Button
size="small"
variant="outlined"
onClick={(e) => { e.stopPropagation(); onDenyAll(); }}
sx={{
minWidth: 0,
minHeight: 22,
px: 1,
py: 0,
fontSize: '0.62rem',
fontWeight: 700,
textTransform: 'none',
borderRadius: 50,
color: c.status.error,
borderColor: c.status.error,
'&:hover': { borderColor: c.status.error, bgcolor: `${c.status.error}0a` },
}}
>
Deny
</Button>
<Tooltip title="Show details" arrow>
<IconButton
size="small"
onClick={(e) => { e.stopPropagation(); onExpand(); }}
sx={{ p: 0.25, color: c.text.ghost, '&:hover': { color: c.text.tertiary } }}
>
<ExpandMoreIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
</motion.div>
);
// ---------------------------------------------------------------------------
// 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<{
<Box
sx={{
overflow: 'auto',
maxHeight: 'calc(100vh - 100px)',
maxHeight: 'min(420px, calc(100vh - 100px))',
'&::-webkit-scrollbar': { width: 4 },
'&::-webkit-scrollbar-track': { background: 'transparent' },
'&::-webkit-scrollbar-thumb': {
@@ -711,16 +874,71 @@ const ExpandedCard: React.FC<{
onNavigate={onNavigateToDashboard}
/>
))}
{finishedAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
{finishedAgents.length > 0 && (
<>
{activeAgents.length > 0 && (
<Box sx={{ mx: 2, my: 0.5, borderTop: `0.5px solid ${c.border.subtle}` }} />
)}
<Box
onClick={() => 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',
}}
>
<Typography
sx={{
fontSize: '0.58rem',
fontWeight: 600,
color: c.text.ghost,
textTransform: 'uppercase',
letterSpacing: '0.06em',
flex: 1,
}}
>
Completed ({finishedAgents.length})
</Typography>
<Typography
component="span"
onClick={(e: React.MouseEvent) => { 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
</Typography>
<IconButton size="small" sx={{ p: 0, color: c.text.ghost }}>
{completedExpanded
? <ExpandLessIcon sx={{ fontSize: 14 }} />
: <ExpandMoreIcon sx={{ fontSize: 14 }} />}
</IconButton>
</Box>
<Collapse in={completedExpanded}>
{finishedAgents.map((agent) => (
<AgentStatusRow
key={agent.id}
agent={agent}
c={c}
onStop={onStopAgent}
onDismiss={onDismissAgent}
onNavigate={onNavigateToDashboard}
/>
))}
</Collapse>
</>
)}
</Box>
)}
</Box>
+47 -10
View File
@@ -318,11 +318,14 @@ export const handleApproval = createAsyncThunk(
message?: string;
updatedInput?: Record<string, any>;
}) => {
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<string, AgentSession> = {};
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;