feat(desktop): 底部抽屉可拖拽调高/最大化 + 输出渲染 Markdown

解决两个真实体验问题(编排页底部输出区):
- 抽屉太矮(176px)长输出放不下:改为可拖拽顶边调高(140px~85vh) + 一键最大化(82vh)/还原。
- 输出原样显示 markdown 未渲染:接入现有轻量 Markdown 组件(标题/加粗/斜/码/列表/引用/分隔/双链,
  随主题),替换 <pre>;含 ```chart 块时文本段渲 Markdown、图表段渲 SVG,保持顺序。
  复用现成组件,未引重依赖(react-markdown 试装后回退),bundle 不涨。

tsc + 生产构建 + vitest 48 全过;桌面端已 HMR。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-25 12:57:51 +08:00
parent 760ca06667
commit 429b04d64a
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useCallback, useRef, useState } from "react";
import { ChevronDown, ChevronUp, Wrench, ShieldCheck, Check, X } from "lucide-react"; import { ChevronDown, ChevronUp, Wrench, ShieldCheck, Check, X, Maximize2, Minimize2 } from "lucide-react";
import { deriveNodes, pendingApproval, type RunState } from "../lib/run"; import { deriveNodes, pendingApproval, type RunState } from "../lib/run";
import { ExecTrace } from "../components/ExecTrace"; import { ExecTrace } from "../components/ExecTrace";
import { ChartView } from "../components/ChartView"; import { ChartView } from "../components/ChartView";
import { Markdown } from "../components/Markdown";
import { extractChartBlocks, hasChart } from "../lib/chartspec"; import { extractChartBlocks, hasChart } from "../lib/chartspec";
import { approveTask } from "../lib/api"; import { approveTask } from "../lib/api";
import { Tabs, Badge, cn, type TabDef } from "../ui"; import { Tabs, Badge, cn, type TabDef } from "../ui";
@@ -13,6 +14,35 @@ type Tab = "output" | "trace" | "tools" | "cite" | "eval";
export function BottomDrawer({ run }: { run: RunState }) { export function BottomDrawer({ run }: { run: RunState }) {
const [open, setOpen] = useState(true); const [open, setOpen] = useState(true);
const [tab, setTab] = useState<Tab>("output"); const [tab, setTab] = useState<Tab>("output");
const [height, setHeight] = useState(240); // 可拖拽调节的抽屉高度
const prevH = useRef(240);
// 拖拽顶边调高:监听 window,高度 = 视口高 - 鼠标 Y,夹在 [140, 85vh]。
const startResize = useCallback(() => {
const onMove = (e: MouseEvent) => {
const h = Math.min(window.innerHeight * 0.85, Math.max(140, window.innerHeight - e.clientY));
setOpen(true);
setHeight(h);
};
const onUp = () => {
window.removeEventListener("mousemove", onMove);
window.removeEventListener("mouseup", onUp);
document.body.style.userSelect = "";
};
document.body.style.userSelect = "none";
window.addEventListener("mousemove", onMove);
window.addEventListener("mouseup", onUp);
}, []);
const maximized = height >= window.innerHeight * 0.8;
const toggleMax = () => {
if (maximized) setHeight(prevH.current || 240);
else {
prevH.current = height;
setHeight(Math.round(window.innerHeight * 0.82));
}
setOpen(true);
};
const nodes = deriveNodes(run.exec); const nodes = deriveNodes(run.exec);
const toolCount = nodes.filter((n) => n.kind === "tool").length; const toolCount = nodes.filter((n) => n.kind === "tool").length;
@@ -38,6 +68,8 @@ export function BottomDrawer({ run }: { run: RunState }) {
return ( return (
<div className="shrink-0 border-t border-line bg-ink-900"> <div className="shrink-0 border-t border-line bg-ink-900">
{approval && run.taskId && <ApprovalBar taskId={run.taskId} node={approval.node} title={approval.title} summary={approval.summary} />} {approval && run.taskId && <ApprovalBar taskId={run.taskId} node={approval.node} title={approval.title} summary={approval.summary} />}
{/* 拖拽调高手柄(横跨顶边) */}
{open && <div onMouseDown={startResize} className="h-1 cursor-ns-resize bg-transparent hover:bg-brand/40 transition" title="拖动调整高度" />}
<div className="flex items-center border-b border-line px-2"> <div className="flex items-center border-b border-line px-2">
<Tabs <Tabs
tabs={tabs} tabs={tabs}
@@ -48,13 +80,20 @@ export function BottomDrawer({ run }: { run: RunState }) {
}} }}
/> />
<span className={cn("ml-2 text-[11px]", statusCls)}>{statusText}</span> <span className={cn("ml-2 text-[11px]", statusCls)}>{statusText}</span>
<button onClick={() => setOpen((o) => !o)} className="ml-auto flex items-center gap-1 px-2 py-2 text-xs text-slate-500 hover:text-slate-300"> <div className="ml-auto flex items-center">
{open && (
<button onClick={toggleMax} className="flex items-center px-2 py-2 text-slate-500 hover:text-slate-300" title={maximized ? "还原" : "最大化"}>
{maximized ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</button>
)}
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-1 px-2 py-2 text-xs text-slate-500 hover:text-slate-300">
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />} {open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronUp className="h-3.5 w-3.5" />}
{open ? "收起" : "展开"} {open ? "收起" : "展开"}
</button> </button>
</div> </div>
</div>
{open && ( {open && (
<div className="h-44 overflow-auto p-3 text-xs"> <div className="overflow-auto p-3 text-xs" style={{ height }}>
{tab === "output" && <OutputView output={run.output} />} {tab === "output" && <OutputView output={run.output} />}
{tab === "trace" && <ExecTrace events={run.exec} phase={run.phase} />} {tab === "trace" && <ExecTrace events={run.exec} phase={run.phase} />}
{tab === "tools" && <ToolCalls run={run} />} {tab === "tools" && <ToolCalls run={run} />}
@@ -123,26 +162,19 @@ function ApprovalBar({ taskId, node, title, summary }: { taskId: string; node: s
// OutputView:渲染模型输出。含 ```chart 块时分段渲染(文本 + SVG 图表),否则纯文本。 // OutputView:渲染模型输出。含 ```chart 块时分段渲染(文本 + SVG 图表),否则纯文本。
function OutputView({ output }: { output: string }) { function OutputView({ output }: { output: string }) {
if (!output) { if (!output) {
return ( return <div className="text-xs leading-relaxed text-slate-500"> Markdown </div>;
<pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
token
</pre>
);
} }
if (!hasChart(output)) { if (!hasChart(output)) {
return <pre className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">{output}</pre>; return <Markdown text={output} className="text-[13px]" />;
} }
// 含 ```chart 块:文本段渲染 Markdown,图表段渲染 SVG,保持原顺序。
return ( return (
<div> <div>
{extractChartBlocks(output).map((seg, i) => {extractChartBlocks(output).map((seg, i) =>
seg.kind === "chart" ? ( seg.kind === "chart" ? (
<ChartView key={i} spec={seg.spec} /> <ChartView key={i} spec={seg.spec} />
) : ( ) : (
seg.text.trim() && ( seg.text.trim() && <Markdown key={i} text={seg.text} className="text-[13px]" />
<pre key={i} className="whitespace-pre-wrap font-mono leading-relaxed text-emerald-300">
{seg.text}
</pre>
)
), ),
)} )}
</div> </div>