Files
MailUI4Agents/client/electron/src/components/MailView.tsx
JianFeeeee 9aa702b8cc fix(webui): 导航改"按主题的玻璃"(浅色白玻璃+深字)+ 去掉整屏闪的入场动画
用户两句话:
  「那你为什么不把白字换成黑字或者自动反色或者描边呢?」
  「部分动画十分不合理,会导致页面大范围的闪动,且不能让人自然的把注意力集中在
    将要出现的页面上」

## ① 导航:他说得对,我之前是用错误的方式解决对比度

这个应用是**浅色底 + 深字**,导航却是全页唯一一块黑的 —— 那才是"割裂"。我上一轮
为了"白字对比度"把它做成深玻璃,等于**把一块地方永久压黑**来回避问题。

现在导航底色/文字全部走令牌,按主题切换:

    浅色:白玻璃 rgb(255 255 255 / .72) + 深字 #475569   → 对比度 7.48:1
    深色:深玻璃 rgb(15 23 42 / .72)   + 亮字 #94a3b8   → 自动反色

组件侧改成 `.nav-rail` / `.nav-item[data-active]` 令牌类(Sidebar 与 NarrowNav 同一套)。
同时删掉壁纸模式里写死的深玻璃规则 —— 那条正是"为什么还是黑色"。

## ② 动画:整面板入场删掉

先是从"所有面板一起动"改成"只动主内容区",试下来仍然不对:页面级淡入会把
**已经在那儿的框架**也一起暗一下,观感还是闪。所以这一档整体删掉,只保留局部、
有明确语义的动效(日历翻页、菜单展开)。实测切视图时 `animatedOnSwitch = 0`。
骨架不动,注意力自然落在变化的那块内容上。

## ③ 一条我自己的误判(值得记下)

深色主题下我量到导航文字对比度只有 2.36,一度以为是变量/级联的问题,还写死了一份
深色字面值。**那是误判**:`.nav-item` 有 `transition: color .15s`,我在切主题后
**立刻**读 computed color,拿到的是过渡的**起点**(浅色值)。决定性证据是连内联
`style.color` 都"改不动"它 —— 级联不可能这样,只可能是还在过渡中。等 400ms 再量就正常。
写死的那份已撤掉,并在 CSS 里留下说明;新判据 `test/manual/nav-contrast-verify.mjs`
用 WCAG 比值量对比度(不是"颜色是不是黑的"),每次读之前等 400ms。

背景套件 32 条全绿(含"导航走令牌 + 两套令牌都在")。
2026-09-14 11:57:41 +08:00

1093 lines
42 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 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, CheckIcon, CloseIcon, ForwardIcon, TreeIcon, TagIcon, GaugeIcon, ChevronRightIcon, ChatBubbleIcon } from './icons';
import { useIsNarrow } from '../hooks/useIsNarrow';
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]);
if (currentSession && currentSessionMails.length > 0) {
const last = currentSessionMails[currentSessionMails.length - 1];
// 会话视图的对端是**会话的属性**,不能由「最后一封是谁发的」决定:
// 人在这里打字就是「给这次任务的对方追加一句」,而最后一封很可能是自己刚发的,
// 那时取对端会取成自己 —— 信就发给了自己(生产已发生)。
const sessionTarget = sessionReplyTarget(currentSessionMails, currentSession, me);
return (
<div className="flex-1 min-w-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="flex-1 min-w-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);
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() || busy) return;
setBusy(true);
setError(null);
try {
await api.forwardMail(mail.mail_id, {
to: to.trim(),
cc: cc.trim(),
comment: comment.trim()
});
await Promise.all([fetchInbox('all'), fetchSent(), fetchSessions(), fetchContacts()]);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
};
return (
<div className="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「窄屏页面阅读邮件时顶部的邮件信息和底部的输入框等
* 占用了绝大部分页面,用户只能通过中间的一小块看邮件,体验十分不好,
* 这些位置应当有一个自动隐藏的逻辑,比如上面缩为窄栏,只显示邮件标题,
* 点击展开显示完整信息」。
*
* 做法:窄屏**默认收起**,只留一行标题(+ 未读/权限这类必须常驻的状态点),
* 点标题行展开完整信息与操作。宽屏没有这个矛盾(横向空间够),所以宽屏恒展开、
* 不引入多余点击 —— 同一套头部代码,两种默认值。
*
* 跨过断点时要重置(宽 → 窄应自动收起),否则「自动隐藏」只在首次生效。
*/
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(!narrow);
useEffect(() => setOpen(!narrow), [narrow]);
return (
<div className="shrink-0 px-4 md:px-6 py-2 md:py-3.5 border-b border-gray-200 bg-white">
<div className="flex items-center gap-2">
{lead}
<button
type="button"
// 宽屏没有可展开的东西(已经全展开了),此时点它不该有任何副作用
onClick={() => narrow && setOpen(o => !o)}
aria-expanded={open}
aria-label={narrow ? (open ? '收起邮件信息' : '展开邮件信息') : undefined}
className={`min-w-0 flex-1 flex items-center gap-2 text-left ${narrow ? 'tap' : 'cursor-default'}`}
>
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-gray-900">
{title}
</span>
{meta}
{narrow && (
<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('');
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;
const submit = async (decision: string, noteText: string) => {
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);
} finally {
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;
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
<div className="text-2xs text-gray-500 mb-2">
{options.length === 0
? '这题没有预设选项,请直接填写回答:'
: multi ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:'}
</div>
{options.length > 0 && (
<div className="flex flex-wrap gap-2">
{options.map(opt => {
const on = picked.includes(opt);
return (
<button
key={opt}
onClick={() => toggle(opt)}
disabled={busy}
aria-pressed={on}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border transition-colors disabled:opacity-40 ${
on
? 'bg-blue-700 text-white border-blue-700 hover:bg-blue-800'
: 'glass-control text-gray-700 border-gray-300 hover:bg-gray-50'
}`}
>
{on && <CheckIcon className="w-3.5 h-3.5" />}
{opt}
</button>
);
})}
</div>
)}
<textarea
value={note}
onChange={e => setNote(e.target.value)}
rows={options.length === 0 ? 3 : 2}
placeholder={options.length === 0 ? '你的回答(必填)' : '补充说明(可选)'}
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 resize-y focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<div className="mt-2 flex items-center gap-2">
<button
onClick={() => submit(picked.join('\n'), note.trim())}
disabled={busy || blank}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-blue-700 text-white hover:bg-blue-800 disabled:opacity-40"
>
</button>
{blank && (
<span className="text-2xs text-gray-400"></span>
)}
</div>
</div>
);
}
// ─── 审批型:批准 / 拒绝 ───
const options = mail.permission_options?.length ? mail.permission_options : ['同意', '拒绝'];
const isApprove = (s: string) => /同意|允许|批准|approve|yes/i.test(s);
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
<div className="flex flex-wrap gap-2">
{options.map(opt => (
<button
key={opt}
onClick={() => submit(opt, note)}
disabled={busy}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-40 ${
isApprove(opt)
? 'bg-green-700 text-white hover:bg-green-800'
: 'bg-red-50 text-red-700 border border-red-200 hover:bg-red-100'
}`}
>
{isApprove(opt) ? (
<CheckIcon className="w-3.5 h-3.5" />
) : (
<CloseIcon className="w-3.5 h-3.5" />
)}
{opt}
</button>
))}
</div>
<input
value={note}
onChange={e => setNote(e.target.value)}
placeholder="备注(可选)"
className="mt-2 w-full text-xs border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</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 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();
// 窄屏默认收起(只留悬浮球);宽屏恒展开。跨断点时重置,否则“自动隐藏”只在首次生效。
const [open, setOpen] = useState(!narrow);
useEffect(() => setOpen(!narrow), [narrow]);
if (!replyTo) return null;
// 判据是「当前登录用户名」而不是字面量 'human':后者是单用户时代的遗留,
// 多用户下登录名可能是 jianf判据恒为假 → 对端取成自己 → 信发给自己。
const peer = mailCounterpart(replyTo, me);
const target = overrideTarget || mailReplyTarget(replyTo, me);
// 窄屏收起态:一个悬浮球,点开才出现输入区。
//
// 窄屏上「顶部邮件信息 + 底部输入框」同时常驻会把可读区压成一条缝(用户实测反馈)。
// 输入框不是阅读时每刻都要用的东西,就该按需展开;球放右下、悬浮于底部导航之上,
// 与列表页的 ComposeFab 同一套观感,不新增一种视觉语言。
if (narrow && !open) {
return (
<button
type="button"
data-testid="reply-fab"
onClick={() => 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)
});
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 className="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>
{narrow && (
<button
onClick={() => setOpen(false)}
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>
)}
<textarea
value={body}
onChange={e => setBody(e.target.value)}
placeholder="回复内容Markdown"
// 窄屏是「点球展开」进来的,焦点直接落到输入框,少一次点击
autoFocus={narrow}
className="w-full h-20 text-sm font-mono border border-gray-300 rounded-md p-3 resize-none focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<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>
<button
onClick={send}
disabled={busy || !body.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 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>;
}