Files
WorthPath/apps/web/src/AgentConnections.tsx
T

607 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
import { api } from './api';
import { AgentDrafts } from './AgentDrafts';
import { toolLabel, statusLabel, permissionLabel } from './agent-display';
import { showToast, useToast } from './Toast';
type Connection = {
id: string;
name: string;
scopes: string[];
expiresAt: string | null;
refreshExpiresAt: string | null;
revokedAt: string | null;
clientId: string | null;
};
type Operation = { id: string; tool: string; status: string; expiresAt: string; createdAt: string };
type Management = {
capabilities: { name: string; description: string; scope: string; destructive?: boolean }[];
mcpUrl: string;
grants: Connection[];
operations: Operation[];
calls: { id: string; tool: string; status: string; createdAt: string }[];
};
const scopeDetails = [
{
id: 'read',
title: '只读查询',
summary: '查看账目和状态,不修改金额',
detail:
'查询账户、资产、债务、关联、余额和历史、净资产趋势、日历、计划与执行记录、汇率、贵金属报价、设置和图标;读取本连接操作及文件状态。隐藏账户默认不允许读取,须额外授权。',
},
{
id: 'draft',
title: '创建草稿',
summary: '提出普通修改,等待你在网页确认',
detail:
'可提出创建或编辑持仓、余额/估值更新、转账和借贷还款、历史更正/删除、计划管理、分组排序、设置修改及私有图标上传等普通操作。草稿明确保存参数和影响,10 分钟过期;不会直接入账。',
},
{
id: 'write',
title: '普通写入',
summary: '普通修改直接执行',
detail:
'覆盖普通修改和创建草稿的操作。授予 write 后普通修改直接执行;draft 等待网页确认;read 拒绝修改。归档、历史删除和转账撤销也属于普通写入,请按所需范围授权。',
},
];
const durationOptions = [
['1', '1 天'],
['3', '3 天'],
['7', '7 天'],
['30', '30 天'],
['365', '1 年(365 天)'],
['permanent', '永久(可撤销)'],
];
function instructions(url: string) {
return [
'以下是配置 MCP 连接后的操作说明,不会自动添加连接或授予工具。若本会话没有 WorthPath 工具,请先告知我,并指导我在客户端配置连接;不要仅凭网址或聊天中的令牌声称已连接。',
'请使用已连接的 WorthPath MCP 服务处理我的资产与负债,服务地址:' + url,
'接入使用 Streamable HTTP。支持 OAuth 的客户端使用该地址发现授权服务,并由我在 WorthPath 网页登录授权;个人令牌仅用于明确支持 Bearer 认证的客户端,按其文档配置环境变量或凭据存储。不要假设客户端有自定义请求头界面,不要让我把密码或令牌粘贴到对话中。localhost 仅适用于 MCP 请求在 WorthPath 所在电脑执行的客户端;云端客户端需要可访问的 HTTPS 地址。',
'先 tools/list 发现工具,调用 connection_info 核对权限:read 只读,draft 创建待网页确认的草稿,write 直接普通写入。隐藏账户读写以连接附加授权为准。',
'查询:用 positions_list 搜索对象;需要选定对象且遇到同名时先让我选择稳定 UUID;按 limit/offset 或 cursor 完整翻页。金额和克数使用十进制字符串,业务时间是 UTC+8 的 YYYY-MM-DD 或 YYYY-MM-DDTHH:mm。不同币种不能直接相加;明确折算币种、汇率日期、统计纳入范围及缺失汇率或估值。不擅自排除测试项目或其他项目;若提供剔除后的参考汇总,同时说明系统实际汇总与剔除项,不修改纳入设置。',
'写入:先 state_get 取得 state,使用 expectedState 与唯一 idempotencyKey 调用所需工具。网络重试保持键和全部参数一致;状态冲突则重新查询并换键。不要把估值更新、转账、还款或负债变化互相替代。',
'若返回 pending,把 confirmationUrl 给我,由我在网页审阅并确认;用 operation_get 轮询最终结果,不能用 confirmed=true 代替人类确认,也不能把草稿称为已完成。',
'MCP 不提供密码修改、清空数据、备份导出恢复、共享图标发布及汇率或报价修改;这些流程由我在网站操作。',
'我接下来会告诉你具体任务;在我提出任务前先不要修改数据。未发现的工具或未授予的权限请如实告知,不要猜测成功。',
].join('\n\n');
}
function codexSetupPrompt(url: string, level: string, hiddenRead = false, hiddenWrite = false) {
const requestedHiddenWrite = hiddenRead && hiddenWrite && level !== 'read';
const selected = [
'read',
...(level !== 'read' ? [level] : []),
...(hiddenRead ? ['hidden_read'] : []),
...(requestedHiddenWrite ? ['hidden_write'] : []),
].join(',');
return [
'请直接在这台电脑上配置并验证 WorthPath MCP,让其他本机 Codex 新对话也能使用,并在我提出余额、账户、资产、负债、净资产、历史、转账、借贷、还款等相关任务时主动优先调用它。不要只给我命令或方案。我授权必要的本机检查、新增或更新名为 worthpath 的 Streamable HTTP 与 OAuth 连接、发起 OAuth,以及保存下面的跨对话使用规则。仅修改 worthpath 连接及全局指令文件中的 WorthPath 规则,保留其他 MCP、Codex 配置和已有指令。配置和验证期间不得创建、修改或删除账目。',
'服务地址:' +
url +
'。申请权限:' +
selected +
'。隐藏账户读取:' +
(hiddenRead ? '允许' : '不允许') +
';隐藏账户修改:' +
(requestedHiddenWrite ? '允许' : '不允许') +
'。最终权限和授权期限由我在 WorthPath 网页审核完成。不要让我把密码或个人令牌发送到聊天里,也不要替我点击授权确认。',
'先确认执行环境确实在这台电脑上,再检查 codex 命令、实际版本及 codex mcp add --help、codex mcp login --help。只检查 WorthPath 相关连接配置,输出经过筛选的名称、地址、启用状态和认证类型,不输出凭据、Authorization 头、令牌或其他连接详情。如果运行在云端或另一台电脑,无法访问上述服务地址,请如实说明,不要声称已连接。',
'Windows 命令执行排查也请由你完成,不要一遇到 PowerShell 挂起就让我自行确认 CLI。版本和帮助检查是短命令:执行工具支持时先设置 login:false(或等效 -NoProfile),避免启动配置文件。若短命令超过 15 秒无输出,结束你自己启动的这一条检查,不要停止 WorthPath 服务或其他进程;不要反复原样重试。',
'若普通沙箱检查挂起或权限不足,使用执行工具提供的正常权限申请流程,申请 sandbox_permissions=require_escalated 或等效本机执行权限,说明用途是核对 Codex CLI、配置 WorthPath OAuth及保存跨对话使用规则。我授权这些必要操作。获得工具批准后继续执行;若被拒绝,报告具体拒绝原因和受影响步骤。不要绕过权限限制,也不要仅因普通沙箱命令挂起就认定 CLI 不可用。',
'CLI 找不到时,在本机已安装 Codex 的官方目录中定位可执行文件并使用完整路径;不要安装未知程序、永久修改 PATH 或读取其他凭据。区分短命令故障和 OAuth 等待:授权登录进程在等待网页回调时应保留,不适用上述 15 秒短命令期限。',
'使用 codex mcp add worthpath --url ' +
url +
' 添加连接;已有同名、同地址且启用的 OAuth 连接时跳过添加。同地址连接被禁用时仅启用它;同名连接指向不同服务时说明冲突,等待我决定,不覆盖。若这条连接使用旧 Bearer 认证,将本连接切换为 OAuth,移除 bearer_token_env_var 和静态或环境映射的 Authorization 头,保留其余配置,不展示旧令牌。',
'mcp add 可能自动发起默认 OAuth。避免产生两条同时等待的授权流程;如需切换到下方显式登录,可以结束自己启动的自动登录等待,再启动指定 scopes 的登录。不要把默认候选权限较宽当作登录故障。',
'执行 codex mcp login worthpath --scopes ' +
selected +
' --oauth-client-registration dcr 发起网页登录。我会自行登录并确认权限。等待回调时保留登录进程;需要等待我完成网页步骤时,明确告诉我当前状态。',
'若授权链接包含 read,draft,write,hidden_read,hidden_write 等较宽范围,这是客户端候选范围,不代表最终授权。WorthPath 网页支持收窄,服务端按网页选择发行令牌。打开正确 WorthPath 资源的授权页供我审阅;由我选择本次指定的权限等级、隐藏账户读写选项和授权期限并确认。不要仅因候选范围较宽就终止登录;最终以 connection_info 返回的实际权限为准。',
'分别验证连接配置正确且启用、OAuth 登录成功、真实 MCP 工具调用成功,三者不能互相替代。先通过 tools/list 或客户端暴露的工具目录发现真实工具,再调用 connection_info。resource 必须为 ' +
url +
',permission 必须为 ' +
level +
',最终 scopes 集合必须恰好为 ' +
selected +
'(顺序不限),且 readHidden=' +
hiddenRead +
'、writeHidden=' +
requestedHiddenWrite +
'。若结果不符,停止使用该连接并报告差异,不修改账目。若当前会话不会重新加载工具,请告诉我重启客户端或新建本机会话后继续验证。只有配置、OAuth 成功和工具调用成功都验证后才能说已可用,不把 codex mcp list 或网页登录成功当作工具调用成功。',
'保存跨对话使用规则:在实际 CODEX_HOME(默认 ~/.codex)的全局指令中,先检查 AGENTS.override.md 与 AGENTS.md 的加载优先级,在会被加载的文件中新增或更新 <!-- worthpath-mcp-preference:start --> 与 <!-- worthpath-mcp-preference:end --> 标记块。保留已有内容,只更新此块;不要创建会遮蔽其他全局指令的 override 文件,不重复添加规则。',
'全局规则应保存本次服务地址、申请的权限等级、scopes 和隐藏账户读写选项,以及下面的查询和写入操作规则。每个新会话先发现真实工具,再通过 connection_info 核对资源及本次授权,若不符停止使用并报告差异。用户提出个人余额、账户、资产、负债、净资产、历史、转账、借贷、还款或其他 WorthPath 任务时主动优先调用 worthpath MCP,不要求重复指定,不把旧聊天余额当作当前数据。这项偏好仅决定工具选择;只有用户提出具体写入任务后才执行,不自动授权账目修改。',
'工具未加载、服务不可达或授权失效时如实说明,必要时提示重新授权、重启客户端或新建本机会话,不擅自改用 UI/API 写入。不记录密码、令牌、账户余额或其他财务明细到全局指令中。保存后验证文件内容与加载条件,说明规则适用于这台电脑使用同一 Codex 配置的本机会话,不声称自动适用于其他电脑或云端会话。',
...instructions(url).split('\n\n').slice(3),
'完成后简要报告连接配置、OAuth、真实工具验证和全局规则保存的实际结果及剩余步骤。在我提出具体账目任务前,不修改任何数据。',
].join('\n\n');
}
const connectionExpiry = (g: Connection) => (g.clientId ? g.refreshExpiresAt : g.expiresAt);
export function AgentConnections({ changed }: { changed?: () => void }) {
const [review, setReview] = useState<string | null>(null);
const [data, setData] = useState<Management | null>(null),
[view, setView] = useState('connect'),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[permission, setPermission] = useState('draft'),
[codexHiddenRead, setCodexHiddenRead] = useState(false),
[codexHiddenWrite, setCodexHiddenWrite] = useState(false),
[token, setToken] = useState(''),
[tokenPermission, setTokenPermission] = useState('read'),
[hiddenRead, setHiddenRead] = useState(false),
[hiddenWrite, setHiddenWrite] = useState(false),
[search, setSearch] = useState('');
useToast(error, 'error');
const load = async () => setData(await api<Management>('/agent'));
async function act(work: () => Promise<unknown>) {
if (busy) return;
setBusy(true);
setError('');
try {
await work();
await load();
} catch (e) {
setError(e instanceof Error ? e.message : '操作失败');
} finally {
setBusy(false);
}
}
useEffect(() => {
void act(load);
}, []);
async function copy(text: string, label: string) {
try {
await navigator.clipboard.writeText(text);
showToast(label, 'success');
} catch {
showToast('复制失败,请展开文本后手动复制', 'error');
}
}
const pending =
data?.operations.filter((o) => o.status === 'pending' && new Date(o.expiresAt) > new Date()) ||
[];
const active =
data?.grants.filter(
(g) => !g.revokedAt && (!connectionExpiry(g) || new Date(connectionExpiry(g)!) > new Date()),
) || [];
return (
<section className="panel agent-panel">
<div className="agent-heading">
<div>
<p className="eyebrow">AGENT CONNECTIONS</p>
<h2>让助手连接你的资产空间</h2>
<p className="muted">选择权限、连接助手,随时查看和管理它提出的修改。</p>
</div>
<button className="secondary" disabled={busy} onClick={() => void act(load)}>
刷新
</button>
</div>
<nav className="agent-tabs" aria-label="Agent 功能分区">
{[
['connect', '连接助手'],
['connections', '我的连接'],
['operations', '草稿与记录'],
['tools', '能力与权限'],
].map(([key, label]) => (
<button
key={key}
className={view === key ? 'primary' : 'secondary'}
aria-pressed={view === key}
onClick={() => setView(key)}
>
{label}
{key === 'connections'
? ' · ' + active.length
: key === 'operations' && pending.length
? ' · ' + pending.length
: ''}
</button>
))}
</nav>
{!data && <p>{error ? '连接信息加载失败,请点击刷新重试。' : '正在读取连接信息…'}</p>}
{data && view === 'connect' && (
<div className="agent-connect-layout">
<section className="agent-card agent-setup">
<span className="agent-step-number">1</span>
<h3>选择助手能做什么</h3>
<div className="agent-permission-choices">
{[
['read', '只读查询', '只查看账户、余额和统计。'],
['draft', '草稿修改', '每次修改先由你确认,建议从这里开始。'],
['write', '直接写入', '普通修改立即执行,适合你信任的助手。'],
].map(([key, title, detail]) => (
<button
key={key}
className={
permission === key ? 'permission-choice selected' : 'permission-choice'
}
aria-pressed={permission === key}
onClick={() => {
setPermission(key);
if (key === 'read') setCodexHiddenWrite(false);
}}
>
<strong>{title}</strong>
<span>{detail}</span>
</button>
))}
</div>
<fieldset className="agent-scope-options">
<legend>申请隐藏账户权限(默认关闭)</legend>
<label className="check-line">
<input
type="checkbox"
checked={codexHiddenRead}
onChange={(e) => {
setCodexHiddenRead(e.target.checked);
if (!e.target.checked) setCodexHiddenWrite(false);
}}
/>
允许读取隐藏账户
</label>
<label className="check-line">
<input
type="checkbox"
checked={codexHiddenWrite}
disabled={!codexHiddenRead || permission === 'read'}
onChange={(e) => setCodexHiddenWrite(e.target.checked)}
/>
允许修改隐藏账户
</label>
</fieldset>
<span className="agent-step-number">2</span>
<h3>把配置提示词发送给 Codex</h3>
<p>本机 Codex 会配置连接并打开独立授权页。你只需登录并核对权限。</p>
<button
className="primary"
onClick={() =>
void copy(
codexSetupPrompt(data.mcpUrl, permission, codexHiddenRead, codexHiddenWrite),
'Codex 配置提示词已复制',
)
}
>
复制 Codex 配置提示词
</button>
<details className="agent-details">
<summary>预览配置提示词</summary>
<textarea
className="agent-tutorial"
readOnly
rows={6}
aria-label="Codex 配置提示词"
value={codexSetupPrompt(data.mcpUrl, permission, codexHiddenRead, codexHiddenWrite)}
/>
</details>
<span className="agent-step-number">3</span>
<h3>确认连接后,开始交代任务</h3>
<p>先让助手核对连接权限。草稿会出现在“草稿与记录”,确认后才会写入。</p>
<button
className="secondary"
onClick={() => void copy(instructions(data.mcpUrl), 'Agent 使用教程已复制')}
>
复制后续操作说明
</button>
<details className="agent-details">
<summary>预览操作说明</summary>
<textarea
className="agent-tutorial"
readOnly
rows={6}
aria-label="Agent 使用教程"
value={instructions(data.mcpUrl)}
/>
</details>
</section>
<aside className="agent-card agent-help">
<h3>其他客户端</h3>
<p className="muted">使用支持 Streamable HTTP 和 OAuth 的 MCP 客户端。</p>
<div className="agent-copy-row">
<code className="agent-address">{data.mcpUrl}</code>
<button
className="secondary"
onClick={() => void copy(data.mcpUrl, 'MCP 地址已复制')}
>
复制地址
</button>
</div>
<p>localhost 适用于本机助手;云端助手需要能访问的 HTTPS 服务地址。</p>
<details className="agent-details">
<summary>接入与有效期</summary>
<p>
OAuth 访问令牌 1 小时;授权期限可选 1、3、7、30 天、1
年或永久,刷新不会延长有限授权期限。配置连接后,客户端需要成功列出工具并调用
connection_info。
</p>
<p>需要 Bearer 令牌的客户端,可在“我的连接”中创建个人令牌。</p>
</details>
</aside>
</div>
)}
{data && view === 'connections' && (
<>
<section className="agent-card">
<h3>已授权连接</h3>
<p className="muted">最近 100 条 · 撤销后助手将无法继续访问。</p>
{!data.grants.length && (
<div className="agent-empty">
<h4>还没有连接</h4>
<p>先复制配置提示词,让助手发起连接。</p>
<button className="primary" onClick={() => setView('connect')}>
连接助手
</button>
</div>
)}
{data.grants.map((g) => (
<div className="agent-row" key={g.id}>
<div>
<strong>{g.name}</strong>
<p>
{permissionLabel(g.scopes)} · {g.clientId ? 'OAuth' : '个人令牌'} ·{' '}
{g.revokedAt
? '已撤销'
: connectionExpiry(g) && new Date(connectionExpiry(g)!) <= new Date()
? '已过期'
: '有效'}
</p>
<small>
{g.scopes.includes('hidden_read')
? g.scopes.includes('hidden_write')
? '可读取和修改隐藏账户'
: '可读取隐藏账户'
: '不可访问隐藏账户'}{' '}
·{' '}
{connectionExpiry(g)
? '授权到期 ' + new Date(connectionExpiry(g)!).toLocaleString()
: '永久有效'}
</small>
</div>
{!g.revokedAt && (
<button
className="secondary"
disabled={busy}
onClick={() =>
void act(async () => {
await api('/agent/connections/' + g.id, 'DELETE');
showToast('连接已撤销', 'success');
})
}
>
撤销连接
</button>
)}
</div>
))}
</section>
<details className="agent-card agent-details">
<summary>高级接入:创建个人令牌</summary>
<p className="muted">仅供支持 Bearer 认证的客户端使用。完整令牌只显示一次。</p>
<form
className="agent-form"
onSubmit={(e) => {
e.preventDefault();
const form = e.currentTarget,
f = new FormData(form);
void act(async () => {
const v = await api<{ token: string }>('/agent/tokens', 'POST', {
name: f.get('name'),
password: f.get('password'),
days: f.get('days') === 'permanent' ? null : Number(f.get('days')),
scopes: [
'read',
...(tokenPermission !== 'read' ? [tokenPermission] : []),
...(hiddenRead ? ['hidden_read'] : []),
...(hiddenWrite ? ['hidden_write'] : []),
],
});
setToken(v.token);
form.reset();
setTokenPermission('read');
setHiddenRead(false);
setHiddenWrite(false);
});
}}
>
<label>
连接名称
<input name="name" required maxLength={100} placeholder="例如:桌面助手" />
</label>
<label>
令牌有效期
<select name="days" defaultValue="30">
{durationOptions.map(([v, label]) => (
<option key={v} value={v}>
{label}
</option>
))}
</select>
</label>
<label>
连接权限
<select
value={tokenPermission}
onChange={(e) => {
setTokenPermission(e.target.value);
if (e.target.value === 'read') setHiddenWrite(false);
}}
>
<option value="read">只读查询</option>
<option value="draft">草稿修改</option>
<option value="write">直接写入</option>
</select>
</label>
<fieldset className="agent-scope-options">
<legend>隐藏账户权限(默认关闭)</legend>
<label className="check-line">
<input
type="checkbox"
checked={hiddenRead}
onChange={(e) => {
setHiddenRead(e.target.checked);
if (!e.target.checked) setHiddenWrite(false);
}}
/>
允许读取隐藏账户
</label>
<label className="check-line">
<input
type="checkbox"
checked={hiddenWrite}
disabled={!hiddenRead || tokenPermission === 'read'}
onChange={(e) => setHiddenWrite(e.target.checked)}
/>
允许修改隐藏账户
</label>
</fieldset>
<label>
验证当前密码
<input name="password" type="password" required autoComplete="current-password" />
</label>
<button className="primary" disabled={busy}>
创建个人令牌
</button>
</form>
{token && (
<div className="agent-secret" role="status">
<p>请将令牌保存到客户端凭据设置。</p>
<code className="agent-address">{token}</code>
<div className="actions">
<button className="secondary" onClick={() => void copy(token, '令牌已复制')}>
复制令牌
</button>
<button className="secondary" onClick={() => setToken('')}>
隐藏令牌
</button>
</div>
</div>
)}
</details>
</>
)}
{data && view === 'operations' && (
<section className="agent-card">
<h3>待确认草稿</h3>
<button className="primary" disabled={busy} onClick={() => setReview('all')}>
审阅全部草稿
</button>
{!pending.length && <p className="agent-empty">没有等待确认的修改。</p>}
{pending.map((o) => (
<div className="agent-row" key={o.id}>
<div>
<strong>{toolLabel(o.tool)}</strong>
<p className="muted">有效至 {new Date(o.expiresAt).toLocaleTimeString()}</p>
</div>
<button className="agent-review-link" onClick={() => setReview(o.id)}>
审阅草稿
</button>
</div>
))}
<details className="agent-details">
<summary>最近操作({data.operations.length})</summary>
{!data.operations.length && <p>暂无操作记录</p>}
{data.operations.map((o) => (
<div className="agent-row" key={o.id}>
<div>
<strong>{toolLabel(o.tool)}</strong>
<p>
{statusLabel(
o.status === 'pending' && new Date(o.expiresAt) < new Date()
? 'expired'
: o.status,
)}{' '}
· {new Date(o.createdAt).toLocaleString()}
</p>
</div>
<button className="text" onClick={() => setReview(o.id)}>
查看详情
</button>
</div>
))}
</details>
<details className="agent-details">
<summary>调用日志</summary>
<p className="muted">仅保存工具名称和状态,不保存参数、密码或令牌。</p>
{!data.calls.length && <p>暂无调用记录</p>}
{data.calls.map((c) => (
<div className="agent-row" key={c.id}>
<span>
{toolLabel(c.tool)} <small>({c.tool})</small> · {statusLabel(c.status)}
</span>
<small>{new Date(c.createdAt).toLocaleString()}</small>
</div>
))}
</details>
</section>
)}
{data && view === 'tools' && (
<>
<div className="agent-scope-grid">
{scopeDetails.map((s) => (
<article className="agent-card" key={s.id}>
<h3>{s.title}</h3>
<p>{s.summary}</p>
<details className="agent-details">
<summary>详细范围</summary>
<p>{s.detail}</p>
</details>
</article>
))}
</div>
<section className="agent-card">
<h3>助手的能力</h3>
<p className="muted">
查询与修改受连接权限约束;账户密码、清空数据、备份恢复等仍由你在网站操作。
</p>
<label>
搜索能力
<input
type="search"
placeholder="例如:转账、余额、计划"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</label>
<div className="agent-tool-grid">
{data.capabilities
.filter((t) =>
(toolLabel(t.name) + ' ' + t.name + ' ' + t.description)
.toLowerCase()
.includes(search.toLowerCase()),
)
.map((t) => (
<article className="agent-tool-card" key={t.name}>
<h4>{toolLabel(t.name)}</h4>
<span className="badge">
{t.scope === 'read' ? '查询' : '草稿 / 写入'}
{t.destructive ? ' · 删除操作' : ''}
</span>
<details className="agent-details">
<summary>查看说明</summary>
<p>{t.description}</p>
<code>{t.name}</code>
</details>
</article>
))}
</div>
{!data.capabilities.some((t) =>
(toolLabel(t.name) + ' ' + t.name + ' ' + t.description)
.toLowerCase()
.includes(search.toLowerCase()),
) && <p>没有匹配的能力。</p>}
</section>
</>
)}
{review !== null && (
<AgentDrafts
initialId={review === 'all' ? undefined : review}
close={() => setReview(null)}
changed={() => {
void load();
changed?.();
}}
/>
)}
</section>
);
}