Skip to content

Commit cbd1dc3

Browse files
committed
fix: complete approval loop (G1), wire middleware options (G2), mcp chip CSS (G3)
G1 Approval dialog: new ApprovalDialog.tsx renders pending tool-call approvals with approve/deny buttons. ChatHeader badge is now a clickable button that opens the dialog. Chat.tsx polls approvalList and renders the dialog. Completes the human-in-the-loop trust loop that was previously a dead end (badge with no action). G2 Middleware options: hermes.ts now passes memoryRecallFn (from readMemory) into createBeforeModelChain so the memory-inject middleware actually receives config. G3 MCP chip CSS: added .mcp-chip / .mcp-category-chips styles so the category filter chips render correctly. i18n: chat.approval.* keys added to all 8 locales. Tests: new approval-loop.test.ts round-trip regression test (IPC + SSE + UI + middleware wiring). Fixed 5 hermes test mocks for the new memory import, updated chat-middleware chain-length assertion (2 to 5), updated Mcp test for one-click Install. All 1412 tests pass.
1 parent def1206 commit cbd1dc3

22 files changed

Lines changed: 603 additions & 80 deletions

agent-desktop/src/main/hermes.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
hermesCliArgs,
2020
getEnhancedPath,
2121
} from "./installer";
22+
import { readMemory } from "./memory";
2223
import {
2324
compressForChat,
2425
isOllamaLikeProvider,
@@ -1157,7 +1158,23 @@ function sendMessageViaApi(
11571158
if (!_harnessRegistry) {
11581159
_harnessRegistry = createHarnessRegistry();
11591160
}
1160-
const chain = createBeforeModelChain(_harnessRegistry);
1161+
// G2 — wire the middleware options so the P3/P4/P5 modules are
1162+
// actually configured. memoryRecallFn pulls the profile's memory
1163+
// entries (synchronous read) so the memory-inject middleware can
1164+
// prepend them to the system prompt. Degrades to [] on any error.
1165+
const memoryRecallFn = (): Array<{ content: string; label: string }> => {
1166+
try {
1167+
return readMemory().memory.entries.map((e) => ({
1168+
content: e.content,
1169+
label: "memory",
1170+
}));
1171+
} catch {
1172+
return [];
1173+
}
1174+
};
1175+
const chain = createBeforeModelChain(_harnessRegistry, {
1176+
memoryRecallFn,
1177+
});
11611178
const middlewareMessages: MiddlewareChatMessage[] = messages.map(
11621179
(m) => ({
11631180
role: m.role as MiddlewareChatMessage["role"],

agent-desktop/src/renderer/src/assets/main.css

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3973,7 +3973,7 @@ select:focus-visible {
39733973
gap: 10px;
39743974
}
39753975

3976-
/* Approval inbox badge (P8) */
3976+
/* Approval inbox badge (P8) — clickable, opens the approval dialog */
39773977
.chat-approval-badge {
39783978
display: flex;
39793979
align-items: center;
@@ -3986,6 +3986,13 @@ select:focus-visible {
39863986
padding: 2px 8px;
39873987
border-radius: 10px;
39883988
animation: fadeIn 0.2s ease;
3989+
cursor: pointer;
3990+
font-family: inherit;
3991+
transition: filter var(--transition);
3992+
}
3993+
3994+
.chat-approval-badge:hover {
3995+
filter: brightness(1.1);
39893996
}
39903997

39913998
.chat-approval-badge-dot {
@@ -3996,6 +4003,76 @@ select:focus-visible {
39964003
flex-shrink: 0;
39974004
}
39984005

4006+
/* Approval dialog (P8 approval loop — G1) */
4007+
.approval-dialog {
4008+
max-width: 520px;
4009+
}
4010+
4011+
.approval-dialog-hint {
4012+
font-size: 13px;
4013+
color: var(--text-secondary);
4014+
margin: 0 0 12px 0;
4015+
}
4016+
4017+
.approval-dialog-empty {
4018+
font-size: 13px;
4019+
color: var(--text-muted);
4020+
margin: 0;
4021+
}
4022+
4023+
.approval-dialog-list {
4024+
list-style: none;
4025+
margin: 0;
4026+
padding: 0;
4027+
display: flex;
4028+
flex-direction: column;
4029+
gap: 10px;
4030+
}
4031+
4032+
.approval-dialog-item {
4033+
border: 1px solid var(--border);
4034+
border-radius: var(--radius-md);
4035+
padding: 12px 14px;
4036+
display: flex;
4037+
flex-direction: column;
4038+
gap: 8px;
4039+
background: var(--bg-secondary);
4040+
}
4041+
4042+
.approval-dialog-item-head {
4043+
display: flex;
4044+
align-items: center;
4045+
gap: 8px;
4046+
flex-wrap: wrap;
4047+
}
4048+
4049+
.approval-dialog-tool {
4050+
font-size: 13px;
4051+
font-weight: 600;
4052+
color: var(--text-primary);
4053+
}
4054+
4055+
.approval-dialog-reason {
4056+
font-size: 12px;
4057+
color: var(--text-muted);
4058+
}
4059+
4060+
.approval-dialog-command {
4061+
font-family: var(--font-mono);
4062+
font-size: 12px;
4063+
background: var(--bg-tertiary);
4064+
border-radius: var(--radius-sm);
4065+
padding: 6px 10px;
4066+
color: var(--text-secondary);
4067+
word-break: break-all;
4068+
white-space: pre-wrap;
4069+
}
4070+
4071+
.approval-dialog-actions {
4072+
display: flex;
4073+
gap: 8px;
4074+
}
4075+
39994076
.chat-token-counter {
40004077
font-size: 11px;
40014078
color: var(--text-muted);
@@ -8424,6 +8501,43 @@ select:focus-visible {
84248501
flex: 1;
84258502
}
84268503

8504+
/* MCP category filter chips (Item 4 / G3) */
8505+
.mcp-category-chips {
8506+
display: flex;
8507+
flex-wrap: wrap;
8508+
gap: 6px;
8509+
}
8510+
8511+
.mcp-chip {
8512+
appearance: none;
8513+
border: 1px solid var(--border);
8514+
background: var(--bg-secondary);
8515+
color: var(--text-secondary);
8516+
font-size: 12px;
8517+
font-weight: 500;
8518+
padding: 3px 10px;
8519+
border-radius: 999px;
8520+
cursor: pointer;
8521+
transition: background var(--transition), color var(--transition),
8522+
border-color var(--transition);
8523+
}
8524+
8525+
.mcp-chip:hover {
8526+
background: var(--bg-tertiary);
8527+
color: var(--text-primary);
8528+
}
8529+
8530+
.mcp-chip-active {
8531+
background: var(--accent);
8532+
border-color: var(--accent);
8533+
color: #fff;
8534+
}
8535+
8536+
.mcp-chip-active:hover {
8537+
background: var(--accent);
8538+
color: #fff;
8539+
}
8540+
84278541
.mcp-search-results {
84288542
display: flex;
84298543
flex-direction: column;
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// ApprovalDialog.tsx — human-in-the-loop approval UI (P8).
2+
//
3+
// Shows the pending tool-call approval entries from the ApprovalInbox.
4+
// The user can approve or deny each entry. Follows the existing
5+
// models-modal-overlay / models-modal pattern used by OAuthLoginModal.
6+
7+
import { useState } from "react";
8+
import { X } from "../../assets/icons";
9+
import { useI18n } from "../../components/useI18n";
10+
11+
export interface ApprovalDialogEntry {
12+
id: string;
13+
sessionId: string;
14+
toolName: string;
15+
command: string;
16+
reason: string;
17+
status: string;
18+
createdAt: number;
19+
resolvedAt: number | null;
20+
timeoutMs?: number;
21+
}
22+
23+
interface ApprovalDialogProps {
24+
entries: ApprovalDialogEntry[];
25+
onClose: () => void;
26+
/** Called after an approve/deny resolves so the parent can refresh. */
27+
onResolved: () => void;
28+
}
29+
30+
export function ApprovalDialog({
31+
entries,
32+
onClose,
33+
onResolved,
34+
}: ApprovalDialogProps): React.JSX.Element {
35+
const { t } = useI18n();
36+
const [busyId, setBusyId] = useState<string | null>(null);
37+
38+
const pending = entries.filter((e) => e.status === "pending");
39+
40+
async function handleDecision(id: string, approve: boolean): Promise<void> {
41+
setBusyId(id);
42+
try {
43+
if (approve) {
44+
await window.hermesAPI.approvalApprove(id);
45+
} else {
46+
await window.hermesAPI.approvalDeny(id);
47+
}
48+
onResolved();
49+
} catch {
50+
// IPC failure — leave the entry as-is; the user can retry.
51+
} finally {
52+
setBusyId(null);
53+
}
54+
}
55+
56+
return (
57+
<div className="models-modal-overlay" onClick={onClose}>
58+
<div className="models-modal approval-dialog" onClick={(e) => e.stopPropagation()}>
59+
<div className="models-modal-header">
60+
<h2 className="models-modal-title">{t("chat.approval.title")}</h2>
61+
<button
62+
className="btn-ghost"
63+
onClick={onClose}
64+
aria-label={t("common.close")}
65+
>
66+
<X size={18} />
67+
</button>
68+
</div>
69+
<div className="models-modal-body">
70+
<p className="approval-dialog-hint">{t("chat.approval.hint")}</p>
71+
{pending.length === 0 ? (
72+
<p className="approval-dialog-empty">{t("chat.approval.empty")}</p>
73+
) : (
74+
<ul className="approval-dialog-list">
75+
{pending.map((entry) => (
76+
<li key={entry.id} className="approval-dialog-item">
77+
<div className="approval-dialog-item-head">
78+
<span className="approval-dialog-tool">{entry.toolName}</span>
79+
<span className="approval-dialog-reason">{entry.reason}</span>
80+
</div>
81+
{entry.command && (
82+
<code className="approval-dialog-command">{entry.command}</code>
83+
)}
84+
<div className="approval-dialog-actions">
85+
<button
86+
type="button"
87+
className="btn btn-primary btn-sm"
88+
disabled={busyId === entry.id}
89+
onClick={() => void handleDecision(entry.id, true)}
90+
>
91+
{busyId === entry.id
92+
? t("chat.approval.working")
93+
: t("chat.approval.approve")}
94+
</button>
95+
<button
96+
type="button"
97+
className="btn btn-secondary btn-sm"
98+
disabled={busyId === entry.id}
99+
onClick={() => void handleDecision(entry.id, false)}
100+
>
101+
{t("chat.approval.deny")}
102+
</button>
103+
</div>
104+
</li>
105+
))}
106+
</ul>
107+
)}
108+
</div>
109+
</div>
110+
</div>
111+
);
112+
}

agent-desktop/src/renderer/src/screens/Chat/Chat.tsx

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useCallback, useEffect, useRef, useState } from "react";
22
import { ChatInput, type ChatInputHandle } from "./ChatInput";
33
import { ChatHeader } from "./ChatHeader";
4+
import { ApprovalDialog } from "./ApprovalDialog";
45
import { ChatEmptyState } from "./ChatEmptyState";
56
import { MessageList } from "./MessageList";
67
import { ModelPicker } from "./ModelPicker";
@@ -60,6 +61,21 @@ function Chat({
6061
const [worktreeVisible, setWorktreeVisible] = useState<boolean>(true);
6162
// Pending approval count for the chat header badge (P8)
6263
const [pendingApprovals, setPendingApprovals] = useState(0);
64+
// Pending approval entries + dialog visibility (P8 approval loop)
65+
const [approvalEntries, setApprovalEntries] = useState<
66+
Array<{
67+
id: string;
68+
sessionId: string;
69+
toolName: string;
70+
command: string;
71+
reason: string;
72+
status: string;
73+
createdAt: number;
74+
resolvedAt: number | null;
75+
timeoutMs?: number;
76+
}>
77+
>([]);
78+
const [showApprovalDialog, setShowApprovalDialog] = useState(false);
6379
const dragCounter = useRef(0);
6480
const chatInputRef = useRef<ChatInputHandle>(null);
6581
const queueRef = useRef<QueuedMessage[]>([]);
@@ -76,13 +92,17 @@ function Chat({
7692
};
7793
}, []);
7894

79-
// Poll for pending approvals (P8) — every 2s while chat is active
95+
// Poll for pending approvals (P8) — every 2s while chat is active.
96+
// Fetches the full entry list so the approval dialog can render them.
8097
useEffect(() => {
8198
let cancelled = false;
8299
const poll = async (): Promise<void> => {
83100
try {
84-
const hasPending = await window.hermesAPI.approvalHasPending();
85-
if (!cancelled) setPendingApprovals(hasPending ? 1 : 0);
101+
const entries = await window.hermesAPI.approvalList(false);
102+
if (!cancelled) {
103+
setApprovalEntries(entries);
104+
setPendingApprovals(entries.length);
105+
}
86106
} catch {
87107
// IPC not available — silently skip
88108
}
@@ -95,6 +115,18 @@ function Chat({
95115
};
96116
}, []);
97117

118+
// Re-fetch approval entries after a decision resolves.
119+
const refreshApprovals = useCallback(async (): Promise<void> => {
120+
try {
121+
const entries = await window.hermesAPI.approvalList(false);
122+
setApprovalEntries(entries);
123+
setPendingApprovals(entries.length);
124+
if (entries.length === 0) setShowApprovalDialog(false);
125+
} catch {
126+
// IPC not available — silently skip
127+
}
128+
}, []);
129+
98130
const { containerRef, bottomRef } = useChatScroll(messages);
99131
const modelConfig = useModelConfig(profile);
100132
const {
@@ -344,6 +376,7 @@ function Chat({
344376
contextFolder={contextFolder}
345377
showContextFolder={!remoteMode}
346378
pendingApprovals={pendingApprovals}
379+
onOpenApprovals={() => setShowApprovalDialog(true)}
347380
worktreeVisible={worktreeVisible}
348381
onPickFolder={handlePickFolder}
349382
onClearFolder={handleClearFolder}
@@ -407,6 +440,13 @@ function Chat({
407440
</div>
408441
</div>
409442
)}
443+
{showApprovalDialog && (
444+
<ApprovalDialog
445+
entries={approvalEntries}
446+
onClose={() => setShowApprovalDialog(false)}
447+
onResolved={() => void refreshApprovals()}
448+
/>
449+
)}
410450
</div>
411451
);
412452
}

0 commit comments

Comments
 (0)