Files
MailUI4Agents/client/electron/src/components/MailView.tsx
JianFeeeee 4a01297dfe fix(安全)★★: 主进程零导航拦截 —— 加 will-navigate / 新窗口拒绝 + 单实例锁
**主进程此前没有任何守卫**(实测 grep 0 命中:setWindowOpenHandler、
will-navigate、requestSingleInstanceLock 全无)。
在 `loadFile(dist/index.html)` 下,邮件正文里的链接(react-markdown 会渲染
`<a href>`)点下去会**在应用窗口里导航走** —— 一个 `href="https://…"` 就足以
把整个应用窗口变成浏览器,而窗口标题与 preload 注入的 API base/token
全部暴露在那个站点上。

★ **本条防的不是 XSS**:`markdown-xss` 守的是渲染层(无 rehype-raw、
  defaultUrlTransform 中和 javascript:),**目前没有 XSS 面**。
  这里防的是**导航逃逸** —— 让外部站点**借用**这个窗口与 preload 上下文。
  两者失效方式不同,所以分开钉。

**① setWindowOpenHandler** ⇒ 一律 `deny`,地址交系统浏览器。
   `allow` 会给那个站点一个**带 preload 的窗口**。
**② will-navigate** ⇒ 拦下并 preventDefault。
   ★ 但必须**放行本应用自己的加载**(prod 的 `file://` / dev 的 `DEV_URL`)
     —— 只会 preventDefault 的实现会把应用自己锁死,首屏进不去。
     这是「过严的守卫同样是缺陷」,判据专门为它加了一格。
**③ 外跳只放行 http/https**:file: / javascript: / 自定义协议交给
   `shell.openExternal` 意图不可控;`new URL()` 对畸形输入会抛,必须 catch。
**④ requestSingleInstanceLock**:双击图标此前会起**两个进程** ——
   两条 SSE 连接、两套 `accounts.json` 并发写入(那个文件是 tmp+rename 原子写,
   并发即「后写的赢」),而用户以为只有一扇窗。

**同时修的两处双提交**(形状与 ④ 同源,都是「busy/state 要到提交后才为真」):
· PermissionPanel.submit:审批是本工程**唯一带副作用且不可撤销**的动作
  ⇒ 同帧两次激活会发出**两条** decidePermission(服务端记两次账)。
  照同文件 ForwardBar 的 `inFlight` 形状改。
· Attachments.handleFiles:`uploading` 只加在按钮的 disabled 上,
  `<input type=file>` 本身无闸门 ⇒ 上传期间重入会拿到**上一次的 items 闭包**
  ⇒ onChange 把上一次结果整批覆盖,表现为「附件少了」且**无任何提示**。
  ★ 清 `input.value` 必须与闸门**成对**提前:只提前清而不加闸门,
  会亲手制造「上传中重选同一文件 ⇒ value 已空 ⇒ change 照触发 ⇒ 二次上传」。

**判据(新建 main-process-security.test.mjs,14 格,已接线)**
按括号配对取函数体/实参,**不用** `\{[\s\S]{0,80}` 窗口(§1 第三次露头)。
变异测试 **10 个全部抓住**:去掉 deny / 去掉 preventDefault / 守卫写死不放行自己 /
放开所有 scheme / 拿不到锁不退出 / sandbox:false / contextIsolation:false /
second-instance 删 show() / 删整个 if / 删 restore()。

★ PermissionPanel 那条新判据第一版是**假绿**:`fireEvent.click` 连发两次
  (不在 act 里)**删掉闸门也照样绿** —— 两次 fireEvent 之间 React 提交了一次,
  第二次点到的是已 disabled 的按钮。必须放进**同一个 act**(同批次、不提交)
  才复现。`userEvent.click` 每次都 await 一轮 ⇒ 它测不到同帧。
  这条已写进判据注释,免得下一个人再写一次。

**边界 / 未做**
· 这些是**静态**断言,证明守卫被写下来了,**不证明运行时生效**(那要真起窗口点链接)。
· 没有把 webPreferences 的值当"够不够安全"来评审 —— 那属于安全评审,不属可机检;
  本条只钉「不许被放松」。
· X-2(index.html 无 CSP)没做:首帧防闪屏那段内联脚本要求 'unsafe-inline',
  加 CSP 是在**降低**强度的前提下加一层,值得单独一轮 + 真机验闪屏,不夹在本次。
2026-10-03 11:33:51 +08:00

1252 lines
50 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, useLayoutEffect, useRef } from 'react';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useMailStore } from '../stores/mailStore';
import { useSessionStore } from '../stores/sessionStore';
import { useContactStore } from '../stores/contactStore';
import { useAuthStore } from '../stores/authStore';
import {
sessionReplyTarget,
mailReplyTarget,
mailCounterpart,
replyAllCC,
participantAddress
} from '../lib/replyTarget';
import * as api from '../api/client';
import type { Mail } from '../types';
import { MailIcon, ShieldIcon, PersonIcon, BotIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
import { Composer, ComposerChip } from './Composer';
import { useIsNarrow } from '../hooks/useIsNarrow';
import { rememberOrigin, takeComposeOrigin } from '../lib/composeOrigin';
import { useUIStore } from '../stores/uiStore';
import AddressInput from './AddressInput';
import { AttachmentList, AttachmentPicker, type PendingAttachment } from './Attachments';
import ThreadView from './ThreadView';
import PermissionChip, { permissionModeHint } from './PermissionChip';
import BackButton from './BackButton';
export default function MailView() {
const currentMail = useMailStore(s => s.currentMail);
const markRead = useMailStore(s => s.markRead);
const currentSession = useSessionStore(s => s.currentSession);
const currentSessionMails = useSessionStore(s => s.currentSessionMails);
// 当前登录用户名:判定「哪封是我发的」的唯一基准。
// 曾经写死成 'human'(单用户时代的遗留),多用户下登录名可能是 jianf,
// 判据恒为假 —— 于是回复自己发的信时对端取成了自己。
const me = useAuthStore(s => s.user?.username || '');
// 转发面板作用于哪封邮件;null = 未打开
const [forwarding, setForwarding] = useState<Mail | null>(null);
// 正在看哪封邮件的对话树;null = 看正常的邮件视图
const [threadOf, setThreadOf] = useState<string | null>(null);
const narrow = useIsNarrow();
// 切换邮件时关掉树视图:树是针对某封邮件的,留着会显示上一封的线索
const currentMailID = currentMail?.mail_id;
useEffect(() => {
setThreadOf(null);
}, [currentMailID]);
/*
* 打开即已读(2026-09-14 用户:「现在邮件需要完全手动标记是否已读而不支持点进去
* 自动已读或者回复自动已读」)。
*
* 两个细节:
* 1. 窄屏下 MailView **可能已经渲染但被列表盖住**(NarrowStack 的覆盖层),
* 那时用户还没看到它 ⇒ 必须等到真正可见(narrowPane === 'detail')才标,
* 否则"滑过去但没看"的邮件也会被标已读。
* 2. 只对 unread 发请求:已读的再标一次是白跑一趟(而且会让接口日志噪音不断)。
*/
const narrowPane = useUIStore(s => s.narrowPane);
const visible = !narrow || narrowPane === 'detail';
const currentStatus = currentMail?.status;
useEffect(() => {
if (!visible || !currentMailID || currentStatus !== 'unread') return;
void markRead(currentMailID);
}, [visible, currentMailID, currentStatus, markRead]);
if (currentSession && currentSessionMails.length > 0) {
const last = currentSessionMails[currentSessionMails.length - 1];
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
// relative 是给收起态的回复悬浮球定位用的(见 ReplyBar 的说明):
// 宽屏下没有它,球会挂到视口右下角,而不是这块详情栏的右下角。
return (
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-gray-50">
<CollapsibleHeader
narrow={narrow}
lead={<BackButton label="会话" />}
title={
currentSession.subject ||
(currentSession.session_alias ? `.${currentSession.session_alias}` : '(未命名会话)')
}
meta={
<>
<StatusBadge status={currentSession.status} />
<span className="text-xs text-gray-400 shrink-0">
{currentSessionMails.length} 封
</span>
</>
}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-semibold text-gray-900 font-mono">
{currentSession.session_alias
? `.${currentSession.session_alias}`
: '(未命名会话)'}
</span>
<div className="flex-1" />
<PermissionEditor />
<BudgetEditor />
</div>
</CollapsibleHeader>
<RenameProposalBar />
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4 space-y-3">
{currentSessionMails.map(m => (
<ThreadCard key={m.mail_id} mail={m} onForward={() => setForwarding(m)} />
))}
</div>
{/* 会话视图原先只有回复,转发入口只存在于单封邮件视图 ——
而人多数时间待在会话视图里,等于转发功能在 UI 上找不到 */}
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={last} overrideTarget={sessionTarget} />
)}
</div>
);
}
if (!currentMail) {
return (
<div className="flex-1 min-w-0 flex items-center justify-center bg-gray-50 text-gray-400">
<div className="text-center">
<MailIcon className="w-10 h-10 mx-auto text-gray-300" />
<p className="text-sm mt-3 text-gray-500">选择一封邮件查看,或点击左侧「新建」写邮件</p>
</div>
</div>
);
}
if (threadOf) {
return <ThreadView mailID={threadOf} onClose={() => setThreadOf(null)} />;
}
return (
<div className="relative flex-1 min-w-0 min-h-0 flex flex-col bg-white">
<Header
mail={currentMail}
onRead={() => markRead(currentMail.mail_id)}
onForward={() => setForwarding(currentMail)}
onThread={() => setThreadOf(currentMail.mail_id)}
/>
<div className="flex-1 overflow-y-auto px-4 md:px-6 py-4">
<div className="markdown text-sm">
<Markdown remarkPlugins={[remarkGfm]}>{currentMail.body}</Markdown>
</div>
<AttachmentList items={currentMail.attachments ?? []} />
{currentMail.mail_type === 'permission_request' && (
<PermissionPanel mail={currentMail} />
)}
</div>
{forwarding ? (
<ForwardBar mail={forwarding} onClose={() => setForwarding(null)} />
) : (
<ReplyBar replyTo={currentMail} />
)}
</div>
);
}
/**
* 权限档位编辑器(会话头部)。
*
* 人在派活时声明「这件事允许 Agent 动手到什么程度」。
* 只有新会话时在 ComposePage 里设;对话页里随时可改(规划档位)。
* 三档:plan(只读)/ workspace(目录内,越界问人)/ full(全权)。
*/
function PermissionEditor() {
const session = useSessionStore(s => s.currentSession);
const setPermissionMode = useSessionStore(s => s.setPermissionMode);
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
if (!session) return null;
const mode = session.permission_mode || 'workspace';
const enforcement = session.permission_enforcement || 'advisory';
if (!editing) {
return (
<button
onClick={() => setEditing(true)}
title={permissionModeHint(mode, enforcement)}
className="inline-flex items-center"
>
<PermissionChip mode={mode} enforcement={enforcement} />
</button>
);
}
const modes = [
{ value: 'plan', label: '只读', desc: '不许写/改/执行' },
{ value: 'workspace', label: '目录内', desc: '越界问人' },
{ value: 'full', label: '全权', desc: '自动放行' },
];
return (
<div className="flex items-center gap-1">
{modes.map(o => (
<button
key={o.value}
type="button"
disabled={busy}
onClick={async () => {
setBusy(true);
await setPermissionMode(o.value);
setBusy(false);
setEditing(false);
}}
title={o.desc}
className={`px-1.5 py-0.5 rounded text-3xs font-medium border transition-colors disabled:opacity-40 ${
mode === o.value
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-200 text-gray-500 hover:bg-gray-50'
}`}
>
{o.label}
</button>
))}
<button onClick={() => setEditing(false)} className="text-3xs text-gray-400 hover:text-gray-700 ml-0.5">
×
</button>
</div>
);
}
/**
* 本任务的往返预算编辑器(会话头部)。
*
* 配额最该被编辑的地方就是这里:人看着往来内容才知道这件事还值不值得再来几个回合。
* 放在管理员页面调某个 Agent 的全局配额是另一回事 —— 那管的是「这个 Agent 总共能发多少」,
* 而不是「这件事值得多少个来回」。
*/
function BudgetEditor() {
const budget = useSessionStore(s => s.budget);
const setBudget = useSessionStore(s => s.setBudget);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [busy, setBusy] = useState(false);
if (!budget) return null;
const exhausted = !budget.unlimited && budget.remaining === 0;
const open = () => {
setDraft(budget.unlimited ? '' : String(budget.max_rounds));
setEditing(true);
};
const commit = async (patch: { max_rounds?: number; reset?: boolean }) => {
setBusy(true);
await setBudget(patch);
setBusy(false);
setEditing(false);
};
if (!editing) {
return (
<button
onClick={open}
title="本任务的往返预算:Agent 主动发信的次数上限(自动转发的总结与权限询问不占用)"
className={`inline-flex items-center gap-1 text-2xs px-2 py-0.5 rounded border transition-colors ${
exhausted
? 'border-red-200 bg-red-50 text-red-700'
: 'border-gray-200 text-gray-500 hover:border-blue-300 hover:text-blue-600'
}`}
>
<GaugeIcon className="w-3 h-3" />
{budget.unlimited
? '预算不限'
: `${budget.used_rounds}/${budget.max_rounds} 来回${exhausted ? ' · 已用尽' : ''}`}
</button>
);
}
const invalid = draft.trim() !== '' && !/^\d+$/.test(draft.trim());
return (
<div className="flex items-center gap-1.5">
<span className="text-2xs text-gray-500">往返预算</span>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
inputMode="numeric"
placeholder="不限"
autoFocus
className={`w-16 text-xs border rounded px-1.5 py-1 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
invalid ? 'border-red-300' : 'border-gray-300'
}`}
/>
<button
disabled={busy || invalid}
onClick={() => commit({ max_rounds: draft.trim() === '' ? 0 : Number(draft.trim()) })}
className="text-2xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
保存
</button>
<button
disabled={busy || budget.used_rounds === 0}
onClick={() => commit({ reset: true })}
title="已用次数归零,上限不变"
className="text-2xs text-gray-500 hover:text-gray-900 disabled:opacity-30"
>
重置
</button>
<button
onClick={() => setEditing(false)}
className="text-2xs text-gray-400 hover:text-gray-700"
>
取消
</button>
</div>
);
}
/**
* Agent 提议改会话别名的提示条。
*
* 为什么要人点头而不是让 Agent 直接改:别名是**人**的寻址入口(name@path.别名)。
* Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
* 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。
*/
function RenameProposalBar() {
const proposal = useSessionStore(s => s.renameProposal);
const current = useSessionStore(s => s.currentSession);
const accept = useSessionStore(s => s.acceptRename);
const dismiss = useSessionStore(s => s.dismissRename);
const [busy, setBusy] = useState(false);
if (!proposal) return null;
const from = current?.session_alias ? `.${current.session_alias}` : '(未命名)';
return (
<div className="px-4 md:px-6 py-2.5 bg-blue-50 border-b border-blue-100">
<div className="flex items-start gap-2">
<TagIcon className="w-3.5 h-3.5 text-blue-500 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-900">
Agent 建议把会话别名从 <span className="font-mono">{from}</span> 改为{' '}
<span className="font-mono font-semibold">.{proposal.alias}</span>
</p>
{proposal.reason && (
<p className="text-2xs text-blue-700 mt-0.5">{proposal.reason}</p>
)}
<p className="text-3xs text-blue-700 mt-0.5">
改名后需用 name@path.{proposal.alias} 寻址;旧别名立即失效。
接受后此别名不再被 Agent 平台的自动命名覆盖
</p>
</div>
<button
disabled={busy}
onClick={async () => {
setBusy(true);
await accept();
setBusy(false);
}}
className="px-2.5 py-1 rounded bg-blue-600 text-white text-xs hover:bg-blue-600 disabled:opacity-50 shrink-0"
>
{busy ? '改名中' : '接受'}
</button>
<button
onClick={dismiss}
className="px-2.5 py-1 rounded border border-blue-200 text-blue-700 text-xs hover:bg-blue-100 shrink-0"
>
忽略
</button>
</div>
</div>
);
}
/**
* 转发面板。与回复并列,二者互斥显示 —— 同时开两个输入框会让人不知道自己在写哪个。
* 收件人用与写信页一致的三段式补全,正文引用由服务端生成(保证格式统一)。
*/
function ForwardBar({ mail, onClose }: { mail: Mail; onClose: () => void }) {
const [to, setTo] = useState('');
const [cc, setCc] = useState('');
const [ccOpen, setCcOpen] = useState(false);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
/*
* 同步的"正在提交"闸门。
*
* ★ 为什么 `busy` 这个 state 不够:它在 React 提交后才为真,而两次快速点击
* 可以在同一次提交之前都读到 `busy === false` ⇒ **发出两个 forwardMail**
* (真转发,不是本地乐观更新)。ref 的写入是同步的,能覆盖那个窗口
* —— 与 `ThreadView.loadMore` 用 `busy.current` 处理同一类问题的做法一致。
*/
const inFlight = useRef(false);
const fetchInbox = useMailStore(s => s.fetchInbox);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const submit = async () => {
if (!to.trim() || inFlight.current) return;
inFlight.current = true;
setBusy(true);
setError(null);
try {
await api.forwardMail(mail.mail_id, {
to: to.trim(),
cc: cc.trim(),
comment: comment.trim()
});
/*
* 刷新失败**不该拦住关窗**:转发此刻已经成功了,而 `Promise.all` 会在
* 四个刷新里任一失败时 reject ⇒ `onClose()` 被跳过 ⇒ 人看到报错、
* 于是**再转一次**(真的多发一封)。所以这里 allSettled:关窗照做,
* 失败的部分留给下一次 SSE/手动刷新补上。
*/
await Promise.allSettled([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
inFlight.current = false;
setBusy(false);
}
};
return (
<div className="rise-in shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 px-4 md:px-6 py-3 space-y-2">
<div className="flex items-center gap-2">
<ForwardIcon className="w-3.5 h-3.5 text-gray-500" />
<span className="text-2xs font-medium text-gray-600">
转发「{mail.subject}」
</span>
<div className="flex-1" />
<button
onClick={() => setCcOpen(o => !o)}
className={`tap text-3xs ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
</div>
<AddressInput value={to} onChange={setTo} placeholder="新收件人:pi@root.new" />
{ccOpen && (
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个"
/>
)}
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="转发说明(可选,置于引用原文之前;原文将以引用块附在下方)"
className="w-full h-16 text-sm border border-gray-300 rounded-md p-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="flex items-center gap-2 flex-wrap">
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
<div className="flex-1" />
<button onClick={onClose} className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800">
取消
</button>
<button
onClick={submit}
disabled={busy || !to.trim()}
className="tap px-4 py-1.5 text-xs font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
>
{busy ? '转发中' : '转发'}
</button>
</div>
</div>
);
}
function Header({
mail,
onRead,
onForward,
onThread
}: {
mail: Mail;
onRead: () => void;
onForward: () => void;
onThread: () => void;
}) {
const time = new Date(mail.created_at).toLocaleString('zh-CN');
const narrow = useIsNarrow();
// 发件/收件行显示**各方在这条会话里的完整地址**,人与 Agent 带的段数不同:
//
// Agent → `pi@/home/program/agentmail.<会话别名>` 三段才唯一确定
// 人 → `jianf` 人没有目录也不需要会话位
//
// 此前这里有两处错:别名被拼给了**发件人**(`jianf.<别名>` —— 既指错归属,
// 又因为人没有工作目录而拼出 path 位为空的非法地址),以及收件人**没有**
// 别名(`pi@/home/program/agentmail` 指向默认会话,不是人指定的那条)。
//
// workspace 取 `session_workspace` 而不是 from/to_workspace:后者对 Agent
// 存的是 Agent 名而非路径(历史遗留),拿它拼会得到 `dsh@dsh`。
const ws = mail.session_workspace || '';
const from = participantAddress(
mail.from_name,
mail.from_human,
mail.from_human ? '' : ws,
mail.session_alias
);
const to = participantAddress(
mail.to_name,
mail.to_human,
mail.to_human ? '' : ws,
mail.session_alias
);
return (
<CollapsibleHeader
narrow={narrow}
lead={<BackButton />}
title={mail.subject}
meta={
<>
{mail.status === 'unread' && (
<span className="shrink-0 px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs font-medium">
未读
</span>
)}
{mail.mail_type === 'permission_request' && (
<span className="shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 text-3xs font-medium">
<ShieldIcon className="w-3 h-3" />
权限请求
</span>
)}
</>
}
>
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
{mail.status === 'unread' && (
<button onClick={onRead} className="tap text-xs text-blue-600 hover:underline">
标记已读
</button>
)}
<button
onClick={onThread}
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
title="沿回复与转发关系展开整条线索"
>
<TreeIcon className="w-3.5 h-3.5" />
对话树
</button>
<button
onClick={onForward}
className="tap inline-flex items-center gap-1 text-xs text-gray-500 hover:text-gray-900"
>
<ForwardIcon className="w-3.5 h-3.5" />
转发
</button>
</div>
<dl className="text-xs text-gray-500 space-y-0.5">
<Row label="发件">{from}</Row>
<Row label="收件">{to}</Row>
{mail.cc_list?.length > 0 && (
<Row label="抄送">
{mail.cc_list.map(a => a.raw || `${a.name}@${a.path || ''}`).join('、')}
</Row>
)}
<Row label="时间">{time}</Row>
</dl>
</CollapsibleHeader>
);
}
/**
* 可折叠头部(邮件详情 / 会话)。
*
* 用户(2026-09-14):「窄屏页面阅读邮件时,顶部的邮件信息和底部的输入框等
* 占用了绝大部分页面,用户只能通过中间的一小块看邮件,体验十分不好,
* 这些位置应当有一个自动隐藏的逻辑,比如上面缩为窄栏,只显示邮件标题,
* 点击展开显示完整信息」。
*
* 做法:**默认收起**,只留一行标题(+ 未读/权限这类必须常驻的状态点),
* 点标题行展开完整信息与操作。
*
* ★ 默认值**不再分窄宽**(2026-09-14 用户:「我发现宽屏布局也有邮件内容
* 显示区域过小的问题,宽屏也同步窄屏的折叠策略吧」)。
*
* 上一版是「窄屏默认收起 / 宽屏恒展开」,理由是「宽屏横向空间够,不该引入
* 多余点击」。那条理由只看了**横向**:实测 1280x800 下,详情栏里头部 139px
* 占 17%、回复框 246px 占 31%,留给正文的只剩 385px(48%)—— 竖向一样不够用。
* 而邮件的用途是读,头和输入框都不是每时每刻都要用的东西,所以两种宽度用
* **同一套策略**:默认收起、点一下展开。
*
* 跨过断点回到默认(收起):宽度变了说明使用场景变了(横竖屏、分屏),
* 沿用上一个场景里手动展开的状态,会让人在更小的屏上莫名少掉正文空间。
*/
function CollapsibleHeader({
narrow,
lead,
title,
meta,
children
}: {
narrow: boolean;
/** 标题行最左侧的固定元素(返回键);它不能套在 toggle 按钮里(button 嵌 button) */
lead?: React.ReactNode;
title: React.ReactNode;
/** 收起态也要常驻的状态点(未读 / 权限请求 / 会话状态) */
meta?: React.ReactNode;
/** 展开后才显示的内容(收发件人、时间、操作按钮…) */
children: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
useEffect(() => setOpen(false), [narrow]);
return (
<div className="glass-card shrink-0 mx-2.5 mt-2.5 px-3.5 md:px-4 py-2.5 md:py-3">
<div className="flex items-center gap-2">
{lead}
<button
type="button"
onClick={() => setOpen(o => !o)}
aria-expanded={open}
aria-label={open ? '收起邮件信息' : '展开邮件信息'}
className="tap min-w-0 flex-1 flex items-center gap-2 text-left"
>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-gray-900">
{title}
</span>
{meta}
<ChevronRightIcon
className={`w-4 h-4 shrink-0 text-gray-400 transition-transform duration-200 motion-reduce:transition-none ${
open ? 'rotate-90' : ''
}`}
/>
</button>
</div>
{open && <div className="mt-1.5">{children}</div>}
</div>
);
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex gap-2">
<dt className="w-8 shrink-0 text-gray-400">{label}</dt>
{/* min-w-0 + break-all:会话别名可达 128 字节,不给收缩权会把整行撑出容器 */}
<dd className="min-w-0 font-mono text-gray-600 break-all">{children}</dd>
</div>
);
}
function ThreadCard({ mail, onForward }: { mail: Mail; onForward?: () => void }) {
// 「这封是我发的吗」而不是「发件人叫 human 吗」:多用户下登录名可能是
// jianf,写死 'human' 会让自己发的信显示成机器人图标。
const me = useAuthStore(s => s.user?.username || '');
const isMine = !!me && mail.from_name === me;
const isPermission = mail.mail_type === 'permission_request';
const time = new Date(mail.created_at).toLocaleString('zh-CN');
return (
<div
className={`rounded-lg border p-4 ${
isPermission
? 'border-orange-200 bg-orange-50'
: isMine
? 'border-blue-200 bg-blue-50/60'
: 'border-gray-200 bg-white'
}`}
>
<div className="flex items-center gap-1.5 mb-2 text-xs">
{isMine ? (
<PersonIcon className="w-3.5 h-3.5 text-blue-600" />
) : (
<BotIcon className="w-3.5 h-3.5 text-slate-600" />
)}
{/* 显示真实发件人名而不是 'human':会话里可能有多个人类参与方,
都渲染成 human 就分不清谁说的话 */}
<span className="font-semibold text-gray-800 font-mono">{mail.from_name}</span>
{isPermission && (
<span className="px-1 py-0.5 rounded bg-orange-200 text-orange-800 text-3xs font-medium">
权限请求
</span>
)}
{mail.cc_list?.length > 0 && (
<span
className="text-3xs text-gray-400"
title={mail.cc_list.map(c => c.raw || `${c.name}@${c.path || ''}`).join(', ')}
>
抄送 {mail.cc_list.length}
</span>
)}
<div className="flex-1" />
<span className="text-3xs text-gray-400">{time}</span>
{onForward && (
<button
onClick={onForward}
title="转发这封"
className="text-gray-400 hover:text-blue-600"
>
<ForwardIcon className="w-3 h-3" />
</button>
)}
</div>
<div className="markdown text-sm">
<Markdown remarkPlugins={[remarkGfm]}>{mail.body}</Markdown>
</div>
<AttachmentList items={mail.attachments ?? []} />
{isPermission && <PermissionPanel mail={mail} />}
</div>
);
}
/**
* 待办面板:审批型(permission)与主动提问(question)共用入口。
*
* 两类待办的渲染与提交语义完全不同:
* - permission:点「同意/拒绝」当场放行或拦下一个危险操作
* - question:模型缺信息,人**回答问题**(勾选预设选项 + 自由文本)
*
* 混用一套 UI 的后果很具体:一个问「配置文件叫什么」的问题会被渲染成
* 「同意 / 拒绝」,人只能点个毫无意义的按钮,模型拿到「同意」当答案。
*
* 导出供测试单独渲染:通过整个 MailView 渲染它需要先把 mailStore 与
* sessionStore 摆到「当前正看着一封 permission_request 邮件」的状态,
* 那些铺垫与这个组件本身的行为无关。
*/
export function PermissionPanel({ mail }: { mail: Mail }) {
const isQuestion = mail.permission_kind === 'question';
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const [decided, setDecided] = useState(mail.permission_result || '');
// 问题模式下已勾选的选项(多选时是多个)。
const [picked, setPicked] = useState<string[]>([]);
// 服务端判定这条决策越过了等待窗口时回给我们的说明。
const [staleWarning, setStaleWarning] = useState('');
// 提交失败要**说出来**:这是审批动作,失败时如果什么都不显示,人看到的是
// "点了没反应",于是反复点,而 Agent 那边一直阻塞在同一个请求上。
const [submitError, setSubmitError] = useState('');
const fetchInbox = useMailStore(s => s.fetchInbox);
const selectSession = useSessionStore(s => s.selectSession);
// 本地先算一遍「可能已失效」:服务端只给失效**时刻**,当前时间本地就有,
// 不必等一次往返。判定与后端 decideResponse 同一口径(严格晚于)。
const expiredBeforeDecide =
!mail.permission_result &&
!!mail.permission_expires_at &&
Date.now() > Date.parse(mail.permission_expires_at);
/**
* 越窗提示。
*
* 两边都要显:决策**前**(人即将点下去)与决策**后**(人以为事情已经办了)。
* 只在决策后显示等于让人先做错一次;只在决策前显示则补不上服务端在两次渲染
* 之间越窗的情形。
*/
const staleBanner =
staleWarning || expiredBeforeDecide ? (
<p className="mt-2 text-2xs leading-relaxed text-amber-800 bg-amber-50 border border-amber-200 rounded-md px-2.5 py-1.5">
{staleWarning ||
'已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'}
</p>
) : null;
/*
* 提交失败的提示条。
*
* 与 `staleBanner` 并列放在两种形态之外算一次:审批与提问的失败是同一件事
* (请求没打过去,`decided` 没变,Agent 还在等),不该各写一份。
*/
const submitErrorBanner = submitError ? (
<p className="mt-2 text-2xs leading-relaxed text-red-700 bg-red-50 border border-red-200 rounded-md px-2.5 py-1.5">
提交失败:{submitError}
<br />
Agent 仍在等待这个决定,可以再试一次。
</p>
) : null;
/*
* ★ 同步闸门(同文件 `ForwardBar` 的 `inFlight` 同一形状、同一理由)。
* `disabled={busy}` 挡得住**渲染之后**的点击,但 `busy` 要等 React 提交才为真
* ⇒ 同一帧里的两次激活(双击、Enter 连按、鼠标+键盘同时)都读到 `busy === false`,
* 于是**发出两条 `decidePermission`**。
* 对审批这不是「多点一次」的问题:服务端**记两次账**,Agent 侧也收到两条通知。
* —— 而审批是本工程里**唯一带副作用且不可撤销**的那类动作。
*/
const inFlight = useRef(false);
const submit = async (decision: string, noteText: string) => {
if (inFlight.current) return;
inFlight.current = true;
setSubmitError('');
setBusy(true);
try {
const res = await api.decidePermission(mail.mail_id, decision, noteText || undefined);
// 服务端在请求已越过等待窗口时会回 expired + warning:这次批准不会恢复
// 当时那次工具调用。必须显示出来 —— 否则人看到「已处理」就以为事情办了。
if (res?.warning) setStaleWarning(res.warning);
setDecided(decision || '(自由文本回答)');
await fetchInbox('all');
if (mail.session_id) selectSession(mail.session_id);
} catch (err) {
/*
* ★ 原来这里只有 `console.error(err)`:失败后 `decided` 仍是 '',界面回到
* 可点状态,**用户看不到任何提示**,只会以为"点了没反应"而反复点,
* 而 Agent 那边一直阻塞。同文件其它提交路径(转发/回复/预算)
* 都正确地把错误显示出来了,只有这一处是例外。
*/
setSubmitError(err instanceof Error ? err.message : String(err));
} finally {
inFlight.current = false;
setBusy(false);
}
};
if (decided) {
return (
<div className="mt-3 pt-2.5 border-t border-orange-200 text-xs text-gray-600">
已处理:
<strong className="text-gray-800 whitespace-pre-wrap">{decided}</strong>
{staleBanner}
</div>
);
}
// ─── 主动提问:勾选 + 自由文本 ───
if (isQuestion) {
const options = mail.permission_options ?? [];
const multi = mail.permission_multi_select === true;
const toggle = (opt: string) => {
setPicked(prev => {
if (multi) return prev.includes(opt) ? prev.filter(p => p !== opt) : [...prev, opt];
// 单选:再点同一项则取消,否则替换
return prev.includes(opt) ? [] : [opt];
});
};
// 回答必须非空:空提交会让模型拿到一个什么都没说的结果继续跑。
const blank = picked.length === 0 && note.trim().length === 0;
const hint =
options.length === 0
? '这题没有预设选项,请直接填写回答:'
: multi
? '可多选,也可补充说明:'
: '请选择一项,也可补充说明:';
// 形状(输入区/动作行/提示)与另两处回信完全同一套;差异只有"选项胶囊"
// 这一段,走 Composer 的 header 条件渲染(见 Composer 顶部的说明)。
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
{submitErrorBanner}
<Composer
value={note}
onChange={setNote}
rows={options.length === 0 ? 3 : 2}
placeholder={options.length === 0 ? '你的回答(必填)' : '补充说明(可选)'}
header={
<div>
<div className="text-2xs text-gray-500 mb-2">{hint}</div>
{options.length > 0 && (
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<ComposerChip
key={opt}
label={opt}
active={picked.includes(opt)}
disabled={busy}
onSelect={() => toggle(opt)}
/>
))}
</div>
)}
</div>
}
submit={{
label: '提交回答',
busy,
disabled: blank,
onClick: () => submit(picked.join('\n'), note.trim())
}}
hint={blank ? '请先选择或填写回答' : undefined}
/>
</div>
);
}
// ─── 审批型:批准 / 拒绝 ───
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
// 同样是那个 Composer:这里只换三件事 —— 备注是**单行**、选项胶囊按语义上色、
// 点胶囊即提交(审批只有"批准/拒绝"两个动作,不需要再按一次提交)。
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
{submitErrorBanner}
<Composer
value={note}
onChange={setNote}
singleLine
placeholder="备注(可选)"
header={
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<ComposerChip
key={opt}
label={opt}
variant="action"
tone={isApprove(opt) ? 'approve' : 'danger'}
disabled={busy}
onSelect={() => submit(opt, note)}
/>
))}
</div>
}
// 审批型**不给 submit**:点选项即提交(多一个提交口会以 options[0] 兜底,
// 等于点一下就批准)。备注走上面的输入框。
/>
</div>
);
}
function ReplyBar({
replyTo,
/**
* 会话视图传进来的目标地址,覆盖「按锚点邮件推断」。
*
* 会话视图的语义是「跟这个 Agent 的一次任务」,对端是会话的属性;
* 单封邮件视图没有这层语境,才回落到按那封邮件推断。
*/
overrideTarget
}: {
replyTo?: Mail;
overrideTarget?: string;
}) {
const [body, setBody] = useState('');
const [cc, setCc] = useState('');
// 抄送默认收起:多数回复不需要它,常驻一行输入框只会挤掉正文空间。
// 原邮件带抄送时自动展开并预填 —— 「回复全部」是人在这种场景下的默认预期
const [ccOpen, setCcOpen] = useState(false);
const [budgetEditing, setBudgetEditing] = useState(false);
const [budgetBusy, setBudgetBusy] = useState(false);
const [maxRoundsDraft, setMaxRoundsDraft] = useState('');
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchInbox = useMailStore(s => s.fetchInbox);
// 回复即已读要用
const markRead = useMailStore(s => s.markRead);
const fetchSent = useMailStore(s => s.fetchSent);
const fetchSessions = useSessionStore(s => s.fetchSessions);
const fetchContacts = useContactStore(s => s.fetchContacts);
const selectSession = useSessionStore(s => s.selectSession);
const budget = useSessionStore(s => s.budget);
const setBudget = useSessionStore(s => s.setBudget);
const me = useAuthStore(s => s.user?.username || '');
const narrow = useIsNarrow();
// 默认收起(只留悬浮球),宽窄同一套 —— 理由见 CollapsibleHeader 的说明。
// 跨断点回到默认,否则“自动隐藏”只在首次生效。
const [open, setOpen] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
/** 球的矩形:打开时存下来,收起时用它做反向 morph(球那时不在 DOM 上) */
const originRef = useRef<DOMRect | null>(null);
useEffect(() => setOpen(false), [narrow]);
/*
* ★ 2026-09-15 用户:「邮件页面那个蓝色的圆形是聊天图标点击没有任何动画」。
*
* 上一版这里只有一个 150ms 的 opacity 淡入 —— 观感上等于没有。用户要的形态与
* 写信页一致:**这个球自己长成回复框**(容器变形)。
*
* 用 useLayoutEffect 而不是 useEffect:要在浏览器**首次绘制之前**把 CSS 那个
* 挂载淡入关掉并接上 morph,否则会先闪一下淡入再跳一次。
* 只动 transform / opacity / border-radius(合成器可做,不触发布局)。
*/
useLayoutEffect(() => {
const el = boxRef.current;
if (!open || !el) return;
const from = takeComposeOrigin();
if (!from || !from.width) return;
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; // 关动画的人不该被位移动到
const to = el.getBoundingClientRect();
if (!to.width) return;
const scale = Math.max(0.04, from.width / to.width);
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
el.style.animation = 'none'; // 让位给 morph:两条动画都动 transform/opacity
const anim = el.animate(
[
{ transform: `translate(${dx}px, ${dy}px) scale(${scale})`, opacity: 0.3, borderRadius: '28px' },
{ transform: 'none', opacity: 1, borderRadius: '0px' }
],
{ duration: 220, easing: 'cubic-bezier(0.22, 0.61, 0.36, 1)' }
);
return () => anim.cancel();
}, [open]);
/*
* ★ 2026-09-15 用户:「收起没有动画」。打开有 morph、收起是瞬间消失 —— 不对称。
* 这里补反向:从当前形状缩回球的位置与大小,播完再卸载(先卸载就没得可播)。
*/
const collapse = () => {
const el = boxRef.current;
const from = originRef.current;
if (!el || !from || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setOpen(false);
return;
}
const to = el.getBoundingClientRect();
if (!to.width) {
setOpen(false);
return;
}
const scale = Math.max(0.04, from.width / to.width);
const dx = from.left + from.width / 2 - (to.left + to.width / 2);
const dy = from.top + from.height / 2 - (to.top + to.height / 2);
const anim = el.animate(
[
{ transform: 'none', opacity: 1, borderRadius: '0px' },
{ transform: `translate(${dx}px, ${dy}px) scale(${scale})`, opacity: 0.15, borderRadius: '28px' }
],
{ duration: 160, easing: 'cubic-bezier(0.4, 0, 0.6, 1)' }
);
// finished 在被取消时会 reject(组件卸载等),两种都得落到"收起"
anim.finished.then(() => setOpen(false)).catch(() => setOpen(false));
};
if (!replyTo) return null;
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
// 多用户下登录名可能是 jianf,判据恒为假 → 对端取成自己 → 信发给自己。
const peer = mailCounterpart(replyTo, me);
const target = overrideTarget || mailReplyTarget(replyTo, me);
// 收起态:一个悬浮球,点开才出现输入区。
//
// 「顶部邮件信息 + 底部输入框」同时常驻会把可读区压成一条缝(用户实测反馈),
// 窄屏与宽屏都成立(1280x800 下实测回复框 246px,占 31%)。输入框不是阅读时
// 每刻都要用的东西,就该按需展开;球放右下、悬浮于底部内容之上,与列表页的
// ComposeFab 同一套观感,不新增一种视觉语言。
//
// 球的定位靠 MailView 两个根节点上的 `relative`:宽屏没有它,
// `absolute` 会一路找到视口,球就挂到窗口右下角而不是这块详情栏的右下角。
if (!open) {
return (
<button
type="button"
data-testid="reply-fab"
data-morph-trigger
onClick={e => {
// 起点就在手边:把这个球自己的矩形记下来 —— 打开时用它做 morph,
// 收起时还要用它把框"缩回球里"(那时球已经不在 DOM 上,取不到)
originRef.current = e.currentTarget.getBoundingClientRect();
rememberOrigin(e.currentTarget);
setOpen(true);
}}
title={`回复 ${target}`}
aria-label={`回复 ${target}`}
className="absolute bottom-4 right-4 z-20 w-14 h-14 rounded-full bg-blue-600 text-white shadow-lg flex items-center justify-center hover:bg-blue-700 active:bg-blue-800 transition-colors"
>
<ChatBubbleIcon className="w-6 h-6" />
</button>
);
}
const send = async () => {
if (!body.trim()) return;
setBusy(true);
setError(null);
try {
await api.sendMail(target, `Re: ${replyTo.subject}`, body, {
reply_to: replyTo.mail_id,
cc: cc.trim(),
attachment_ids: attachments.map(a => a.id)
});
// 回复即已读(用户要求):回了信就等于处理过这封了,不必再手动点一次。
// 放在发送成功之后:发送失败时不该把"我处理过了"记下来。
void markRead(replyTo.mail_id);
setBody('');
setCc('');
setCcOpen(false);
setAttachments([]);
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
if (replyTo.session_id) selectSession(replyTo.session_id);
} catch (err) {
// 必须显示出来:预算耗尽、地址不存在、速率限制都会走到这里,
// 原先只 console.error,用户点了发送什么反应都没有
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
/**
* 「回复全部」:把原邮件的其他参与方填进抄送。
*
* cc_list 是结构化的 Address(后端解析过三维寻址)。传当前会话别名进去,
* 让 `.new` 被换成真实别名 —— 原样回填 `.new` 会让这封回复给抄送方
* **另开一条新会话**,于是同一件事裂成两条线索。
*/
const replyAll = () => {
// 去重与「去掉自己」都在 replyAllCC 里:原先用 !a.startsWith('human')
// 去自己,同一个遗留判据 —— 去不掉 jianf,点「回复全部」会把自己抄送进去。
setCc(replyAllCC(replyTo, me, peer.name).join(', '));
setCcOpen(true);
};
return (
<div
ref={boxRef}
className="rise-in shrink-0 max-h-[min(65vh,calc(var(--app-height)-3rem))] overflow-y-auto overscroll-contain border-t border-gray-200 px-4 md:px-6 py-3"
>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<p className="text-3xs text-gray-400 font-mono min-w-0 truncate">回复 {target}</p>
<div className="flex-1" />
{(replyTo.cc_list?.length ?? 0) > 0 && (
<button
onClick={replyAll}
className="tap text-3xs text-gray-500 hover:text-blue-600"
>
回复全部
</button>
)}
<button
onClick={() => setCcOpen(o => !o)}
className={`tap text-3xs ${ccOpen ? 'text-blue-600' : 'text-gray-500 hover:text-blue-600'}`}
>
{ccOpen ? '收起抄送' : '抄送'}
</button>
<button
onClick={collapse}
className="tap inline-flex items-center gap-0.5 text-3xs text-gray-500 hover:text-blue-600"
aria-label="收起回复框"
>
收起
<ChevronRightIcon className="w-3 h-3 rotate-90" />
</button>
</div>
{ccOpen && (
<div className="mb-2">
<AddressInput
value={cc}
onChange={setCc}
allowMultiple
placeholder="抄送:逗号分隔,可多个(如 pi@root, ops@root)"
/>
</div>
)}
{/* 输入区走共用的 Composer(roomy 档:回信要写作空间 + 等宽字体)。
发送动作也并入它的动作行,附属内容(附件、预算、回复全部)走 footerExtra ——
形状只有一处定义,差异是数据。 */}
<Composer
value={body}
onChange={setBody}
placeholder="回复内容(Markdown)"
density="roomy"
autoFocus
error={error}
submit={{
label: '发送',
busy,
disabled: !body.trim(),
onClick: send
}}
/>
<div className="mt-2">
<AttachmentPicker items={attachments} onChange={setAttachments} disabled={busy} />
</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{error && <span className="text-xs text-red-600 min-w-0 break-words">{error}</span>}
<div className="flex-1" />
{budget && !budgetEditing && (
<button
onClick={() => {
setMaxRoundsDraft(budget.unlimited ? '' : String(budget.max_rounds));
setBudgetEditing(true);
}}
title="本任务往返预算:Agent 主动发信上限(自动转发与权限询问不占)"
className={`tap inline-flex items-center gap-1 text-3xs px-2 py-1 rounded border ${
budget.unlimited
? 'border-gray-200 text-gray-500 hover:border-blue-300'
: budget.remaining === 0
? 'border-red-200 text-red-600'
: 'border-gray-200 text-gray-500 hover:border-blue-300'
}`}
>
<GaugeIcon className="w-3 h-3" />
{budget.unlimited
? '预算不限'
: `${budget.used_rounds}/${budget.max_rounds} 来回${budget.remaining === 0 ? ' · 已用尽' : ''}`}
</button>
)}
{budget && budgetEditing && (
<span className="inline-flex items-center gap-1">
<span className="text-3xs text-gray-500">预算</span>
<input
value={maxRoundsDraft}
onChange={e => setMaxRoundsDraft(e.target.value)}
inputMode="numeric"
placeholder="不限"
autoFocus
className="w-14 text-xs border border-gray-300 rounded px-1.5 py-0.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<button
disabled={budgetBusy || (maxRoundsDraft.trim() !== '' && !/^\d+$/.test(maxRoundsDraft.trim()))}
onClick={async () => {
setBudgetBusy(true);
await setBudget({ max_rounds: maxRoundsDraft.trim() === '' ? 0 : Number(maxRoundsDraft.trim()) });
setBudgetBusy(false);
setBudgetEditing(false);
}}
className="tap px-2 py-0.5 text-3xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
保存
</button>
<button
onClick={() => setBudgetEditing(false)}
className="tap text-3xs text-gray-400 hover:text-gray-700"
>
×
</button>
</span>
)}
<button
onClick={() => {
setBody('');
setError(null);
}}
className="tap px-3 py-1.5 text-xs text-gray-500 hover:text-gray-800"
>
清空
</button>
{/* 发送按钮已并入上面 Composer 的动作行:同一个 UI,不再各写一个。
这里只留「清空」这类不属于"提交"的动作。 */}
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { label: string; cls: string }> = {
active: { label: '进行中', cls: 'bg-yellow-100 text-yellow-700' },
waiting: { label: '等待中', cls: 'bg-blue-100 text-blue-700' },
completed: { label: '已完成', cls: 'bg-green-100 text-green-700' },
archived: { label: '已归档', cls: 'bg-gray-200 text-gray-600' }
};
const b = map[status] || map.active;
return <span className={`text-3xs px-1.5 py-0.5 rounded-full ${b.cls}`}>{b.label}</span>;
}