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'; } from '@renderer/constants/cssVariables';
import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors'; import { getTeamColorSet, getThemedBorder } from '@renderer/constants/teamColors';
import { useTheme } from '@renderer/hooks/useTheme'; import { useTheme } from '@renderer/hooks/useTheme';
import { useStore } from '@renderer/store';
import { import {
getMessageTypeLabel, getMessageTypeLabel,
getStructuredMessageSummary, getStructuredMessageSummary,
@ -47,12 +48,15 @@ import {
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
import { import {
AlertTriangle, AlertTriangle,
Check,
ChevronRight, ChevronRight,
Clock,
Command, Command,
ListPlus, ListPlus,
Maximize2, Maximize2,
RefreshCw, RefreshCw,
Reply, Reply,
X,
} from 'lucide-react'; } from 'lucide-react';
import { ReplyQuoteBlock } from './ReplyQuoteBlock'; import { ReplyQuoteBlock } from './ReplyQuoteBlock';
@ -269,10 +273,12 @@ const NoiseRow = ({
name, name,
label, label,
colors, colors,
icon,
}: { }: {
name: string; name: string;
label: string; label: string;
colors: TeamColorSet; colors: TeamColorSet;
icon?: React.ReactNode;
}): React.JSX.Element => ( }): React.JSX.Element => (
<div className="flex items-center gap-2 px-3 py-1" style={{ opacity: 0.45 }}> <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 }} /> <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 }}> <span className="text-[11px]" style={{ color: CARD_ICON_MUTED }}>
{label} {label}
</span> </span>
{icon}
</div> </div>
); );
@ -594,9 +601,36 @@ export const ActivityItem = memo(
]); ]);
const summaryText = useMemo(() => extractMarkdownPlainText(rawSummary), [rawSummary]); 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 // Noise messages: minimal inline row
if (noiseLabel) { if (noiseLabel) {
return <NoiseRow name={message.from} label={noiseLabel} colors={colors} />; return (
<NoiseRow name={message.from} label={noiseLabel} colors={colors} icon={permissionIcon} />
);
} }
const messageType = const messageType =

View file

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

View file

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