Files
MailUI4Agents/client/electron/src/components/MailView.tsx
JianFeeeee f5d05755b7 fix(webui): 窄屏两处 —— 收起补反向 morph、编辑态不再隐藏底部导航
用户:「收起没有动画,且输入框弹出后,底部导航栏会消失,再次点击界面导航才会出现」

① 收起没动画:打开有 morph、收起是瞬间卸载(不对称)。补反向 morph:
   打开时把球的矩形存进 originRef(球在框打开时不在 DOM 上,收起草稿时取不到),
   收起时从当前形状缩回球的位置/大小(160ms),**播完再卸载**(先卸载就没得播)。
   finished 被取消时也会 reject,两种情况都落到"收起"。

② 底部导航消失:测量结果是 nav 的 rect = 0,0,0,0,即 **display:none**,不是被挤出去——
   根因是 `.form-editing .narrow-nav { display: none }` 撞上"回复框展开即自动聚焦 textarea"
   (form-editing 由 main.tsx 在任何输入框获焦时挂上)⇒ 一弹框导航就消失,点到别处才回来。
   而这条隐藏已经没必要:viewport 声明了 `interactive-widget=resizes-content`,键盘弹出时
   浏览器会重排布局,导航自然落在键盘上方。已删掉该隐藏。
   顺带:详情/列表两个窄屏根节点补 min-h-0(flex 子项默认不可压缩,回复框一撑会把导航顶出可视区)。

判据 +4(编辑态不隐藏导航、viewport 声明 resizes-content、两个根节点带 min-h-0、收起有反向
morph 且先播完再卸载)。变异:抽掉 min-h-0 → 红;收起改回瞬间卸载 → 红;把 display:none
放回去 → 红。
★ 一处判据自身的坑:判断"还有没有那条隐藏规则"必须用**剥注释版**——我第一版用原文判,
被我刚写的那段解释性注释(里面原样引用了那条规则)判红,与 read.mjs 里记的同一形态。

窄屏(420x820,真实邮件)实测:初始/开信/回复框弹出并聚焦(form-editing)/收起后 ——
底部导航高度都是 51px 且在视口内;收起时框上有 160ms 动画。
2026-09-15 08:45:54 +08:00

1196 lines
47 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);
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="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('');
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;
const hint =
options.length === 0
? '这题没有预设选项,请直接填写回答:'
: multi
? '可多选,也可补充说明:'
: '请选择一项,也可补充说明:';
// 形状(输入区/动作行/提示)与另两处回信完全同一套;差异只有"选项胶囊"
// 这一段,走 Composer 的 header 条件渲染(见 Composer 顶部的说明)。
return (
<div className="mt-3 pt-3 border-t border-orange-200">
{staleBanner}
<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}
<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>;
}