diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx
index c0df3c72..26e4d7ee 100644
--- a/frontend/src/canvas/Inspector.tsx
+++ b/frontend/src/canvas/Inspector.tsx
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
-import { Copy, Plus, Settings2, Trash2, X } from 'lucide-react'
+import { ChevronLeft, Copy, Plus, Settings2, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { NODE_DEFS, type FieldDef } from './nodeDefs'
import { useStudio } from '../store/studio'
@@ -39,7 +39,18 @@ export function Inspector() {
return (
-
+
+ {/* 这块是盖在助手栏上的一层。不给一个明确的"回去",用户就不知道
+ 底下还有东西——只看到一个 ×,会以为关掉之后什么都没有了 */}
+
+
{def.label}
diff --git a/frontend/src/canvas/InspectorSheet.tsx b/frontend/src/canvas/InspectorSheet.tsx
new file mode 100644
index 00000000..66bd01fa
--- /dev/null
+++ b/frontend/src/canvas/InspectorSheet.tsx
@@ -0,0 +1,100 @@
+import { useEffect } from 'react'
+import { ChevronLeft, Hand } from 'lucide-react'
+import clsx from 'clsx'
+import { useCatalog } from '../store/catalog'
+import { useStudio } from '../store/studio'
+import { decodeRun, type Step } from '../run/decode'
+import { Spinner } from '../components/ui'
+import { Inspector } from './Inspector'
+
+/**
+ * 属性面板:盖在助手栏上的一层,不是和它并列的 tab。
+ *
+ * 之前两者是 [助手|属性] 两个 tab。tab 是互斥的,而这两件事在时间上不互斥——
+ * 跑图跑到一半想点开某个节点看配置,整条执行过程就从眼前消失了;切回来时
+ * 滚动位置、展开的步骤、输入框里打了一半的字全没(tab 切换会卸载组件)。
+ *
+ * 改成"盖上去"之后:助手一直在下面活着,属性是临时造访者,关掉就回到原处。
+ * 选中节点即滑入,Esc 或点画布空白处即滑出——不需要专门去找一个 tab。
+ */
+export function InspectorSheet() {
+ const selectedId = useStudio((s) => s.selectedId)
+ const select = useStudio((s) => s.select)
+
+ // Esc 关闭。这是"覆盖层"这种东西的通用约定,没有它就只能去找那个 ×
+ useEffect(() => {
+ if (!selectedId) return
+ const onKey = (e: KeyboardEvent) => {
+ if (e.key !== 'Escape') return
+ // 正在输入框里打字时 Esc 是给输入法用的,别抢
+ const el = document.activeElement
+ const typing = el instanceof HTMLElement
+ && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
+ if (typing) return
+ select(null)
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [selectedId, select])
+
+ if (!selectedId) return null
+
+ return (
+ // key 带上 selectedId:换一个节点时重播一次滑入,让人知道内容换了。
+ // 不重播的话,点另一个节点看起来像什么都没发生
+
+ )
+}
+
+/**
+ * 盖住助手栏期间,底部留一条执行进度。
+ *
+ * 不留的话,跑图跑到一半点开节点看配置,运行就从眼前彻底消失了——这正是
+ * 原来 tab 方案最难受的地方,不能因为换成"盖一层"就原样留着。点一下回去。
+ */
+function LiveStrip() {
+ const streaming = useStudio((s) => s.streaming)
+ const events = useStudio((s) => s.events)
+ const select = useStudio((s) => s.select)
+ const approvals = useCatalog((s) => s.approvals)
+ const run = useStudio((s) => s.run)
+ const waiting = approvals.some((a) => a.run_id === run?.id && a.status === 'pending')
+
+ if (!streaming && !waiting) return null
+
+ // 只取最后一步说给人听,不在这里重演整条流——这是一条提示,不是第二个时间线
+ const steps = decodeRun(events)
+ const last = flattenLast(steps)
+
+ return (
+
select(null)}
+ >
+ {waiting
+ ?
+ : }
+
+ {waiting ? '等你确认' : last?.title || '正在执行…'}
+
+
+
+ )
+}
+
+/** 最后一条有意义的步骤:优先取最深的子步骤,那才是"此刻在干什么"。 */
+function flattenLast(steps: Step[]): Step | undefined {
+ const flat: Step[] = []
+ const walk = (list: Step[]) => list.forEach((s) => { flat.push(s); if (s.children) walk(s.children) })
+ walk(steps)
+ return [...flat].reverse().find((s) => s.kind !== 'lifecycle') ?? flat[flat.length - 1]
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 37581db1..a006ed99 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -221,6 +221,92 @@ pre,
animation: fade-up 0.16s ease-out;
}
+/* --------------------------------------------------------------------------
+ 助手栏的动效。
+ 缓动统一用 cubic-bezier(.32,.72,0,1):起步快、尾部长,是"被推出来"而不是
+ "匀速平移"的手感。时长都压在 240ms 以内——面板滑动超过这个数就开始像卡顿,
+ 而不是像流畅。
+ -------------------------------------------------------------------------- */
+
+/* 属性面板:从右侧盖到助手栏上。它是临时造访者,助手才是常住的,所以用
+ "盖上去再拿走"而不是切 tab——切 tab 会把下面那层卸载掉,滚动位置、
+ 输入草稿、展开状态全丢。 */
+@keyframes sheet-in {
+ from { opacity: 0; transform: translateX(16px); }
+ to { opacity: 1; transform: none; }
+}
+.sheet-in {
+ animation: sheet-in 0.22s cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+/* 例句卡片依次入场。--i 是序号,错开 40ms 就够读出"一条条出现",
+ 再多就开始等 */
+@keyframes rise-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: none; }
+}
+.rise-in {
+ opacity: 0;
+ animation: rise-in 0.34s cubic-bezier(0.32, 0.72, 0, 1) forwards;
+ animation-delay: calc(var(--i, 0) * 40ms);
+}
+
+/* 正在流的那一步:一道光扫过文字。比转圈多说一件事——它在出字,不只是在等 */
+@keyframes shimmer {
+ from { background-position: 200% 0; }
+ to { background-position: -200% 0; }
+}
+.shimmer {
+ background: linear-gradient(
+ 90deg,
+ var(--text-faint) 0%, var(--text-faint) 35%,
+ var(--text) 50%,
+ var(--text-faint) 65%, var(--text-faint) 100%
+ );
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ background-clip: text;
+ color: transparent;
+ animation: shimmer 2.4s linear infinite;
+}
+
+/* 空态那枚标记的呼吸感。很轻——它是在暗示"可以开始了",不是在要求注意力 */
+@keyframes breathe {
+ 0%, 100% { opacity: 0.55; transform: scale(1); }
+ 50% { opacity: 1; transform: scale(1.06); }
+}
+.breathe {
+ animation: breathe 4s ease-in-out infinite;
+}
+
+/* 输入坞聚焦时的光晕。焦点是这一栏最重要的状态,值得比 1px 描边更明确 */
+.dock-focus {
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
+ border-color: var(--accent);
+}
+
+/* --------------------------------------------------------------------------
+ 前庭敏感的人看这些动效会难受,而这套界面里动的东西不少(边在流动、节点在
+ 脉冲、面板在滑)。系统里关掉动效的,这里就全部停掉——不是调慢,是停掉。
+ -------------------------------------------------------------------------- */
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ transition-duration: 0.01ms !important;
+ scroll-behavior: auto !important;
+ }
+ .rise-in { opacity: 1; }
+ .shimmer {
+ background: none;
+ -webkit-background-clip: border-box;
+ background-clip: border-box;
+ color: var(--text-dim);
+ }
+}
+
/* 表单控件统一外观 */
.field {
width: 100%;
diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx
index 33f6f59c..df512197 100644
--- a/frontend/src/pages/StudioPage.tsx
+++ b/frontend/src/pages/StudioPage.tsx
@@ -6,12 +6,13 @@ import {
import clsx from 'clsx'
import { api } from '../api/client'
import { FlowCanvas } from '../canvas/FlowCanvas'
-import { Inspector } from '../canvas/Inspector'
import { Palette } from '../canvas/Palette'
+import { InspectorSheet } from '../canvas/InspectorSheet'
import { AssistantPanel } from '../run/AssistantPanel'
+import { RunControl } from '../run/RunControl'
import { useStudio, toGraph } from '../store/studio'
import { useCatalog } from '../store/catalog'
-import { Empty, Modal, Spinner, Tabs, useToast } from '../components/ui'
+import { Empty, Modal, Spinner, useToast } from '../components/ui'
import type { Workflow } from '../types'
export function StudioPage() {
@@ -20,11 +21,9 @@ export function StudioPage() {
const workflow = useStudio((s) => s.workflow)
const dirty = useStudio((s) => s.dirty)
const issues = useStudio((s) => s.issues)
- const selectedId = useStudio((s) => s.selectedId)
const streaming = useStudio((s) => s.streaming)
- const { load, save, setGraph } = useStudio()
+ const { load, save, setGraph, select } = useStudio()
- const [tab, setTab] = useState<'inspect' | 'run'>('run')
const [picker, setPicker] = useState(false)
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
@@ -35,14 +34,11 @@ export function StudioPage() {
if (!workflow && workflows.length) load(workflows[0])
}, [workflows, workflow, load])
- // 选中节点时自动切到属性页,省一次点击
+ // 开始跑图或生成时,属性面板让开——不然进展发生在一块被盖住的地方。
+ // (选中节点即滑出属性面板,取消选中即收起,不再需要手动切 tab)
useEffect(() => {
- if (selectedId) setTab('inspect')
- }, [selectedId])
- // 开始跑图或开始生成时切回助手栏,否则进展发生在一个看不见的 tab 里
- useEffect(() => {
- if (streaming || copilotActive) setTab('run')
- }, [streaming, copilotActive])
+ if (streaming || copilotActive) select(null)
+ }, [streaming, copilotActive, select])
const doSave = useCallback(async () => {
if (!workflow) return
@@ -137,14 +133,13 @@ export function StudioPage() {
- {/* Copilot 不再是弹窗:它就在右栏里,这个按钮只负责把人送过去。
- 弹窗的问题是每改一次图都要重开一次,而且关掉之后需求文本就没了 */}
+ {/* Copilot 不再是弹窗:它就在右栏里,这个按钮只负责把焦点送过去。
+ 弹窗的问题是每改一次图都要重开一次,关掉之后需求文本就没了 */}
{
- setTab('run')
- // 等这一帧渲染完输入框才在 DOM 里
+ select(null) // 属性面板盖着的话先让开
requestAnimationFrame(() =>
window.dispatchEvent(new Event('agentlab:focus-copilot')))
}}
@@ -156,9 +151,12 @@ export function StudioPage() {
title={dirty ? '先保存再发布' : '把当前版本立为正式版本,正式运行只认它'}>
发布
-
+
{saving ? : } 保存
+ {/* 运行是对整张图的动作,和保存、发布同类,属于工具栏。放在助手栏里
+ 会和 Copilot 输入框两个"主要动作"互相压着 */}
+
{/* 三栏 */}
@@ -169,21 +167,12 @@ export function StudioPage() {
-
diff --git a/frontend/src/run/AssistantPanel.tsx b/frontend/src/run/AssistantPanel.tsx
index 07f8d6c5..46fc4cf5 100644
--- a/frontend/src/run/AssistantPanel.tsx
+++ b/frontend/src/run/AssistantPanel.tsx
@@ -1,27 +1,24 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
-import { Code2, Send, Settings2, Sparkles, Square, Trash2 } from 'lucide-react'
+import { useMemo, useRef, useState, useEffect } from 'react'
+import { Code2, Eraser } from 'lucide-react'
import clsx from 'clsx'
-import { api } from '../api/client'
-import { useCatalog, modelOptions } from '../store/catalog'
+import { useCatalog } from '../store/catalog'
import { useStudio } from '../store/studio'
-import { Spinner, useToast } from '../components/ui'
-import { AssistantStream, StreamEmpty, type StreamTurn } from './AssistantStream'
+import { Spinner } from '../components/ui'
+import { AssistantStream, type StreamTurn } from './AssistantStream'
+import { Composer } from './Composer'
import { decodeCopilot, decodeRun, formatDuration } from './decode'
-import { ApprovalCard, RunLauncher } from './RunPanel'
+import { ApprovalCard } from './RunPanel'
/**
- * 画布右栏的助手。
+ * 画布右栏:助手。
*
- * 取代原来的「运行 / 属性 → 时间线 / 成果 / 事件」两层嵌套(5 个视图,
- * 每个只装一小块,找什么都得先猜在哪一层)。现在一栏一条流:你让它做什么、
- * 它做了什么、结果是什么,按时间从上往下。
+ * 这一栏现在只有一件事——和 Copilot 说话,以及看它和运行做了什么。属性不再
+ * 和它平级挤在两个 tab 里:选中一个节点会滑出属性面板盖在上面,关掉就回到
+ * 原处。之前是切 tab,而 tab 是互斥的——跑图跑到一半点开一个节点看配置,
+ * 整条执行过程就从眼前消失了,回来还得重新找位置。
*
- * 两件事在这里合流——Copilot 建图和跑图。它们在协议上完全无关(SSE 操作流
- * vs 事件 WebSocket,不共享 seq 和 node_id),但对用户是同一件事的两半:
- * "帮我搭一个能算月度毛利的流程" → "跑一下"。decode.ts 把两种流翻译成同一种
- * Step,这里才能把它们排进同一条时间轴。
- *
- * 右栏不能删:它是发起运行和处理审批的唯一入口,RunLauncher 必须留着。
+ * 两种体量:没说过话时输入框是主体(竖直居中、带例句),有对话之后它退到
+ * 底部,上面让给过程。
*/
export function AssistantPanel() {
const run = useStudio((s) => s.run)
@@ -40,30 +37,28 @@ export function AssistantPanel() {
id: t.id,
question: t.instruction,
phase: t.phase === 'running' ? 'running' : t.phase === 'error' ? 'error' : 'done',
- // 生成期间不能只给一个孤零零的转圈图标。具体阶段从 copilot 实时状态
- // 里取——它只对当前这一轮有效,历史轮次用固定文案
- status: t.phase === 'running'
- ? (copilot.lastOp || PHASE_TEXT[copilot.phase] || '正在生成流程…')
+ // 生成期间不能只给一个孤零零的转圈图标。具体阶段从 copilot 实时状态里取
+ status: t.phase === 'running' ? (copilot.lastOp || '正在生成流程…')
: t.phase === 'done' ? '流程已更新到画布' : '',
steps: decodeCopilot(t.ops),
output: t.explanation ? { 说明: t.explanation } : null,
error: t.error,
}))
if (run) {
- const steps = decodeRun(events)
- // run 对象是启动时的快照,成果在 run.finished 事件里
const finished = [...events].reverse().find((e) => e.type === 'run.finished')
const failedEvent = [...events].reverse().find((e) => e.type === 'run.failed')
const waiting = pending.length > 0
list.push({
id: run.id,
- question: undefined, // 运行的"问题"是输入表单,已经在上面填过了
+ // 运行的"问题"是输入表单,已经在工具栏那边填过了
+ question: undefined,
phase: failedEvent ? 'error'
: waiting ? 'waiting'
: streaming ? 'running'
: finished ? 'done' : 'running',
status: streaming ? '正在执行…' : waiting ? '等待你的确认' : finished ? '完成' : '',
- steps,
+ steps: decodeRun(events),
+ // run 对象是启动时的快照,成果在 run.finished 事件里
output: finished?.data?.output ?? run.output ?? null,
error: failedEvent ? String(failedEvent.data?.error ?? '运行失败') : run.error || undefined,
runId: run.id,
@@ -71,250 +66,97 @@ export function AssistantPanel() {
})
}
return list
- }, [copilotTurns, run, events, streaming, pending.length, copilot.lastOp, copilot.phase])
+ }, [copilotTurns, run, events, streaming, pending.length, copilot.lastOp])
+
+ const empty = !turns.length
return (
-
-
- {pending.map((approval) => (
-
attachRun(runId)} />
- ))}
-
-
- {raw ?
: (
-
}
- />
- )}
-
-
-
-
-
- {run ? (
- <>
- #{run.id.slice(0, 8)}
- {run.run_class === 'formal'
- ? 正式 v{run.version}
- : 探索}
- {streaming && ● 实时}
-
- {!!run.usage?.total_tokens && {run.usage.total_tokens} tok}
- {run.usage?.cost_usd ? ${Number(run.usage.cost_usd).toFixed(4)} : null}
- {run.usage?.duration_ms ? {formatDuration(run.usage.duration_ms)} : null}
- >
- ) : }
- {!!copilotTurns.length && !raw && (
-
-
-
- )}
- {/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
- 翻译层出问题时它是唯一能对照的东西 */}
- setRaw((v) => !v)}
- >
-
-
-
-
- )
-}
-
-function PanelEmpty() {
- const nodes = useStudio((s) => s.nodes)
- return (
-
}
- title={nodes.length ? '还没有动静' : '从一句话开始'}
- hint={nodes.length
- ? '上面填好输入点「试运行」,或者在下面告诉它要改什么'
- : '在下面描述你要的流程,它会直接画到左边的画布上'}
- />
- )
-}
-
-// -------------------------------------------------------------------------
-
-const EXAMPLES = [
- '读取用户上传的问题,先查知识库,查到就基于资料回答并标注出处,查不到就联网搜索',
- '把一段长文本拆成要点,逐条用模型打分,低分的让模型重写一次,最后汇总成表格',
- '写代码分析数据,在沙箱里跑,出错就把报错喂回去让模型修,最多修三次',
-]
-
-/**
- * Copilot 输入框。
- *
- * 原来是个 Modal:点按钮 → 弹窗 → 输入 → 关闭弹窗 → 盯着画布上的浮条看。
- * 每改一次图都要重走一遍,而且弹窗关掉之后需求文本就没了,想微调只能重打。
- * 放进侧栏之后它就是个普通输入框,说完一句接着说下一句。
- */
-function Composer() {
- const nodes = useStudio((s) => s.nodes)
- const copilot = useStudio((s) => s.copilot)
- const { runCopilot, stopCopilot, retryCopilot } = useStudio()
- const providers = useCatalog((s) => s.providers)
- const [text, setText] = useState('')
- const [opts, setOpts] = useState(false)
- const [model, setModel] = useState('')
- const [effective, setEffective] = useState('')
- const [useBase, setUseBase] = useState(true)
- const ref = useRef
(null)
-
- // Copilot 的模型是独立设置:它写的是编排本身,值得和工作流节点分开选
- useEffect(() => {
- void api.copilot.getModel().then((m) => {
- setModel(m.model ?? '')
- setEffective(m.effective_model ?? '')
- }).catch(() => undefined)
- }, [])
-
- // 工具栏的 Copilot 按钮把焦点甩过来
- useEffect(() => {
- const focus = () => ref.current?.focus()
- window.addEventListener('agentlab:focus-copilot', focus)
- return () => window.removeEventListener('agentlab:focus-copilot', focus)
- }, [])
-
- const pickModel = (value: string) => {
- setModel(value)
- void api.copilot.setModel({ model: value || null })
- .then((m) => setEffective(m.effective_model ?? ''))
- .catch(() => undefined)
- }
-
- const send = () => {
- const q = text.trim()
- if (!q || copilot.active) return
- runCopilot(q, useBase && nodes.length > 0, model || undefined)
- setText('')
- }
-
- const options = modelOptions(providers)
- const groups = [...new Set(options.map((o) => o.group))]
-
- return (
-
- {copilot.error && (
-
-
{copilot.error}
- {/* 失败多半跟需求本身无关(模型抽风、协议跑偏、网断了),
- 不该逼用户把需求再敲一遍 */}
- {copilot.lastInstruction && (
-
重试
+ {!empty && (
+
+ 助手
+ {streaming && (
+
+ 执行中
+
)}
-
- )}
-
- {!nodes.length && !copilot.active && (
-
- {EXAMPLES.map((ex) => (
- { setText(ex); ref.current?.focus() }}>
- {ex.slice(0, 16)}…
+
+ {run && (
+
+ #{run.id.slice(0, 6)}
+
+ )}
+ {/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
+ 翻译层出问题时它是唯一能对照的东西 */}
+ setRaw((v) => !v)}
+ >
+
+
+ {!!copilotTurns.length && (
+
+
- ))}
+ )}
)}
- {opts && (
-
-
-
-
-
- 只影响 Copilot 自己,不改节点上的模型。它要按协议逐行输出操作,
- 指令遵循弱的模型生成不出东西。
-
+ {empty ? (
+ // 没说过话时输入框就是这一栏的主体,竖直居中。之前它是个 38px 高的
+ // 框挤在最底下,比上面任何一块都不起眼——"没地方引导写问题"说的就是它
+
+
+
+ ) : (
+ <>
+
+ {raw ?
: (
+
(
+ t.runId && pending.length ? (
+
+ {pending.map((a) => (
+
attachRun(runId)} />
+ ))}
+
+ ) : null
+ )}
+ />
+ )}
- {!!nodes.length && (
-
- )}
-
- )}
-
-
- setOpts((v) => !v)}>
-
-
-
-
- {copilot.active && (
-
-
-
- {copilot.lastOp || PHASE_TEXT[copilot.phase] || '正在起草…'}
-
- {copilot.model && {copilot.model}}
- {copilot.elapsedMs > 0 && (
- {Math.round(copilot.elapsedMs / 1000)}s
- )}
-
- )}
- {!copilot.active && !!copilot.explanation && (
-
- 结果不会自动保存,检查无误记得 ⌘S
-
+
+ {run &&
}
+ >
)}
)
}
-// 从提交到第一个节点落地中间有 5~30 秒。阶段会变本身就是"它还活着"的信号,
-// 恒定的"正在起草…"让人分不清是在想还是已经卡死
-const PHASE_TEXT: Record
= {
- connecting: '正在连接模型…',
- planning: '正在理解需求、规划结构…',
- building: '正在放置节点…',
- wiring: '正在连接数据流…',
- finalizing: '正在排版和校验…',
+function RunFooter() {
+ const run = useStudio((s) => s.run)!
+ return (
+
+ {run.run_class === 'formal'
+ ? 正式 v{run.version}
+ : 探索}
+
+ {!!run.usage?.total_tokens && {run.usage.total_tokens} tok}
+ {run.usage?.cost_usd ? ${Number(run.usage.cost_usd).toFixed(4)} : null}
+ {run.usage?.duration_ms ? {formatDuration(run.usage.duration_ms)} : null}
+
+ )
}
-// -------------------------------------------------------------------------
-
/** 原始事件。翻译层出问题时用来对照,平时收着。 */
function RawEvents() {
const events = useStudio((s) => s.events)
diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx
index bde618fe..d5b656b8 100644
--- a/frontend/src/run/AssistantStream.tsx
+++ b/frontend/src/run/AssistantStream.tsx
@@ -189,8 +189,11 @@ function StepRow({ step, dense, depth }: { step: Step; dense: boolean; depth: nu
// 失败要压过 kind:一条 lifecycle 跑挂了还画成打勾的"完成"图标,
// 是把失败说成了成功
const failed = step.status === 'failed' || step.level === 'error'
- const Glyph = failed ? XCircle : Icon
+ // "等你确认"不是"正在忙"。转圈说的是"你等着",而这里正相反
+ const waiting = step.status === 'waiting'
+ const Glyph = failed ? XCircle : waiting ? Hand : Icon
const color = failed ? 'var(--err)'
+ : waiting ? 'var(--warn)'
: step.level === 'warn' ? 'var(--warn)'
: step.kind === 'think' ? 'var(--text-faint)'
: step.status === 'running' ? 'var(--accent)'
@@ -214,7 +217,9 @@ function StepRow({ step, dense, depth }: { step: Step; dense: boolean; depth: nu
+ step.kind === 'think' && 'italic',
+ // 一道光扫过文字。比转圈多说一件事——它在出东西,不只是在等
+ step.status === 'running' && !step.children?.length && 'shimmer')}>
{step.title}
{step.meta && (
diff --git a/frontend/src/run/Composer.tsx b/frontend/src/run/Composer.tsx
new file mode 100644
index 00000000..479c52a7
--- /dev/null
+++ b/frontend/src/run/Composer.tsx
@@ -0,0 +1,287 @@
+import { useEffect, useRef, useState } from 'react'
+import { ArrowUp, Database, Settings2, Sparkles, Square, Wrench } from 'lucide-react'
+import clsx from 'clsx'
+import { api } from '../api/client'
+import { useCatalog, modelOptions } from '../store/catalog'
+import { useStudio } from '../store/studio'
+import { Spinner } from '../components/ui'
+
+/**
+ * Copilot 的输入。
+ *
+ * 一个组件两种体量,而不是两个组件:
+ *
+ * - **hero**:还没说过话的时候,它就是这一栏的主体,竖直居中、三行高、自动
+ * 聚焦,配上例句。这时候"写点什么"是唯一该做的事,输入框就该是最大的东西。
+ * 之前它是一个 38px 高的框挤在长长的流底下,比它上面任何一块都不起眼——
+ * 用户说"没有一个很好的地方引导写问题",说的就是这个。
+ * - **dock**:一旦有了对话,注意力转移到上面的过程上,它退到底部两行。
+ *
+ * 不做成两个组件的原因和 AssistantStream 一样:两个组件就是两个实例,切换时
+ * React 会卸载其中一个,草稿、光标、输入法编辑中的候选词全没。
+ */
+export function Composer({ hero, onFocusChange }: {
+ hero: boolean
+ onFocusChange?: (focused: boolean) => void
+}) {
+ const nodes = useStudio((s) => s.nodes)
+ const copilot = useStudio((s) => s.copilot)
+ const { runCopilot, stopCopilot, retryCopilot } = useStudio()
+ const providers = useCatalog((s) => s.providers)
+ const tools = useCatalog((s) => s.tools)
+ const [text, setText] = useState('')
+ const [opts, setOpts] = useState(false)
+ const [focused, setFocused] = useState(false)
+ const [model, setModel] = useState('')
+ const [effective, setEffective] = useState('')
+ const [useBase, setUseBase] = useState(true)
+ const [sources, setSources] = useState([])
+ const ref = useRef(null)
+
+ useEffect(() => {
+ void api.copilot.getModel().then((m) => {
+ setModel(m.model ?? '')
+ setEffective(m.effective_model ?? '')
+ }).catch(() => undefined)
+ void api.datasources.list().then(setSources).catch(() => undefined)
+ }, [])
+
+ // 工具栏的 Copilot 按钮把焦点甩过来
+ useEffect(() => {
+ const focus = () => ref.current?.focus()
+ window.addEventListener('agentlab:focus-copilot', focus)
+ return () => window.removeEventListener('agentlab:focus-copilot', focus)
+ }, [])
+
+ // hero 态就是"请开始",不该还要求用户先点一下
+ useEffect(() => {
+ if (hero) ref.current?.focus()
+ }, [hero])
+
+ const pickModel = (value: string) => {
+ setModel(value)
+ void api.copilot.setModel({ model: value || null })
+ .then((m) => setEffective(m.effective_model ?? ''))
+ .catch(() => undefined)
+ }
+
+ const send = (override?: string) => {
+ const q = (override ?? text).trim()
+ if (!q || copilot.active) return
+ runCopilot(q, useBase && nodes.length > 0, model || undefined)
+ setText('')
+ }
+
+ const options = modelOptions(providers)
+ const groups = [...new Set(options.map((o) => o.group))]
+ const examples = hero ? exampleFor(nodes.length, sources) : []
+
+ return (
+
+ {hero &&
}
+
+ {copilot.error && (
+
+ {copilot.error}
+ {/* 失败多半跟需求本身无关(模型抽风、协议跑偏、网断了),
+ 不该逼用户把需求再敲一遍 */}
+ {copilot.lastInstruction && (
+ 重试
+ )}
+
+ )}
+
+ {/* 输入坞。整块是一个框,输入区和操作条在里面——比"输入框 + 旁边三个按钮"
+ 更像一个可以对它说话的地方 */}
+
+
+ {opts && (
+
+
+
+
+
+ 只影响 Copilot 自己,不改节点上的模型。它要按协议逐行输出操作,
+ 指令遵循弱的模型生成不出东西。
+
+
+
+ )}
+
+ {copilot.active && (
+
+
+
+ {copilot.lastOp || PHASE_TEXT[copilot.phase] || '正在起草…'}
+
+ {copilot.model && {copilot.model}}
+ {copilot.elapsedMs > 0 && (
+ {Math.round(copilot.elapsedMs / 1000)}s
+ )}
+
+ )}
+
+ {!!examples.length && (
+
+ {examples.map((ex, i) => (
+ send(ex)}
+ >
+ {ex}
+
+ ))}
+
+ )}
+
+ )
+}
+
+/**
+ * hero 态的抬头。
+ *
+ * 不只是一句标语:它要回答"我能让它干什么"。Copilot 能接到的数据源和工具
+ * 数量摆在这里,用户才知道"查销售库"这种话是有意义的——否则只能猜。
+ */
+function HeroHeader({ sources, toolCount }: { sources: any[]; toolCount: number }) {
+ return (
+
+
+
+
+
想让它做什么?
+
+ 说一句话,它把流程画到左边的画布上
+
+
+
+
+ {sources.length ? `${sources.length} 个数据源` : '未接数据源'}
+
+ {toolCount} 个工具
+
+
+ )
+}
+
+/**
+ * 例句。
+ *
+ * 用完整句子而不是截断成 16 个字的 chip——例句的作用是示范"说到什么颗粒度
+ * 才够",截断之后这个作用就没了,只剩装饰。
+ *
+ * 有数据源就用真实的库名造句:用户一眼看到的是自己的数据,而不是别人的示例。
+ */
+function exampleFor(nodeCount: number, sources: any[]): string[] {
+ if (nodeCount) {
+ return [
+ '在最后加一步人工审核,通过了才输出',
+ '中间那步改成 agent,让它自己决定要不要查数据库',
+ '出错的时候重试两次,还不行就走另一条分支',
+ ]
+ }
+ const name = sources[0]?.name
+ return [
+ name
+ ? `从 ${name} 里取上季度的数据,算出同比,写一段中文分析`
+ : '读取用户的问题,先查知识库,查到就基于资料回答并标注出处,查不到就联网搜索',
+ '把一段长文本拆成要点,逐条用模型打分,低分的让模型重写一次,最后汇总成表格',
+ '写代码分析数据,在沙箱里跑,出错就把报错喂回去让模型修,最多修三次',
+ ]
+}
+
+// 从提交到第一个节点落地中间有 5~30 秒。阶段会变本身就是"它还活着"的信号,
+// 恒定的"正在起草…"让人分不清是在想还是已经卡死
+export const PHASE_TEXT: Record = {
+ connecting: '正在连接模型…',
+ planning: '正在理解需求、规划结构…',
+ building: '正在放置节点…',
+ wiring: '正在连接数据流…',
+ finalizing: '正在排版和校验…',
+}
diff --git a/frontend/src/run/RunControl.tsx b/frontend/src/run/RunControl.tsx
new file mode 100644
index 00000000..b338db09
--- /dev/null
+++ b/frontend/src/run/RunControl.tsx
@@ -0,0 +1,172 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { ChevronDown, Play, ShieldCheck, Square } from 'lucide-react'
+import { useStudio } from '../store/studio'
+import { Spinner, useToast } from '../components/ui'
+
+/**
+ * 工具栏上的运行控件。
+ *
+ * 从右栏顶部搬上来的。原先它和 Copilot 输入框一起常驻在助手栏里,两个都是
+ * "主要动作",互相压着——而它们其实是两种不同的意图(改这张图 / 跑这张图),
+ * 不该争同一块地方。运行是对整个文档的动作,和保存、发布是一类,属于工具栏。
+ *
+ * 有输入字段就弹出表单填了再跑;没有字段就一键直接跑,不为了统一而多一次点击。
+ */
+export function RunControl() {
+ const nodes = useStudio((s) => s.nodes)
+ const run = useStudio((s) => s.run)
+ const streaming = useStudio((s) => s.streaming)
+ const issues = useStudio((s) => s.issues)
+ const workflow = useStudio((s) => s.workflow)
+ const dirty = useStudio((s) => s.dirty)
+ const { startRun, startFormalRun, stopRun } = useStudio()
+ const toast = useToast()
+ const [open, setOpen] = useState(false)
+ const [values, setValues] = useState>({})
+ const [busy, setBusy] = useState(false)
+ const wrap = useRef(null)
+
+ const inputFields = useMemo(() => {
+ const entry = nodes.find((n) => n.data.nodeType === 'input')
+ return (entry?.data.config?.fields ?? []) as any[]
+ }, [nodes])
+
+ const errors = issues.filter((i) => i.level === 'error')
+ const canFormal = !!workflow
+ && (workflow.status === 'published' || workflow.status === 'governed')
+ && !!workflow.published_version
+ const running = streaming || run?.status === 'running'
+
+ // 点外面关掉。浮层不能只靠再点一次按钮关——那是很容易漏掉的死路
+ useEffect(() => {
+ if (!open) return
+ const onDown = (e: MouseEvent) => {
+ if (!wrap.current?.contains(e.target as Node)) setOpen(false)
+ }
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
+ window.addEventListener('mousedown', onDown)
+ window.addEventListener('keydown', onKey)
+ return () => {
+ window.removeEventListener('mousedown', onDown)
+ window.removeEventListener('keydown', onKey)
+ }
+ }, [open])
+
+ const launch = async (formal = false) => {
+ setBusy(true)
+ try {
+ const payload: Record = {}
+ for (const field of inputFields) {
+ if (!field.name) continue
+ const raw = values[field.name] ?? field.default ?? ''
+ // 看起来像 JSON 就按 JSON 传,让数组/对象类型的输入能用
+ if (typeof raw === 'string' && /^\s*[[{]/.test(raw)) {
+ try { payload[field.name] = JSON.parse(raw) } catch { payload[field.name] = raw }
+ } else {
+ payload[field.name] = raw
+ }
+ }
+ if (formal) await startFormalRun(payload)
+ else await startRun(payload)
+ setOpen(false)
+ } catch (e: any) {
+ toast(e.message ?? '启动失败', 'error')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (running) {
+ return (
+
+ 停止
+
+ )
+ }
+
+ const blocked = !!errors.length || !nodes.length
+
+ return (
+
+
{
+ // 没有输入字段就没什么可填的,多弹一层只是多一次点击
+ if (!inputFields.length && !canFormal) void launch(false)
+ else setOpen((v) => !v)
+ }}
+ >
+ {busy ? : }
+ 运行
+ {(inputFields.length > 0 || canFormal) && (
+
+ )}
+
+
+ {open && (
+
+ {inputFields.map((field) => (
+
+
+
+ ))}
+ {!inputFields.length && (
+
这张图没有输入节点,将直接运行
+ )}
+
+
+
launch(false)}>
+ 试运行
+
+ {canFormal && (
+
launch(true)}
+ >
+ 正式 v{workflow?.published_version}
+
+ )}
+
+ {canFormal && dirty && (
+
+ 画布有未保存改动;正式运行永远执行已发布的不可变版本
+
+ )}
+ {!!inputFields.length && (
+
⌘⏎ 直接运行
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/run/RunPanel.tsx b/frontend/src/run/RunPanel.tsx
index b322d94e..a7474609 100644
--- a/frontend/src/run/RunPanel.tsx
+++ b/frontend/src/run/RunPanel.tsx
@@ -1,152 +1,15 @@
-import { useEffect, useMemo, useState } from 'react'
-import {
- Ban, Check, ChevronRight, Hand, Play, ShieldCheck, Square, Wrench,
-} from 'lucide-react'
+import { useState } from 'react'
+import { Ban, Check, Hand, Wrench } from 'lucide-react'
import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { useCatalog } from '../store/catalog'
import { NODE_DEFS } from '../canvas/nodeDefs'
import { StatusDot, useToast } from '../components/ui'
-/**
- * 发起运行。
- *
- * 这是整个界面里**唯一**能发起运行的地方,不能因为改版就顺手删掉——一起没掉的
- * 还有审批入口,那会让卡在人工介入上的运行在界面里彻底没有出口。
- *
- * 跑起来之后自动收起:这时候用户要看的是执行到哪了,不是再填一遍输入。
- */
-export function RunLauncher() {
- const nodes = useStudio((s) => s.nodes)
- const run = useStudio((s) => s.run)
- const streaming = useStudio((s) => s.streaming)
- const issues = useStudio((s) => s.issues)
- const workflow = useStudio((s) => s.workflow)
- const dirty = useStudio((s) => s.dirty)
- const { startRun, startFormalRun, stopRun } = useStudio()
- const canFormal = !!workflow && (workflow.status === 'published' || workflow.status === 'governed')
- && !!workflow.published_version
- const toast = useToast()
- const [values, setValues] = useState>({})
- const [busy, setBusy] = useState(false)
- const [open, setOpen] = useState(true)
-
- // 入口节点声明了什么字段,这里就渲染什么表单
- const inputFields = useMemo(() => {
- const entry = nodes.find((n) => n.data.nodeType === 'input')
- return (entry?.data.config?.fields ?? []) as any[]
- }, [nodes])
-
- // 跑起来就让位给执行过程;停下来再自己展开
- useEffect(() => { if (streaming) setOpen(false) }, [streaming])
-
- const errors = issues.filter((i) => i.level === 'error')
-
- const launch = async (formal = false) => {
- setBusy(true)
- try {
- const payload: Record = {}
- for (const field of inputFields) {
- if (!field.name) continue
- const raw = values[field.name] ?? field.default ?? ''
- // 看起来像 JSON 就按 JSON 传,让数组/对象类型的输入能用
- if (typeof raw === 'string' && /^\s*[[{]/.test(raw)) {
- try { payload[field.name] = JSON.parse(raw) } catch { payload[field.name] = raw }
- } else {
- payload[field.name] = raw
- }
- }
- if (formal) await startFormalRun(payload)
- else await startRun(payload)
- } catch (e: any) {
- toast(e.message ?? '启动失败', 'error')
- } finally {
- setBusy(false)
- }
- }
-
- const running = streaming || run?.status === 'running'
-
- return (
-
- {!!inputFields.length && (
-
setOpen((v) => !v)}>
-
- 输入({inputFields.length} 项)
- {!open && (
-
- {inputFields.map((f) => values[f.name] ?? f.default ?? '').filter(Boolean).join(' · ')}
-
- )}
-
- )}
-
- {open && inputFields.map((field) => (
-
-
-
- ))}
- {!inputFields.length && !!nodes.length && (
-
这张图没有输入节点,将直接运行
- )}
-
- {!!errors.length && (
-
- {errors.length} 个问题会阻止运行:{errors[0].message}
-
- )}
-
-
- {running ? (
-
- 停止
-
- ) : (
- <>
-
launch(false)}
- disabled={busy || !!errors.length || !nodes.length}
- title="用画布当前内容跑,结果标探索性"
- >
- 试运行
-
- {canFormal && (
-
launch(true)}
- disabled={busy || dirty}
- title={dirty ? '有未保存改动,正式运行只跑已发布版本' : `从已发布的 v${workflow?.published_version} 不可变版本发起`}
- >
- 正式运行 v{workflow?.published_version}
-
- )}
- >
- )}
-
- {canFormal && dirty && (
-
画布有未保存改动;正式运行永远执行已发布的不可变版本
- )}
-
- )
-}
-
-// -------------------------------------------------------------------------
+// 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在
+// 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的
+// 意图(跑这张图 / 改这张图),不该争同一块地方。运行是对整张图的动作,
+// 和保存、发布同类,属于工具栏。
/**
* 人工介入卡片。
diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts
index f5885fb1..be168565 100644
--- a/frontend/src/run/decode.ts
+++ b/frontend/src/run/decode.ts
@@ -315,24 +315,34 @@ export function decodeRun(events: RunEvent[]): Step[] {
return true
}
+ /** 上一条就是恢复事件——用来认出紧随其后的那条重复的"继续执行" */
+ let justResumed = false
+
for (const event of events) {
const type = String(event.type)
if (EPHEMERAL.has(type) || SILENT.has(type)) continue
const d: any = event.data ?? {}
const seq = event.seq ?? 0
const nodeId = event.node_id ?? undefined
+ // 每条事件都会把"刚恢复过"清掉;只有恢复分支会重新点亮它,
+ // 所以这个标记只在紧挨着的下一条上为真
+ const wasJustResumed = justResumed
+ justResumed = false
switch (type) {
case 'run.started':
case 'run.resumed': {
const resumed = type === 'run.resumed' || !!d.resumed
- // 恢复一次会同时来 run.resumed 和 run.started(resumed=true),
- // 说两遍"继续执行"只会让人以为恢复了两次
- if (resumed && out.some((s) => s.kind === 'lifecycle' && s.title === '继续执行')
- && !out.some((s) => s.status === 'waiting')) {
- break
- }
+ // 恢复一次会紧挨着发两条:run.resumed 和 run.started(resumed=true)。
+ // 说两遍"继续执行"会让人以为恢复了两次。
+ //
+ // 判据是"这两条是不是紧挨着的",不是"界面上还有没有待办"——后者
+ // 曾经能用,但只要多一处会产生 waiting 状态的地方(比如把被中断的
+ // 节点也标成等待),它就失效了。相邻性是这两条事件的固有性质,
+ // 不会因为别处改了展示而变。
+ if (resumed && wasJustResumed) break
if (resumed) {
+ justResumed = true
// 人已经答过了,那条不该再是橙色的"等你确认"。但配对关系要留着:
// 紧接着 LangGraph 会重放该节点,又发一条一模一样的
// human.requested,配对还开着才能认出那是重放而不是新一轮。
@@ -349,6 +359,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
}
case 'node.started': {
+ // 恢复后节点重放,它从"等你"回到"在跑"(下面会复用同一条 Step)
// data.label 是节点标题**随事件传递的唯一来源**。靠画布 nodes 反查的话,
// 助手栏和运行页没加载画布,标题会退化成裸 node_id。
// label 是节点标题随事件传递的唯一来源,但没起过名字的节点它等于
@@ -537,6 +548,10 @@ export function decodeRun(events: RunEvent[]): Step[] {
}
openInterrupts.set(key, step)
push(step, nodeId)
+ // 承载它的那个节点也不是"在跑"——它停下来等人了。转着蓝圈说的是
+ // "在忙,你等着",而实际情况正相反:它在等你
+ const host = nodeId ? nodeSteps.get(nodeId) : undefined
+ if (host && host.status === 'running') host.status = 'waiting'
break
}
diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs
index 26a87632..a2d8a1d6 100644
--- a/scripts/check-decode.mjs
+++ b/scripts/check-decode.mjs
@@ -92,6 +92,26 @@ console.log('\n=== 人工审批(含重放)===')
!all.some((s) => s.status === 'done' && s.level === 'warn'))
}
+console.log('\n=== 停在审批时的状态 ===')
+{
+ // 只喂到中断为止,模拟"运行正卡在人工介入上"这一刻
+ const upto = fixtures.human.slice(0, fixtures.human.findIndex((e) => e.type === 'run.interrupted') + 1)
+ const all = flatten(mod.decodeRun(upto))
+ const host = all.find((s) => s.kind === 'node' && s.nodeId === 'h')
+
+ // 转圈说的是"它在忙,你等着",而实际情况正相反:它在等你。
+ // 两种状态的行动含义完全相反,不能共用一个图标
+ check('被中断的节点是"等你"而不是"在跑"', host?.status === 'waiting', host?.status)
+ check('那一刻没有任何东西在转圈',
+ !all.some((s) => s.kind === 'node' && s.status === 'running'),
+ all.filter((s) => s.status === 'running').map((s) => s.title).join(','))
+
+ // 恢复之后它得回到"在跑",否则整条流永远显示成在等人
+ const after = mod.decodeRun(fixtures.human)
+ const doneHost = flatten(after).find((s) => s.kind === 'node' && s.nodeId === 'h')
+ check('恢复后回到正常状态', doneHost?.status === 'done', doneHost?.status)
+}
+
console.log('\n=== 循环里的多轮审批 ===')
{
// 真实运行:驳回 → 改写 → 再驳回(带备注)→ 改写 → 放行。三轮,每轮都有
diff --git a/scripts/check-ui.mjs b/scripts/check-ui.mjs
index 74a67d4b..db1f90ab 100644
--- a/scripts/check-ui.mjs
+++ b/scripts/check-ui.mjs
@@ -42,25 +42,38 @@ console.log('=== 画布助手栏 ===')
const { page, errors } = await visit('/studio')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
- const tabs = await page.locator('nav, [role=tablist], button').allInnerTexts()
- const flat = tabs.join(' ')
- check('右栏是「助手 / 属性」两个 tab', flat.includes('助手') && flat.includes('属性'))
- check('不再有「时间线 / 成果 / 事件」的第二层嵌套',
- !flat.includes('时间线') && !flat.includes('原始事件'))
+ const body = await page.locator('body').innerText()
+ check('不再有两层 tab 嵌套',
+ !body.includes('时间线') && !body.includes('原始事件'))
- // 改版最容易顺手删掉的两个东西
- check('发起运行的入口还在', await page.getByText('试运行').count() > 0)
- check('Copilot 输入框就在栏里(不再是弹窗)',
- await page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').count() > 0)
+ // 用户的原话:"没有一个很好的地方引导用户写问题"。空态下输入区就该是主体
+ check('空态给出了明确的邀请', body.includes('想让它做什么'))
+ check('说清楚它能够到什么', /\d+ 个工具/.test(body), body.match(/\d+ 个工具/)?.[0])
+ const examples = await page.locator('aside button').filter({ hasText: /。|,/ }).count()
+ check('例句是完整句子而不是截断的 chip', examples >= 2, `${examples} 条`)
- // 切到属性再切回来,输入框里的草稿不该没
- const composer = page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').first()
+ const composer = page.locator('aside textarea').first()
+ check('输入框自动聚焦,不用先点一下',
+ await composer.evaluate((el) => el === document.activeElement))
+
+ // 改版最容易顺手删掉的东西:唯一的运行入口
+ check('发起运行的入口还在(已搬到工具栏)',
+ await page.getByRole('button', { name: /^运行/ }).count() > 0)
+
+ // 这是这次重构的核心承诺:属性是盖在助手上的一层,不是把它换掉。
+ // 切 tab 会卸载组件,草稿、滚动位置、展开状态全丢——那正是要修的问题
await composer.fill('测试草稿不要丢')
- await page.getByRole('button', { name: '属性' }).click()
- await page.waitForTimeout(200)
- await page.getByRole('button', { name: '助手' }).click()
- await page.waitForTimeout(200)
- check('切走再切回来,输入的字还在', await composer.inputValue() === '测试草稿不要丢',
+ await page.locator('.react-flow__node').first().click()
+ await page.waitForTimeout(350)
+ const sheet = await page.locator('body').innerText()
+ check('选中节点滑出属性面板', sheet.includes('节点名称') || sheet.includes('ID:'))
+ check('面板上有明确的返回,让人知道底下还有东西',
+ await page.getByTitle('返回助手(Esc)').count() > 0)
+
+ await page.keyboard.press('Escape')
+ await page.waitForTimeout(350)
+ check('Esc 关得掉', await page.getByTitle('返回助手(Esc)').count() === 0)
+ check('回来之后输入的字还在', await composer.inputValue() === '测试草稿不要丢',
await composer.inputValue())
await composer.fill('')
@@ -71,6 +84,26 @@ console.log('=== 画布助手栏 ===')
await page.close()
}
+console.log('\n=== 动效对前庭敏感者可关 ===')
+{
+ // 这套界面里动的东西不少(边在流动、节点在脉冲、面板在滑)。
+ // 系统里关了动效还照播,对前庭敏感的人是实打实的难受
+ const page = await ctx.newPage()
+ await page.emulateMedia({ reducedMotion: 'reduce' })
+ await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
+ await page.waitForTimeout(500)
+ const durations = await page.evaluate(() => {
+ const probe = document.createElement('div')
+ probe.className = 'rise-in'
+ document.body.appendChild(probe)
+ const d = getComputedStyle(probe).animationDuration
+ probe.remove()
+ return d
+ })
+ check('关掉动效后动画确实停了', parseFloat(durations) < 0.01, durations)
+ await page.close()
+}
+
console.log('\n=== 运行历史 ===')
{
const { page, errors } = await visit('/runs')