feat(team): show approval status icons on permission_request noise rows

- Add resolvedApprovals Map to store (request_id -> allowed/denied)
- Record result in respondToToolApproval and autoResolved events
- NoiseRow accepts optional icon prop
- Permission request rows show: check (approved), x (denied),
  clock (pending), or faded check (pre-existing/unknown)
This commit is contained in:
iliya 2026-03-28 15:52:12 +02:00
parent 569b3b3f5b
commit d01bce2101
3 changed files with 60 additions and 12 deletions

View file

@ -16,6 +16,7 @@ import {
} from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import {
getMessageTypeLabel,
getStructuredMessageSummary,
@ -47,12 +48,15 @@ import {
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import {
AlertTriangle,
Check,
ChevronRight,
Clock,
Command,
ListPlus,
Maximize2,
RefreshCw,
Reply,
X,
} from 'lucide-react';
import { ReplyQuoteBlock } from './ReplyQuoteBlock';
@ -269,10 +273,12 @@ const NoiseRow = ({
name,
label,
colors,
icon,
}: {
name: string;
label: string;
colors: TeamColorSet;
icon?: React.ReactNode;
}): React.JSX.Element => (
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}>
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: colors.border }} />
@ -282,6 +288,7 @@ const NoiseRow = ({
<span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{label}
</span>
{icon}
</div>
);
@ -594,9 +601,36 @@ export const ActivityItem = memo(
]);
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]);
// Permission request status icon (check/x/clock)
const pendingApprovals = useStore((s) => s.pendingApprovals);
const resolvedApprovals = useStore((s) => s.resolvedApprovals);
const permissionIcon = useMemo(() => {
if (!structured) return null;
const type = typeof structured.type === 'string' ? structured.type : null;
if (type !== 'permission_request') return null;
const requestId = typeof structured.request_id === 'string' ? structured.request_id : null;
if (!requestId) return null;
const resolved = resolvedApprovals.get(requestId);
if (resolved === true) {
return <Check size={12} className="text-emerald-400" />;
}
if (resolved === false) {
return <X size={12} className="text-red-400" />;
}
const isPending = pendingApprovals.some((a) => a.requestId === requestId);
if (isPending) {
return <Clock size={12} className="animate-pulse text-amber-400" />;
}
// Not in pending and not resolved — already handled before we started tracking
return <Check size={12} className="text-emerald-400/50" />;
}, [structured, pendingApprovals, resolvedApprovals]);
// Noise messages: minimal inline row
if (noiseLabel) {
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />;
return (
<NoiseRow name={message.from} label={noiseLabel} colors={colors} icon={permissionIcon} />
);
}
const messageType =

View file

@ -683,12 +683,18 @@ export function initializeNotificationListeners(): () => void {
const cleanup = api.teams.onToolApprovalEvent((_event: unknown, data: unknown) => {
const event = data as ToolApprovalEvent;
if ('autoResolved' in event && event.autoResolved) {
// Timeout or auto-allow resolved in main — remove from UI
useStore.setState((s) => ({
pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.runId === event.runId && a.requestId === event.requestId)
),
}));
// Timeout or auto-allow resolved in main — remove from UI and record result
const allowed = event.reason !== 'timeout_deny';
useStore.setState((s) => {
const next = new Map(s.resolvedApprovals);
next.set(event.requestId, allowed);
return {
pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.runId === event.runId && a.requestId === event.requestId)
),
resolvedApprovals: next,
};
});
} else if ('dismissed' in event && event.dismissed) {
const dismiss = event;
useStore.setState((s) => ({

View file

@ -687,6 +687,8 @@ export interface TeamSlice {
subscribeProvisioningProgress: () => void;
unsubscribeProvisioningProgress: () => void;
pendingApprovals: ToolApprovalRequest[];
/** Resolved permission approvals: request_id → allowed (true/false). Used for noise row icons. */
resolvedApprovals: Map<string, boolean>;
toolApprovalSettings: ToolApprovalSettings;
updateToolApprovalSettings: (patch: Partial<ToolApprovalSettings>) => Promise<void>;
respondToToolApproval: (
@ -896,6 +898,7 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
deletedTasks: [],
deletedTasksLoading: false,
pendingApprovals: [],
resolvedApprovals: new Map(),
toolApprovalSettings: loadToolApprovalSettings(),
// Messages panel UI state
@ -2201,11 +2204,16 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
try {
await api.teams.respondToToolApproval(teamName, runId, requestId, allow, message);
// Remove ONLY after successful IPC, by runId+requestId pair
set((s) => ({
pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.runId === runId && a.requestId === requestId)
),
}));
set((s) => {
const next = new Map(s.resolvedApprovals);
next.set(requestId, allow);
return {
pendingApprovals: s.pendingApprovals.filter(
(a) => !(a.runId === runId && a.requestId === requestId)
),
resolvedApprovals: next,
};
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.error(`respondToToolApproval failed for ${teamName}/${requestId}: ${msg}`);