fix(workspace): 创作工作台 - 修复线上样式层级并优化 Agent、菜单与素材密度

This commit is contained in:
ddCat
2026-09-20 05:29:52 +08:00
parent b9d60f9807
commit cfcc53a904
21 changed files with 671 additions and 110 deletions

View File

@@ -5,6 +5,33 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 工作台菜单表面与素材显示密度
- [x] 根据焦点样式反馈,内容库筛选下拉取消鼠标 / 触屏点击后的粗外圈,聚焦和展开改用柔和底色;工具栏区分指针与键盘输入,键盘聚焦保留 1px 细轮廓,不主动移除焦点。离开工具栏后恢复键盘默认状态,相关复用筛选入口同步生效。本轮 12 项专项测试及类型检查通过;指针 / 键盘切换仍需真实页面验收。
- [x] 顶部和侧栏账户菜单共用无描边、无投影表面,去掉箭头、装饰分隔线和积分卡渐变;清理旧版 `ant-popover-inner` 覆盖,使用当前组件的 `ant-popover-container`。
- [x] 用户工作台 Select、Dropdown、Popover 通过显式组件类共用柔和表面和中性选中态;内容库工具栏的 Select 统一使用 filled 变体,保留禁用、危险操作和键盘焦点状态。后台、画布编辑器的通用浮层以及 Modal / 通知不纳入本次修改。
- [x] 修复素材密度状态被固定 224px 网格覆盖的问题:舒适 / 标准 / 紧凑分别采用 272 / 208 / 160px 卡片最小宽度,随容器自动换列,窄容器不会超宽;保持原有本地偏好值。密度入口补充无障碍名称,网格图标前置,恢复下拉箭头。
- [x] 13 项专项测试通过,覆盖偏好恢复、密度尺寸策略、真实工具栏静态渲染与浮层配置、主题 token、样式边界及 CSS 层顺序;前端类型检查、生产构建和 `git diff --check` 通过。构建仍提示部分 chunk 超过 500 kB,未在本次进行无关拆包。
- [ ] 本地真实 `/assets` 路由跳转登录页,尚未完成浏览器视觉与交互验收:登录后在明暗主题检查两个账户入口和列表菜单;切换三档密度、刷新恢复偏好,检查窄屏、长文案、禁用态和键盘导航。静态渲染及尺寸策略测试不等同于浏览器验收;未部署线上。
## Agent 磨砂面板与紧凑操作区
- [x] 根据视觉反馈增强磨砂:外壳底色混合比例由 88% 降至 68%,背景模糊调为 32px、饱和度调为 145%,加入轻微柔光而不增加描边;保留减少透明度偏好的不透明降级。模型选择框由 144px 加宽至 208px,保留最大宽度限制、操作区换行和完整名称悬停提示。
- [x] 真实 Agent 组件改为磨砂外壳、无分隔线头部、柔和消息与工具表面;新增空态技能入口和只填草稿的创作建议,保留审批与错误状态。
- [x] 执行权限入口只显示当前模式图标,点击打开原权限选择菜单;只读、请求审批、自动执行使用不同图标,悬停提示和无障碍名称保留当前权限。Skills 入口缩短为 `Skills(x)`。
- [x] 左上缩放手柄向面板内部偏移并改为对角缩放图标,保留拖动和方向键调整;欢迎语从品牌配置读取名称,显示「在这里,和{品牌名称}让灵感,慢慢成形」。
- [x] 发送按钮从 36px 缩小为 28px、箭头从 16px 缩小为 14px,去掉发光阴影;停止按钮同步缩小。发送、空草稿禁用、发送中禁用逻辑不变。
- [x] 27 项专项测试通过,覆盖空态品牌文本转义、空画布入口、三种权限图标、发送禁用状态、面板尺寸算法、工具消息和 CSS 层顺序;前端类型检查通过。
- [ ] 在已登录的真实画布复验明暗主题、窄屏及长品牌名称换行;确认左上缩放图标与键盘焦点不再被圆角裁切、权限菜单和 Skills 入口可用、发送按钮大小合适。本次本地浏览器停留在登录页,未完成这些视觉/交互验收;未调用生成任务或部署线上。临时预览网络拦截已撤销。
## 生产构建 CSS 层顺序
- [x] 本地产物确认:应用动态加载的依赖列表中,内置编辑器 CSS 先于全局 CSS;编辑器先声明 `utilities` 层,会使随后出现的 `base` reset 优先于工具类,影响画布和 Agent 等区域的间距、边框与布局。
- [x] HTML 最早内联样式固定 `theme, base, components, utilities` 顺序,不依赖生产拆包的 CSS 加载顺序;未调整画布业务逻辑或逐个组件添加覆盖。
- [x] 两项层顺序回归和一项相邻滚动条回归通过;前端类型检查与生产构建通过,并确认构建后的 HTML 在外部样式和脚本之前保留层顺序声明。
- [x] 登录线上真实画布确认同样的 CSS 加载顺序:Agent 内容区 `px-5 py-5` 计算内边距为 `0px`。仅在调试标签页头部临时声明层顺序后,内容区恢复 `20px`,头部恢复水平 `16px`,输入区外层恢复 `8px 12px 12px`;截图确认留白恢复。撤掉临时声明后内容区再次变为 `0px`,验证因果关系。未修改服务器、提交生成/审批或编辑画布内容,临时样式已移除。
- [ ] 发布后复验画布与 Agent 的消息内边距、待办间距、输入区和工具栏,覆盖冷启动直达、站内导航、明暗主题。本次浏览器验证为暗色空对话面板,未覆盖原截图的历史审批会话;线上尚未部署修复。
## 画布素材链路与工作条改造(2026-09-19)
已实现:

View File

@@ -7,6 +7,9 @@
<link rel="icon" href="/logo.svg" type="image/svg+xml" />
<title>正在加载</title>
<style>
/* Fix layer order before production chunks can register utilities ahead of base. */
@layer theme, base, components, utilities;
html,
body {
margin: 0;

View File

@@ -0,0 +1,47 @@
import { ArrowUpRight, Clapperboard, Layers3, Sparkles } from "lucide-react";
type AgentWelcomeProps = {
brandName: string;
nodeCount: number;
onChooseSkill: () => void;
onDraftPrompt: (prompt: string) => void;
};
export function AgentWelcome({ brandName, nodeCount, onChooseSkill, onDraftPrompt }: AgentWelcomeProps) {
return (
<section className="agent-welcome" aria-label="开始 Agent 创作">
<div className="agent-welcome-intro">
<span className="agent-welcome-orb" aria-hidden="true" />
<h2>在这里,和{brandName}让灵感,慢慢成形</h2>
<p>从一个想法开始,和 Agent 一起创作。</p>
</div>
<div className="agent-welcome-actions">
<button type="button" onClick={onChooseSkill}>
<Sparkles aria-hidden="true" />
<span>
<strong>选择技能,开始创作</strong>
<small>为这次创作找到合适的帮手</small>
</span>
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
</button>
<button type="button" onClick={() => onDraftPrompt("我想创作一段短片,请先和我一起梳理故事方向。先询问我的想法,不要直接生成。")}>
<Clapperboard aria-hidden="true" />
<span>
<strong>从灵感构思故事</strong>
<small>聊聊主题、角色,或一个难忘的画面</small>
</span>
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
</button>
<button type="button" disabled={nodeCount === 0} onClick={() => onDraftPrompt("请先阅读当前画布,梳理素材与节点之间的关系,给出接下来的创作建议。先不要修改节点或提交生成任务。")}>
<Layers3 aria-hidden="true" />
<span>
<strong>一起梳理当前画布</strong>
<small>{nodeCount > 0 ? `${nodeCount} 个节点,看看下一步可以做什么` : "添加节点后,一起梳理创作思路"}</small>
</span>
<ArrowUpRight className="agent-welcome-arrow" aria-hidden="true" />
</button>
</div>
<p className="agent-welcome-footnote">先聊想法,再决定下一步</p>
</section>
);
}

View File

@@ -163,7 +163,7 @@ export function AgentChatMessage({
return (
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentTimelineMarker theme={theme} tone="agent" /> : null}
<div className={`min-w-0 text-sm leading-6 ${isUser ? "max-w-[82%] rounded-2xl rounded-br-md px-3.5 py-2.5 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color, ...(isUser ? { background: theme.node.agentUserMessage } : {}) }}>
<div className={`agent-message-body min-w-0 text-sm leading-6 ${isUser ? "agent-message-user max-w-[82%] px-4 py-3 text-right" : "max-w-[calc(100%-36px)] flex-1 text-left"}`} style={{ color }}>
{item.interjection ? (
<span
className="mb-1 inline-flex items-center rounded-full px-1.5 py-[1px] text-[var(--fs-label)] leading-4"
@@ -370,7 +370,7 @@ export function AgentPlanBar({ items, theme, minimized, onToggle }: {
const doneCount = items.filter((entry) => entry.status === "done").length;
const allDone = doneCount === items.length;
return (
<div className="agent-plan-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ background: theme.node.fill, border: `1px solid ${theme.node.stroke}`, color: theme.node.text }}>
<div className="agent-plan-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
<button type="button" className="flex w-full items-center gap-2 px-3 py-2 text-left focus-visible:outline focus-visible:outline-2" aria-expanded={!minimized} onClick={onToggle}>
<ListChecks className="size-3.5 shrink-0" style={{ color: allDone ? "#429477" : theme.node.muted }} />
<span className="text-xs font-semibold">本轮待办</span>
@@ -405,7 +405,7 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
disabled?: boolean;
}) {
return (
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ background: theme.node.fill, border: `1px solid ${theme.accent.primary}`, color: theme.node.text }}>
<div className="agent-question-bar mx-3 mb-2 overflow-hidden rounded-xl" style={{ color: theme.node.text }}>
<div className="flex items-start gap-2 px-3 pt-2.5">
<HelpCircle className="mt-[1px] size-3.5 shrink-0" style={{ color: theme.accent.primary }} />
<span className="min-w-0 flex-1 text-xs font-semibold leading-5">{question.question}</span>
@@ -417,8 +417,8 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false }
type="button"
disabled={disabled}
title={option.detail || option.label}
className="max-w-full rounded-md border px-3 py-1.5 text-left text-xs transition focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ borderColor: theme.node.stroke, background: theme.toolbar.itemHover }}
className="max-w-full rounded-md border-0 px-3 py-1.5 text-left text-xs transition focus-visible:outline focus-visible:outline-2 disabled:cursor-not-allowed disabled:opacity-50"
style={{ background: theme.toolbar.itemHover }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
@@ -600,13 +600,11 @@ export function AgentChatComposer({
};
return (
<div className="min-w-0 shrink-0 px-3 pb-3 pt-2" onWheelCapture={(event) => event.stopPropagation()}>
<div className="agent-composer-wrap min-w-0 shrink-0" onWheelCapture={(event) => event.stopPropagation()}>
<div
className="group/composer relative rounded-2xl px-3 pb-2.5 pt-3 transition-[background-color,box-shadow] duration-200"
className="agent-composer-surface group/composer relative transition-[background-color,box-shadow] duration-200"
style={{
background: theme.node.fill,
color: theme.accent.primary,
boxShadow: `0 16px 40px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,0.045)`,
}}
>
{sending && !reducedMotion ? <WorkingGlow active color={theme.accent.primary} radius={22} /> : null}
@@ -757,8 +755,8 @@ export function AgentChatComposer({
whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }}
transition={{ type: "spring", stiffness: 420, damping: 24 }}
className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{ background: theme.accent.danger, color: theme.accent.onPrimary, boxShadow: `0 8px 20px ${theme.accent.danger}45` }}
className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{ background: theme.accent.danger, color: theme.accent.onPrimary }}
>
{stopping ? <LoaderCircle className="size-4 animate-spin" /> : <Square className="size-3.5" fill="currentColor" />}
</motion.button> : null}
@@ -772,11 +770,10 @@ export function AgentChatComposer({
whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined}
animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }}
transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }}
className="grid size-9 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
className="agent-composer-send grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed"
style={{
background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface,
color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted,
boxShadow: canSubmit || sending ? `0 8px 20px ${theme.accent.primary}45` : "none",
}}
>
<motion.span
@@ -786,7 +783,7 @@ export function AgentChatComposer({
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
className="grid place-items-center"
>
{sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
{sending ? <LoaderCircle className="size-3.5 animate-spin" /> : <ArrowUp className="size-3.5" />}
</motion.span>
</motion.button>
</div>

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type Dispatch, type SetStateAction } from "react";
import { Button, Dropdown, Input } from "antd";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, Bot, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
import { ArrowLeft, Check, ChevronRight, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, MoveDiagonal2, Settings2, ShieldCheck, Trash2, Sparkles, X } from "lucide-react";
import { saveAs } from "file-saver";
import { buildAgentDebugExport } from "@/lib/canvas/agent-debug-export";
import { agentPlanVisible, latestAgentPlanItems, pendingAgentQuestion } from "@/lib/canvas/cloud-agent-plan";
@@ -24,6 +24,7 @@ import { addSkill, listAddedSkills, listSkills, type Skill, type SkillCategory }
import { clearCloudAgentPendingSubmission, cloudAgentConversationTitle, loadCloudAgentConversations, loadCloudAgentPendingSubmission, saveCloudAgentConversations, saveCloudAgentPendingSubmission, type CloudAgentConversation, type CloudAgentPendingSubmission } from "@/services/cloud-agent-conversations";
import { logicalModelIDForConfig, modelOptionName, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
@@ -31,6 +32,7 @@ import { AgentChatComposer, AgentChatMessage, AgentPlanBar, AgentQuestionBar, Ag
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
import { CanvasCloudAgentSettings, agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual, type AgentContextKey } from "./canvas-cloud-agent-settings";
import { useAgentPanelLayout } from "./use-agent-panel-layout";
import { AgentWelcome } from "./canvas-agent-welcome";
import "./canvas-cloud-agent.css";
type CloudAgentPanelProps = { canvasId: string; domainProjectId?: string; nodeCount: number; references: CanvasResourceReference[]; open: boolean; prefillPrompt?: string; onOpen: () => void; onCollapse: () => void; onFocusNode?: (nodeId: string) => void };
@@ -598,8 +600,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 12, scale: 0.985 }}
transition={{ duration: reducedMotion ? 0 : 0.26, ease: [0.16, 1, 0.3, 1] }}
className="canvas-agent-panel fixed z-[var(--z-modal-overlay)] flex min-w-0 flex-col overflow-hidden rounded-2xl border max-sm:rounded-b-none"
style={{ ...panelLayout.style, background: theme.node.panel, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 90px ${theme.spatial.shadow}` }}
className="canvas-agent-panel fixed z-[var(--z-modal-overlay)] flex min-w-0 flex-col overflow-hidden"
style={{ ...panelLayout.style, "--agent-surface-base": theme.node.panel, "--agent-ink": theme.node.text, "--agent-accent": theme.accent.primary, "--agent-shadow-color": theme.spatial.shadow } as CSSProperties & Record<`--${string}`, string>}
aria-label="Agent 工作台"
data-canvas-no-zoom
data-canvas-wheel-scroll
@@ -613,10 +615,11 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
aria-label="调整 Agent 面板大小"
title="拖动调整宽高,也可用方向键调整"
data-agent-resize="northwest"
className="absolute left-0 top-0 z-10 hidden size-5 cursor-nw-resize touch-none opacity-50 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 sm:block"
className="agent-panel-resize-corner absolute left-2 top-2 z-10 hidden size-5 cursor-nw-resize touch-none place-items-center rounded-md opacity-60 transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-2 sm:grid"
onKeyDown={panelLayout.onResizeKeyDown}
style={{ color: theme.node.muted }}
>
<span className="absolute left-1.5 top-1.5 size-2 border-l-2 border-t-2 rounded-tl" style={{ borderColor: theme.node.muted }} />
<MoveDiagonal2 className="size-3" aria-hidden="true" />
</button>
<AnimatePresence mode="wait" initial={false}>
{view === "settings" ? (
@@ -676,6 +679,7 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
<motion.div key="chat" className="flex min-h-0 flex-1 flex-col" initial={{ opacity: 0, x: -12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }} transition={{ duration: reducedMotion ? 0 : 0.18 }}>
<AgentHeader
theme={theme}
hasMessages={messages.length > 0}
statusLabel={statusLabel}
statusColor={statusColor}
nodeCount={nodeCount}
@@ -715,6 +719,8 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, re
approval={approval}
nodeCount={nodeCount}
approvalSubmitting={approvalSubmitting || connectionStatus !== "connected"}
onChooseSkill={() => setSkillsOpen(true)}
onDraftPrompt={(draft) => setPrompt((current) => current.trim() ? `${current}\n\n${draft}` : draft)}
onApprovalReasonChange={(reason) => setApproval((current) => (current ? { ...current, reason } : current))}
onApprove={(settings) => void submitApproval("approve", settings)}
onReject={() => void submitApproval("reject")}
@@ -812,23 +818,20 @@ function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onO
);
}
function AgentHeader({ theme, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
function AgentHeader({ theme, hasMessages, statusLabel, statusColor, nodeCount, onNew, onHistory, onSettings, onCollapse, onExport, exporting }: { theme: CanvasTheme; hasMessages: boolean; statusLabel: string; statusColor: string; nodeCount: number; onNew: () => void; onHistory: () => void; onSettings: () => void; onCollapse: () => void; onExport: () => void; exporting: boolean }) {
return (
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-3 px-4" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
<span className="grid size-9 shrink-0 place-items-center rounded-xl" style={{ color: theme.node.text, background: theme.node.fill }}>
<Bot className="size-4" />
</span>
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold">Agent</span>
<span className="flex items-center gap-1 text-[11px]" style={{ color: statusColor }}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="agent-panel-title">{hasMessages ? "画布助手" : "新对话"}</span>
<span className="agent-panel-status flex items-center gap-1" style={{ color: statusColor }}>
<CircleDot className="size-3" />
{statusLabel}
</span>
</div>
<div className="mt-0.5 text-[11px] opacity-40">当前画布 · {nodeCount} 个节点</div>
<div className="agent-panel-context">Agent · 当前画布 {nodeCount} 个节点</div>
</div>
<div className="flex items-center gap-0.5">
<div className="agent-header-actions flex items-center gap-0.5" style={{ color: theme.node.muted }}>
<Button type="text" shape="circle" icon={<Download className="size-4" />} loading={exporting} onClick={onExport} aria-label="导出 Agent 调试记录" title="导出对话、工具参数、审批和错误(分享前请检查隐私)" />
<Button type="text" shape="circle" icon={<MessageSquarePlus className="size-4" />} onClick={onNew} aria-label="新建对话" title="新建对话" />
<Button type="text" shape="circle" icon={<History className="size-4" />} onClick={onHistory} aria-label="历史对话" title="历史对话" />
@@ -842,7 +845,7 @@ function AgentHeader({ theme, statusLabel, statusColor, nodeCount, onNew, onHist
function AgentHistory({ conversations, activeConversationId, theme, onBack, onNew, onOpen, onDelete }: { conversations: CloudAgentConversation[]; activeConversationId: string; theme: CanvasTheme; onBack: () => void; onNew: () => void; onOpen: (conversation: CloudAgentConversation) => void; onDelete: (id: string) => void }) {
return (
<div className="canvas-agent-history-root flex min-h-0 min-w-0 flex-1 flex-col">
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-2 px-3" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-2">
<Button type="text" shape="circle" icon={<ArrowLeft className="size-4" />} onClick={onBack} aria-label="返回对话" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold">历史对话</div>
@@ -891,6 +894,8 @@ function AgentConversation({
approval,
approvalSubmitting,
nodeCount,
onChooseSkill,
onDraftPrompt,
onFocusNode,
onApprovalReasonChange,
onApprove,
@@ -903,11 +908,14 @@ function AgentConversation({
approval: ApprovalState | null;
approvalSubmitting: boolean;
nodeCount: number;
onChooseSkill: () => void;
onDraftPrompt: (prompt: string) => void;
onFocusNode?: (nodeId: string) => void;
onApprovalReasonChange: (reason: string) => void;
onApprove: (settings?: AgentMediaSettings) => void;
onReject: () => void;
}) {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
const scrollRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
@@ -934,12 +942,12 @@ function AgentConversation({
}, []);
return (
<div ref={scrollRef} data-agent-conversation className="thin-scrollbar min-h-0 flex-1 overflow-y-auto px-5 py-5" onScroll={(event) => {
<div ref={scrollRef} data-agent-conversation className="agent-conversation thin-scrollbar min-h-0 flex-1 overflow-y-auto" onScroll={(event) => {
const element = event.currentTarget;
followRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
}}>
{!messages.length ? <AgentEmptyState theme={theme} nodeCount={nodeCount} /> : null}
<div ref={contentRef} className="space-y-2.5">
{!messages.length ? <AgentWelcome brandName={brandName} nodeCount={nodeCount} onChooseSkill={onChooseSkill} onDraftPrompt={onDraftPrompt} /> : null}
<div ref={contentRef} className="agent-conversation-messages">
{messages.map((item) => (
<AgentChatMessage key={item.id} item={item} theme={theme} references={references} onFocusNode={onFocusNode} isStreaming={busy && !approval && item.streaming === true && item === messages.at(-1)} />
))}
@@ -952,18 +960,6 @@ function AgentConversation({
);
}
function AgentEmptyState({ theme, nodeCount }: { theme: CanvasTheme; nodeCount: number }) {
return (
<div className="flex h-full min-h-72 flex-col items-center justify-center px-8 pb-8 text-center">
<div className="grid size-12 place-items-center rounded-2xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
<Bot className="size-6" />
</div>
<h2 className="mt-5 text-lg font-semibold">现在想做什么?</h2>
<p className="mt-2 max-w-64 text-xs leading-5" style={{ color: theme.node.muted }}>当前画布有 {nodeCount} 个节点。Agent 可以按权限读取画布、调用已启用 Skill、整理文本节点并提交受预算约束的生成任务。</p>
</div>
);
}
function ComposerControls({
reasoningMode,
reasoningSupported,
@@ -992,6 +988,7 @@ function ComposerControls({
selectedSkillCount: number;
}) {
const permissionVisual = agentPermissionVisual(permissionMode);
const PermissionIcon = permissionVisual.icon;
return (
<div className="flex min-w-0 flex-wrap items-center gap-0.5">
<ModelPicker
@@ -1000,7 +997,7 @@ function ComposerControls({
capability="text"
onChange={onModelChange}
variant="creation"
className="!h-8 !min-w-0 !w-36 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
className="!h-8 !min-w-0 !w-52 !max-w-full !border-0 !bg-transparent !px-1.5 !shadow-none"
popoverClassName="agent-model-picker-popover"
showSelectedPrice={false}
showOptionPrices
@@ -1014,12 +1011,12 @@ function ComposerControls({
<Dropdown trigger={["click"]} placement="topLeft" menu={{ items: agentPermissionMenuItems(permissionMode, onPermissionChange) }}>
<button
type="button"
className="flex h-8 shrink-0 items-center gap-1 rounded-md px-2 text-[11px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
className="grid size-8 shrink-0 place-items-center rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-current/25"
style={{ color: theme.node.muted, background: "transparent" }}
aria-label="选择 Agent 执行权限"
aria-label={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
title={`执行权限:${agentPermissionLabel(permissionMode)},点击切换`}
>
<ShieldCheck className="size-3.5" style={{ color: permissionVisual.color }} />
<span className="max-w-20 truncate">{agentPermissionLabel(permissionMode)}</span>
<PermissionIcon className="size-3.5" style={{ color: permissionVisual.color }} aria-hidden="true" />
</button>
</Dropdown>
<button
@@ -1033,7 +1030,7 @@ function ComposerControls({
onClick={() => onSkillsOpenChange(true)}
>
<Sparkles className="size-3.5" />
<span className="max-w-28 truncate">Skills技能包({selectedSkillCount})</span>
<span className="max-w-28 truncate">Skills({selectedSkillCount})</span>
</button>
</div>
);

View File

@@ -76,7 +76,7 @@ export function CanvasCloudAgentSettings(props: AgentSettingsProps) {
return (
<div className="canvas-agent-settings flex min-h-0 min-w-0 flex-1 flex-col" style={{ background: theme.node.panel }}>
<header data-agent-drag-handle className="flex h-[68px] shrink-0 items-center gap-2 px-3" style={{ boxShadow: `inset 0 -1px 0 ${theme.toolbar.border}` }}>
<header data-agent-drag-handle className="agent-panel-header flex shrink-0 items-center gap-2">
<Button type="text" shape="circle" icon={<ArrowLeft className="size-4" />} onClick={section === "home" ? props.onBack : goHome} aria-label={section === "home" ? "返回对话" : "返回设置"} />
<div className="min-w-0 flex-1"><div className="text-sm font-semibold">{title}</div><div className="mt-0.5 text-[11px] opacity-40">{section === "home" ? "只影响下一次新运行" : sectionSubtitle(section)}</div></div>
{section !== "home" ? <span className="rounded-full px-2 py-1 text-[10px] opacity-50" style={{ background: theme.node.fill }}>{section === "skills" ? `${props.selectedSkillIds.length} 已启用` : "当前 Agent"}</span> : null}
@@ -317,5 +317,5 @@ function sectionTitle(section: SettingsSection) { return section === "profile" ?
function sectionSubtitle(section: SettingsSection) { return section === "profile" ? "用户、项目和画布的长期行为偏好" : section === "memories" ? "只属于你,批准后才会注入会话" : section === "skills" ? "搜索、安装并选择本轮技能" : section === "mcp" ? "云端工具与连接状态" : section === "context" ? "控制 Agent 能读取的范围" : "控制本轮积分与生成消耗"; }
export function agentPermissionLabel(mode: AgentPermissionMode) { return permissionOptions.find((option) => option.value === mode)?.label || "请求审批"; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f` }; }
export function agentPermissionVisual(mode: AgentPermissionMode) { const option = permissionOptions.find((item) => item.value === mode) || permissionOptions[0]; return { color: option.color, soft: `${option.color}1f`, icon: option.icon }; }
export function agentPermissionMenuItems(mode: AgentPermissionMode, onChange: (mode: AgentPermissionMode) => void) { return permissionOptions.map((option) => ({ key: option.value, label: <span>{option.label}</span>, icon: mode === option.value ? <Check className="size-3.5" style={{ color: option.color }} /> : <option.icon className="size-3.5" style={{ color: option.color }} />, onClick: () => onChange(option.value) })); }

View File

@@ -1,6 +1,242 @@
.canvas-agent-panel {
/* Canvas theme -> material tokens. Only the floating shell blurs the canvas. */
--agent-material-solid: color-mix(in srgb, var(--agent-ink) 3%, var(--agent-surface-base));
--agent-material-glass: color-mix(in srgb, var(--agent-material-solid) 68%, transparent);
--agent-material-sheen: color-mix(in srgb, var(--color-neutral-0) 8%, transparent);
--agent-surface-soft: color-mix(in srgb, var(--agent-ink) 4%, transparent);
--agent-surface-hover: color-mix(in srgb, var(--agent-ink) 7%, transparent);
--agent-surface-raised: color-mix(in srgb, var(--agent-ink) 6%, var(--agent-material-solid));
--agent-caption: color-mix(in srgb, var(--agent-ink) 62%, var(--agent-surface-base));
--agent-radius-shell: calc(var(--radius) * 3.5);
--agent-radius-surface: calc(var(--radius) * 2.5);
--agent-material-shadow: 0 24px 80px var(--agent-shadow-color);
--agent-focus-ring: color-mix(in srgb, var(--agent-accent) 65%, var(--agent-ink));
container-type: inline-size;
overscroll-behavior: contain;
isolation: isolate;
border: 0;
border-radius: var(--agent-radius-shell);
background: var(--agent-material-solid);
color: var(--agent-ink);
box-shadow: var(--agent-material-shadow);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.canvas-agent-panel {
background: linear-gradient(145deg, var(--agent-material-sheen), transparent 55%), var(--agent-material-glass);
-webkit-backdrop-filter: blur(32px) saturate(145%);
backdrop-filter: blur(32px) saturate(145%);
}
}
.agent-panel-header {
min-height: 80px;
padding: var(--space-4) var(--space-5) var(--space-3) var(--space-6);
}
.agent-panel-title {
font-size: var(--fs-heading);
font-weight: 600;
letter-spacing: -0.02em;
}
.agent-panel-context {
margin-top: var(--space-1);
color: var(--agent-caption);
font-size: var(--fs-label);
}
.agent-panel-status {
font-size: var(--fs-label);
}
.agent-header-actions .ant-btn {
color: inherit;
}
.agent-conversation {
padding: var(--space-4) var(--space-6) var(--space-6);
scroll-padding-block: var(--space-6);
}
.agent-conversation-messages > * + * {
margin-top: var(--space-4);
}
.agent-message-user {
border-radius: var(--agent-radius-surface) var(--agent-radius-surface) var(--space-2) var(--agent-radius-surface);
background: var(--agent-surface-hover, color-mix(in srgb, var(--foreground) 7%, transparent));
}
.agent-welcome {
display: flex;
min-height: 100%;
flex-direction: column;
justify-content: center;
align-items: center;
padding-block: var(--space-6);
}
.agent-welcome-intro {
max-width: 100%;
overflow-wrap: anywhere;
text-align: center;
}
.agent-welcome-orb {
display: inline-block;
width: 52px;
height: 52px;
margin-bottom: var(--space-5);
border-radius: 50%;
background:
radial-gradient(circle at 32% 24%, var(--color-neutral-0), transparent 36%),
radial-gradient(circle at 70% 80%, var(--palette-node-audio), transparent 64%),
radial-gradient(circle at 35% 65%, color-mix(in srgb, var(--agent-accent) 65%, var(--color-neutral-0)), var(--agent-accent));
box-shadow: inset -4px -6px 14px color-mix(in srgb, var(--agent-accent) 28%, transparent), 0 8px 32px color-mix(in srgb, var(--agent-accent) 14%, transparent);
}
.agent-welcome-intro h2 {
margin: 0;
color: var(--agent-ink);
font-size: var(--fs-title);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 1.5;
text-wrap: balance;
}
.agent-welcome-intro p {
margin: var(--space-2) 0 0;
color: var(--agent-caption);
font-size: var(--fs-caption);
line-height: 1.7;
}
.agent-welcome-actions {
display: grid;
width: 100%;
max-width: 360px;
gap: var(--space-1);
margin-top: var(--space-8);
}
.agent-welcome-actions button {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 0;
border-radius: var(--space-4);
background: transparent;
color: var(--agent-caption);
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease;
}
.agent-welcome-actions button:hover:not(:disabled) {
background: var(--agent-surface-soft);
color: var(--agent-ink);
}
.agent-welcome-actions button:focus-visible {
outline: 2px solid var(--agent-focus-ring);
outline-offset: 2px;
}
.agent-welcome-actions button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.agent-welcome-actions svg {
width: var(--space-5);
height: var(--space-5);
flex: 0 0 auto;
stroke-width: 1.5;
}
.agent-welcome-actions button > span {
min-width: 0;
flex: 1;
}
.agent-welcome-actions strong,
.agent-welcome-actions small {
display: block;
font-weight: 400;
line-height: 1.6;
}
.agent-welcome-actions strong {
color: var(--agent-ink);
font-size: var(--fs-body);
}
.agent-welcome-actions small {
margin-top: var(--space-half);
font-size: var(--fs-label);
}
.agent-welcome-actions .agent-welcome-arrow {
width: var(--space-4);
height: var(--space-4);
opacity: 0.6;
}
.agent-welcome-footnote {
margin: var(--space-6) 0 0;
color: var(--agent-caption);
font-size: var(--fs-label);
}
.agent-composer-wrap {
padding: var(--space-2) var(--space-4) var(--space-4);
}
.agent-composer-surface {
padding: var(--space-4) var(--space-4) var(--space-3);
border-radius: var(--agent-radius-surface, var(--radius-xl));
background: var(--agent-surface-raised, var(--background));
}
.agent-composer-surface:focus-within {
box-shadow: 0 0 0 2px color-mix(in srgb, var(--agent-accent, var(--foreground)) 35%, transparent);
}
.agent-plan-bar {
border: 0;
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 4%, transparent));
}
.agent-question-bar {
border: 0;
background: var(--agent-surface-raised, var(--background));
}
@container (max-width: 400px) {
.agent-panel-header {
gap: var(--space-2);
padding-inline: var(--space-4);
}
.agent-panel-status { white-space: nowrap; }
.agent-conversation { padding-inline: var(--space-4); }
.agent-welcome-actions button { padding-inline: var(--space-2); }
}
@media (max-width: 639px) {
.canvas-agent-panel { border-end-start-radius: 0; border-end-end-radius: 0; }
.agent-composer-wrap { padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
}
@media (prefers-reduced-transparency: reduce) {
.canvas-agent-panel { background: var(--agent-material-solid); -webkit-backdrop-filter: none; backdrop-filter: none; }
}
@media (prefers-reduced-motion: reduce) {
.agent-welcome-actions button { transition: none; }
}
/* Agent 浮窗高于普通画布层,模型菜单必须叠在对话窗之上;菜单本身与全站二级选择器同一套。 */
@@ -172,16 +408,16 @@
.agent-tool-action-link {
min-width: 0;
border: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
border: 0;
border-radius: 8px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 4px 7px;
background: var(--agent-surface-soft, color-mix(in srgb, var(--foreground) 3%, transparent));
padding: var(--space-2) var(--space-3);
color: color-mix(in srgb, currentColor 88%, transparent);
font-size: 11px;
line-height: 17px;
overflow-wrap: anywhere;
text-align: left;
transition: border-color 140ms ease, background-color 140ms ease, color 140ms ease;
transition: background-color 140ms ease, color 140ms ease;
}
.agent-tool-action-link:not(:disabled) {
@@ -189,7 +425,6 @@
}
.agent-tool-action-link:not(:disabled):hover {
border-color: color-mix(in srgb, var(--foreground) 26%, transparent);
background: color-mix(in srgb, var(--foreground) 7%, transparent);
color: var(--foreground);
}
@@ -412,7 +647,7 @@
}
.agent-reasoning-card[open] {
background: color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background));
background: var(--agent-surface-soft, color-mix(in srgb, var(--agent-reasoning-accent) 3.5%, var(--background)));
}
.agent-reasoning-summary {
@@ -590,11 +825,10 @@
.canvas-agent-approval-card {
--agent-approval-blue: #2563eb;
--agent-approval-blue-hover: #1d4ed8;
border: 1px solid color-mix(in srgb, var(--foreground) 13%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--foreground) 4%, var(--background));
padding: 16px;
box-shadow: 0 10px 28px color-mix(in srgb, var(--foreground) 5%, transparent);
border: 0;
border-radius: var(--agent-radius-surface, var(--radius-xl));
background: var(--agent-surface-raised, color-mix(in srgb, var(--foreground) 4%, var(--background)));
padding: var(--space-5);
}
.canvas-agent-approval-header {
@@ -654,7 +888,7 @@
display: grid;
gap: 6px;
min-width: 0;
border: 1px solid color-mix(in srgb, var(--foreground) 9%, transparent);
border: 0;
border-radius: 10px;
background: color-mix(in srgb, var(--foreground) 3%, transparent);
padding: 9px 10px;

View File

@@ -1,6 +1,6 @@
import { Button } from "antd";
import { Button, ConfigProvider } from "antd";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import { useState, type ReactNode } from "react";
/** 内容库的浏览控件,不复用后台表格的筛选外框。 */
export function CollectionToolbar({ children, trailing, active, onReset, label = "搜索与筛选" }: {
@@ -10,8 +10,23 @@ export function CollectionToolbar({ children, trailing, active, onReset, label =
onReset?: () => void;
label?: string;
}) {
return <section className="collection-toolbar" aria-label={label}>
<div className="collection-toolbar-controls">{children}</div>
const [inputModality, setInputModality] = useState<"keyboard" | "pointer">("keyboard");
return <section
className="collection-toolbar"
aria-label={label}
data-input-modality={inputModality}
onPointerDownCapture={() => setInputModality("pointer")}
onKeyDownCapture={(event) => {
if (!event.altKey && !event.ctrlKey && !event.metaKey) setInputModality("keyboard");
}}
onBlurCapture={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) setInputModality("keyboard");
}}
>
<ConfigProvider select={{ variant: "filled", classNames: { popup: { root: "workspace-quiet-popup" } } }}>
<div className="collection-toolbar-controls">{children}</div>
</ConfigProvider>
<div className="collection-toolbar-actions">
{active && onReset ? <Button type="text" icon={<RotateCcw />} onClick={onReset}>重置</Button> : null}
{trailing}

View File

@@ -1,4 +1,4 @@
.workspace-account-popover .ant-popover-container { padding: 6px; border: 0 !important; border-radius: 20px; background: var(--user-surface-raised) !important; box-shadow: none !important; }
.workspace-account-popover .ant-popover-container { padding: var(--space-2); }
.workspace-account-popover .workspace-topbar-account-menu { width: auto; min-width: 0; padding: 0; }
.workspace-account-card { width: min(304px, calc(100vw - 48px)); color: var(--user-ink); padding: 12px; }
.workspace-account-card-identity { display: flex; align-items: center; gap: 12px; padding: 4px 0 16px; }
@@ -8,7 +8,7 @@
.workspace-account-card-identity strong { font-size: 15px; line-height: 24px; }
.workspace-account-card-identity span { color: var(--user-ink-muted); font-size: 12px; }
.workspace-account-card-identity em { font-style: normal; font-size: 10px; padding: 4px 8px; border-radius: 999px; background: var(--user-surface-muted); color: var(--user-ink-muted); }
.workspace-account-card-wallet { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; padding: 16px; margin-bottom: 12px; border-radius: 16px; background: linear-gradient(120deg, var(--user-accent-soft), color-mix(in srgb, var(--product-accent-blue) 10%, var(--user-surface))); }
.workspace-account-card-wallet { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; padding: 16px; margin-bottom: 12px; border-radius: var(--user-radius-card); background: var(--user-surface-muted); }
.workspace-account-card-balance > span { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--user-ink-muted); }
.workspace-account-card-balance strong { display: block; margin-top: 4px; font-size: 26px; font-weight: 650; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.workspace-account-card-wallet > button { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--user-accent); white-space: nowrap; padding: 6px 0; }

View File

@@ -27,6 +27,7 @@ export function WorkspaceAccountMenu() {
<><Popover
trigger="click"
placement="bottomRight"
arrow={false}
rootClassName="workspace-account-popover"
open={menuOpen}
onOpenChange={setMenuOpen}

View File

@@ -81,7 +81,7 @@ function WorkspaceSidebarProfile({ collapsed, user }: { collapsed: boolean; user
<WorkspaceSidebarCheckin collapsed={collapsed} />
<WorkspaceSidebarStorageMeter collapsed={collapsed} />
<div className={cn("app-workspace-sidebar-profile-row", collapsed && "is-collapsed")}>
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" rootClassName="workspace-account-popover" content={content}>
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" arrow={false} rootClassName="workspace-account-popover" content={content}>
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
<span className="app-workspace-sidebar-profile-avatar">{avatar}</span>
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}

View File

@@ -8,6 +8,7 @@ import { isSpatialWorkbenchPath } from "@/lib/workspace-routes";
import { getWorkspaceAntThemeConfig } from "@/lib/app-theme";
import { useWorkspaceButtonFeedback } from "@/hooks/use-workspace-button-feedback";
import "@/styles/workspace-product.css";
import "@/styles/workspace-menus.css";
const workspaceTheme = getWorkspaceAntThemeConfig();
@@ -33,7 +34,12 @@ export default function UserLayout({ children }: { children: ReactNode }) {
if (adminWorkspace) return children;
return (
<ConfigProvider theme={productWorkspace ? workspaceTheme : undefined}>
<ConfigProvider
theme={productWorkspace ? workspaceTheme : undefined}
select={productWorkspace ? { classNames: { popup: { root: "workspace-quiet-popup" } } } : undefined}
dropdown={productWorkspace ? { classNames: { root: "workspace-quiet-popup" } } : undefined}
popover={productWorkspace ? { classNames: { root: "workspace-quiet-popup" } } : undefined}
>
<div className={cn("app-user-workspace h-dvh overflow-hidden text-foreground", spatialWorkbench && "app-spatial-workspace", productWorkspace && "app-product-workspace")}>
<AppWorkspaceShell>{children}</AppWorkspaceShell>
</div>

View File

@@ -402,8 +402,16 @@ export function getWorkspaceAntThemeConfig(): ThemeConfig {
paddingInline: 14, paddingInlineSM: 10,
},
Input: { borderRadius: 12, paddingInline: 12 },
Select: { borderRadius: 12, optionSelectedBg: "var(--user-accent-soft)", optionSelectedColor: "var(--user-ink)", optionActiveBg: "var(--user-surface-muted)", optionPadding: "9px 12px" },
Dropdown: { borderRadiusLG: 16, controlItemBgHover: "var(--user-surface-muted)", paddingBlock: 6 },
Select: {
borderRadius: 12,
colorBgElevated: "var(--user-surface-raised)", boxShadowSecondary: "none",
colorFillTertiary: "var(--user-surface-muted)", colorFillSecondary: "var(--user-surface-hover)",
activeOutlineColor: "transparent",
optionSelectedBg: "var(--user-surface-hover)", optionSelectedColor: "var(--user-ink)",
optionActiveBg: "var(--user-surface-muted)", optionPadding: "9px 12px",
},
Dropdown: { borderRadiusLG: 16, colorBgElevated: "var(--user-surface-raised)", boxShadowSecondary: "none", controlItemBgHover: "var(--user-surface-muted)", paddingBlock: 6 },
Popover: { borderRadiusLG: 16, colorBgElevated: "var(--user-surface-raised)", boxShadowSecondary: "none" },
Modal: { borderRadiusLG: 20 },
Popconfirm: { borderRadiusLG: 16 },
Message: { borderRadiusLG: 14, contentPadding: "10px 18px" },

View File

@@ -0,0 +1,19 @@
/** Stored values remain stable; density controls card size, not a fixed column count. */
export const assetGridDensityOptions = [
{ label: "舒适", value: 6 },
{ label: "标准", value: 8 },
{ label: "紧凑", value: 10 },
];
export type AssetGridDensity = 6 | 8 | 10;
export const assetGridCardMinWidth: Record<AssetGridDensity, number> = {
6: 272,
8: 208,
10: 160,
};
export function parseAssetGridDensity(value: unknown): AssetGridDensity {
const number = Number(value);
return number === 6 || number === 10 ? number : 8;
}

View File

@@ -1,4 +1,5 @@
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { assetGridCardMinWidth, assetGridDensityOptions, parseAssetGridDensity, type AssetGridDensity } from "./asset-grid-density";
import { DeleteButton } from "@/components/ui/base/buttons/delete-button";
import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, FolderPlus, Image as ImageIcon, Images, LayoutGrid, Link2, Maximize2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, ZoomIn, ZoomOut, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -62,7 +63,6 @@ const categoryOptions = [{ label: "全部分类", value: "all" }, ...ASSET_CATEG
const ASSET_LIBRARY_QUERY_KEY = ["asset-library"] as const;
const ASSET_FOLDER_QUERY_KEY = ["asset-folders"] as const;
const ASSET_GRID_DENSITY_KEY = "infinite-canvas:asset-grid-density";
type AssetGridDensity = 6 | 8 | 10;
type AssetFolderFilter = "all" | "uncategorized" | string;
const assetKindIcons: Record<LibraryAsset["kind"], LucideIcon> = {
@@ -604,11 +604,12 @@ export default function AssetsPage() {
}}
/>
<Select
aria-label="素材显示密度"
value={gridDensity}
className="w-full sm:w-32"
suffixIcon={<LayoutGrid className="size-3.5" />}
options={[{ label: "舒适", value: 6 }, { label: "标准", value: 8 }, { label: "紧凑", value: 10 }]}
onChange={(value) => setGridDensity(value as AssetGridDensity)}
prefix={<LayoutGrid aria-hidden className="size-3.5" />}
options={assetGridDensityOptions}
onChange={(value) => setGridDensity(parseAssetGridDensity(value))}
/>
</CollectionToolbar>
</div>
@@ -724,7 +725,7 @@ export default function AssetsPage() {
{visibleAssets.length === 0 ? (
<WorkspaceState icon="assets" compact title="没有匹配的素材" description="调整关键词或左侧分类后再试。" />
) : (
<CollectionGrid className="library-grid assets-library-grid" style={{ "--assets-grid-columns": gridDensity } as React.CSSProperties}>
<CollectionGrid className="library-grid assets-library-grid" style={{ "--collection-grid-min-width": `${assetGridCardMinWidth[gridDensity]}px` } as React.CSSProperties}>
{visibleAssets.map((asset) => (
<AssetCard
key={asset.id}
@@ -1495,8 +1496,7 @@ function assetDownloadLabel(asset: LibraryAsset) {
function readAssetGridDensity(): AssetGridDensity {
if (typeof window === "undefined") return 8;
const value = Number(window.localStorage.getItem(ASSET_GRID_DENSITY_KEY));
return value === 6 || value === 10 ? value : 8;
return parseAssetGridDensity(window.localStorage.getItem(ASSET_GRID_DENSITY_KEY));
}
function assetCountMap<T extends { label: string; value: string }>(options: T[], remote: Record<string, number> | undefined, fallback: LibraryAsset[], valueOf: (asset: LibraryAsset) => string) {

View File

@@ -1309,11 +1309,6 @@
box-shadow: none;
}
.workspace-account-popover .ant-popover-inner {
border-color: color-mix(in srgb, var(--foreground) 7%, transparent) !important;
box-shadow: var(--elevation-overlay) !important;
}
body.app-spatial-overlays {
--workspace-overlay-bg: rgba(244, 244, 245, 1);
--workspace-overlay-bg-strong: rgba(250, 250, 250, 1);
@@ -14299,11 +14294,9 @@ html:not(.dark) .creation-history-drawer-root {
-webkit-backdrop-filter: blur(12px);
}
/* 3. 素材网格以 6 列为桌面基准;首格是新增素材,因此每页 35 项时正好组成 6×6 完整网格。 */
.assets-library-page .assets-library-grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, max(180px, calc(100% / var(--assets-grid-columns, 6) - 14px))), 1fr)); gap: 18px 14px; }
/* 素材密度由页面传入卡片最小宽度,网格布局统一在 workspace-product.css。 */
.assets-library-page .library-create-title { margin-top: 12px; font-size: var(--fs-body); }
.assets-library-page .library-create-meta { margin-top: 4px; font-size: var(--fs-label); }
@media (max-width: 899px) and (min-width: 640px) { .assets-library-page .assets-library-grid { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } }
/* 4. 封面浮层统一:窄屏隐藏分类徽章;浅底封面(文本/音频/模型)改用自适应玻璃浮层 */
@media (max-width: 1100px) { .assets-cover-badge.is-category { display: none; } }
@@ -17089,20 +17082,6 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
justify-content: center;
}
/* Account popover: every action gets its own row so icon and label can never collide. */
.workspace-account-popover .ant-popover-inner {
width: auto;
padding: 0 !important;
border: 0 !important;
border-radius: 16px !important;
background: var(--user-surface) !important;
box-shadow: none !important;
}
.workspace-account-popover .ant-popover-inner-content {
padding: 0 !important;
}
.app-user-workspace .app-workspace-sidebar-profile-popover {
width: 100%;
padding: 14px;
@@ -17239,15 +17218,14 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
}
.workspace-topbar-account-section {
padding: 11px 0 4px;
border-top: 1px solid var(--user-border);
padding: var(--space-2) var(--space-3) var(--space-1);
}
.workspace-topbar-account-theme {
display: flex;
height: 40px;
align-items: center;
padding: 0 8px;
padding: 0 var(--space-5);
border-radius: 10px;
color: var(--user-ink-muted);
}

View File

@@ -0,0 +1,46 @@
/* Same layer as the legacy overlay rules; explicit component classes keep
menus quiet without changing dialogs, notifications or the canvas editor. */
@layer utilities {
.workspace-quiet-popup.ant-select-dropdown,
.workspace-quiet-popup .ant-dropdown-menu,
.workspace-quiet-popup .ant-popover-container,
.workspace-account-popover .ant-popover-container {
border: 0 !important;
border-radius: var(--user-radius-card) !important;
background: var(--user-surface-raised) !important;
background-image: none !important;
box-shadow: none !important;
}
.workspace-quiet-popup.ant-select-dropdown,
.workspace-quiet-popup .ant-dropdown-menu {
padding: var(--space-2);
}
.workspace-quiet-popup :where(.ant-select-item-option, .ant-dropdown-menu-item, .ant-dropdown-menu-submenu-title) {
border-radius: var(--user-radius-control) !important;
color: var(--user-ink) !important;
}
.workspace-quiet-popup .ant-dropdown-menu-item-danger {
color: var(--destructive) !important;
}
.workspace-quiet-popup .ant-select-item-option-active:not(.ant-select-item-option-disabled),
.workspace-quiet-popup .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):hover,
.workspace-quiet-popup .ant-dropdown-menu-submenu-title:not(.ant-dropdown-menu-item-disabled):hover {
background: var(--user-surface-muted) !important;
}
.workspace-quiet-popup .ant-select-item-option-selected:not(.ant-select-item-option-disabled),
.workspace-quiet-popup .ant-dropdown-menu-item-selected {
background: var(--user-surface-hover) !important;
color: var(--user-ink) !important;
font-weight: 600;
}
.workspace-quiet-popup .ant-dropdown-menu-item-disabled,
.workspace-quiet-popup .ant-select-item-option-disabled {
color: var(--user-ink-soft) !important;
}
}

View File

@@ -207,11 +207,24 @@
box-shadow: none !important;
}
.app-user-workspace .collection-toolbar .ant-input-affix-wrapper svg { width: var(--user-icon-size); height: var(--user-icon-size); color: var(--user-ink-soft); }
/* Select 的只读 input 在鼠标点击后也可能匹配 :focus-visible;
用工具栏的实际输入方式区分,不通过 blur 破坏下拉键盘操作。 */
.app-user-workspace .collection-toolbar .ant-select {
outline: none !important;
}
.app-user-workspace .collection-toolbar .ant-select:not(.ant-select-disabled):is(:hover, .ant-select-focused, .ant-select-open) {
background: var(--user-surface-hover) !important;
}
.app-user-workspace .collection-toolbar[data-input-modality="keyboard"] .ant-select:not(.ant-select-disabled):focus-within {
outline: 1px solid var(--control-focus-ring) !important;
outline-offset: 2px;
}
}
@media (max-width: 720px) {
.app-product-workspace .library-page > div { width: calc(100% - 32px); }
.app-product-workspace .assets-library-page .assets-library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
.app-product-workspace .assets-library-page .assets-library-grid { gap: var(--space-3); }
.app-product-workspace .app-pagination-bar { flex-wrap: wrap; gap: var(--space-2); padding-inline: 0; }
.app-product-workspace .app-pagination-pages { justify-content: center; flex: 1 1 100%; }
.app-user-workspace .studio-collection-page > div { width: calc(100% - 32px); padding-top: 20px; }
@@ -355,7 +368,7 @@
.app-product-workspace .skill-library-grid,
.app-product-workspace .plugins-grid,
.app-product-workspace .task-grid-view {
grid-template-columns: repeat(auto-fill, minmax(224px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--collection-grid-min-width, 224px)), 1fr));
gap: 24px 16px;
}
@@ -668,7 +681,7 @@
.app-product-workspace .collection-toolbar-controls { flex-basis: 100%; }
.app-product-workspace .collection-toolbar-actions { width: 100%; justify-content: flex-start; }
.app-product-workspace .canvas-collection-grid,
.app-product-workspace .assets-library-grid,
.app-product-workspace .assets-library-grid:not(.assets-library-page .assets-library-grid),
.app-product-workspace .task-grid-view { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 10px; }
.app-product-workspace .plugins-grid { grid-template-columns: minmax(0, 1fr); }
.app-product-workspace .story-launcher-panel { padding: 16px; border-radius: 16px; }

View File

@@ -0,0 +1,49 @@
import { expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import { AgentWelcome } from "@/components/canvas/canvas-agent-welcome";
import { AgentChatComposer } from "@/components/canvas/canvas-cloud-agent-chat-ui";
import { agentPermissionVisual } from "@/components/canvas/canvas-cloud-agent-settings";
import { canvasThemes } from "@/lib/canvas-theme";
const noop = () => {};
test("welcome uses the configured brand and escapes it as text", () => {
const html = renderToStaticMarkup(<AgentWelcome brandName="我的<工作室>" nodeCount={2} onChooseSkill={noop} onDraftPrompt={noop} />);
expect(html).toContain("在这里,和我的&lt;工作室&gt;让灵感,慢慢成形");
expect(html).toContain("2 个节点");
expect(html.match(/type="button"/g)).toHaveLength(3);
expect(html).not.toContain('disabled=""');
});
test("empty canvas disables only the canvas-analysis shortcut", () => {
const html = renderToStaticMarkup(<AgentWelcome brandName="影策" nodeCount={0} onChooseSkill={noop} onDraftPrompt={noop} />);
expect(html.match(/disabled=""/g)).toHaveLength(1);
expect(html).toContain("添加节点后,一起梳理创作思路");
expect(html).not.toContain("生成前由你确认");
});
test("permission modes have distinct icons, not just distinct colors", () => {
const icons = (["read_only", "request_approval", "auto"] as const).map((mode) => {
const Icon = agentPermissionVisual(mode).icon;
return renderToStaticMarkup(<Icon />);
});
expect(new Set(icons).size).toBe(3);
expect(icons[0]).toContain("lucide-lock-keyhole");
expect(icons[1]).toContain("lucide-shield-check");
expect(icons[2]).toContain("lucide-sparkles");
});
test("compact send button preserves empty-draft and sending guards", () => {
for (const [prompt, sending, disabled] of [
["", false, true],
["故事灵感", false, false],
["故事灵感", true, true],
] as const) {
const html = renderToStaticMarkup(<AgentChatComposer prompt={prompt} sending={sending} placeholder="聊聊想法" theme={canvasThemes.dark} onPromptChange={noop} onSubmit={noop} />);
const button = html.match(/<button\b[^>]*class="agent-composer-send [^"]*"[^>]*>/)?.[0];
expect(button).toBeDefined();
expect(button).toContain("size-7");
expect(button?.includes('disabled=""')).toBe(disabled);
expect(button).not.toContain("box-shadow:");
}
});

View File

@@ -0,0 +1,29 @@
import { expect, test } from "bun:test";
const html = await Bun.file(new URL("../index.html", import.meta.url)).text();
const layerOrder = "@layer theme, base, components, utilities;";
test("HTML 在任何外部样式或脚本之前固定 CSS 层顺序", () => {
const firstStyle = html.match(/<style\b[^>]*>([\s\S]*?)<\/style>/i);
expect(firstStyle).not.toBeNull();
const css = firstStyle![1].replace(/\/\*[\s\S]*?\*\//g, "").trim();
expect(css.startsWith(layerOrder)).toBe(true);
const firstLoad = html.search(/<script\b|<link\b[^>]*\brel=["'](?:stylesheet|modulepreload)["']/i);
expect(firstLoad).toBeGreaterThan(html.indexOf(layerOrder));
});
test("入口层顺序与 Tailwind 一致,不依赖插件和全局 CSS 的加载先后", async () => {
const tailwind = await Bun.file(new URL("../node_modules/tailwindcss/index.css", import.meta.url)).text();
const editor = await Bun.file(new URL("../src/lib/plugins/builtin/editor/editor-shell.css", import.meta.url)).text();
const firstStyle = html.match(/<style\b[^>]*>([\s\S]*?)<\/style>/i)![1];
expect(tailwind.trimStart().startsWith(layerOrder)).toBe(true);
expect(editor).toContain("@layer utilities {");
// CSS layers retain their first declared order, even when a later chunk repeats them.
const layersIn = (css: string) => [...new Set([...css.matchAll(/@layer\s+([^;{]+)[;{]/g)].flatMap((match) => match[1].split(",").map((name) => name.trim())))];
expect(layersIn(editor + tailwind).indexOf("utilities")).toBeLessThan(layersIn(editor + tailwind).indexOf("base"));
for (const chunks of [editor + tailwind, tailwind + editor]) {
expect(layersIn(firstStyle + chunks)).toEqual(["theme", "base", "components", "utilities"]);
}
});

View File

@@ -0,0 +1,92 @@
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { renderToStaticMarkup } from "react-dom/server";
import { ConfigProvider, Select } from "antd";
import { useContext } from "react";
import { CollectionToolbar } from "@/components/layout/collection-toolbar";
import { getWorkspaceAntThemeConfig } from "@/lib/app-theme";
import { assetGridCardMinWidth, assetGridDensityOptions, parseAssetGridDensity } from "@/pages/assets/asset-grid-density";
test("density restores all saved choices and defaults invalid preferences to standard", () => {
for (const value of [6, 8, 10] as const) {
expect(parseAssetGridDensity(String(value))).toBe(value);
expect(parseAssetGridDensity(value)).toBe(value);
}
for (const value of [null, undefined, "", "broken", "12"]) {
expect(parseAssetGridDensity(value)).toBe(8);
}
});
test("each density has a distinct usable card size at normal library widths", () => {
expect(assetGridDensityOptions.map(({ value }) => value)).toEqual([6, 8, 10]);
const widths = [6, 8, 10].map((density) => assetGridCardMinWidth[parseAssetGridDensity(density)]);
expect(widths).toEqual([272, 208, 160]);
// CSS auto-fill: n columns fit when n * minimum + (n - 1) * gap <= width.
for (const available of [720, 900, 1100]) {
const columns = widths.map((minimum) => Math.floor((available + 16) / (minimum + 16)));
expect(columns[0]).toBeLessThan(columns[1]);
expect(columns[1]).toBeLessThan(columns[2]);
}
});
test("real collection toolbar supplies filled selects without changing their labels or value", () => {
function PopupConfigProbe() {
const { select } = useContext(ConfigProvider.ConfigContext);
return <span>{JSON.stringify(select?.classNames)}</span>;
}
const html = renderToStaticMarkup(
<CollectionToolbar>
<Select aria-label="素材显示密度" value={10} options={assetGridDensityOptions} />
<PopupConfigProbe />
</CollectionToolbar>,
);
expect(html).toContain("ant-select-filled");
expect(html).toContain("素材显示密度");
expect(html).toContain("紧凑");
expect(html).toContain('role="combobox"');
expect(html).toContain("workspace-quiet-popup");
expect(html).toContain('data-input-modality="keyboard"');
});
test("collection select focus uses a thin keyboard ring, not a persistent pointer outline", () => {
const css = readFileSync(new URL("../src/styles/workspace-product.css", import.meta.url), "utf8");
const toolbar = readFileSync(new URL("../src/components/layout/collection-toolbar.tsx", import.meta.url), "utf8");
expect(css).toContain(".collection-toolbar .ant-select {\n outline: none !important;");
expect(css).toContain('[data-input-modality="keyboard"] .ant-select:not(.ant-select-disabled):focus-within');
expect(css).toContain("outline: 1px solid var(--control-focus-ring) !important;");
expect(toolbar).toContain('onPointerDownCapture={() => setInputModality("pointer")}');
expect(toolbar).toContain("onKeyDownCapture=");
expect(toolbar).toContain("event.currentTarget.contains(event.relatedTarget)");
expect(toolbar).not.toContain(".blur()");
});
test("workspace menu tokens are shadow-free and use theme-aware surfaces", () => {
const { components } = getWorkspaceAntThemeConfig();
for (const name of ["Select", "Dropdown", "Popover"] as const) {
expect(components?.[name]?.boxShadowSecondary).toBe("none");
expect(components?.[name]?.colorBgElevated).toBe("var(--user-surface-raised)");
}
expect(components?.Select?.optionSelectedBg).toBe("var(--user-surface-hover)");
});
test("real grid consumes density rather than the old fixed column calculation", () => {
const page = readFileSync(new URL("../src/pages/assets/index.tsx", import.meta.url), "utf8");
const css = readFileSync(new URL("../src/styles/workspace-product.css", import.meta.url), "utf8");
const globals = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8");
expect(page).toContain('"--collection-grid-min-width": `${assetGridCardMinWidth[gridDensity]}px`');
expect(css).toContain("minmax(min(100%, var(--collection-grid-min-width, 224px)), 1fr)");
expect(globals).not.toContain("--assets-grid-columns");
expect(css).not.toContain(".assets-library-page .assets-library-grid { grid-template-columns: repeat(2");
});
test("menu surfaces are explicitly scoped and old account inner overrides are removed", () => {
const css = readFileSync(new URL("../src/styles/workspace-menus.css", import.meta.url), "utf8");
const globals = readFileSync(new URL("../src/styles/globals.css", import.meta.url), "utf8");
expect(css).toContain("@layer utilities");
expect(css).toContain(".workspace-account-popover .ant-popover-container");
expect(css).toContain("border: 0 !important");
expect(css).toContain("box-shadow: none !important");
expect(css).not.toContain(".ant-modal");
expect(globals).not.toContain(".workspace-account-popover .ant-popover-inner");
expect(globals).toContain(".ant-select:has(input:focus-visible)");
});