**主进程此前没有任何守卫**(实测 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 是在**降低**强度的前提下加一层,值得单独一轮 + 真机验闪屏,不夹在本次。
1252 lines
50 KiB
TypeScript
1252 lines
50 KiB
TypeScript
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>;
|
||
}
|