Align Code navigation and relocate gallery entry

This commit is contained in:
鲨鱼辣椒 committed 2026-10-13 23:25:41 +08:00
1 parent af97e9f69a
commit d287d5ac4b
11 files changed
+170 -50

No files matched your search

@@ -0,0 +1,40 @@
# Task: Align Code navigation with Agents and move gallery entry
## Identity
- Task ID: 20261013-code-gallery-nav-35fad017
- Mode: Feature
- Branch: codex/20261013-code-gallery-nav-35fad017-code-gallery-nav
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-code-gallery-nav-35fad017
- Base commit: af97e9f69a95038089e74350224cd2aaec4edfdc
- Owner: codex
- Status: In Progress
## Scope
- Align Code sidebar actions/projects with the existing Agent icon-badge + text navigation. Remove the bottom community invitation and make the welcome mark + Works Square label the public-gallery button. Preserve other existing colors, welcome title/caption, teacher surfaces and user resizing.
## Intent And Constraints
- User supplied three screenshots and explicitly requested these local changes after rejecting and rolling back the broader visual refinement. Do not reapply that rejected palette or teacher/composer/tab restyling.
- Reuse openCommunity and its existing fixed external public-gallery URL. No new APIs, fetched gallery data, fake shortcuts, cloud mutation, credential/profile reads, Main restart or unsent-input discard.
- Concurrent Task Gate Passed: official check/start/status succeeded; unique task ID, feature/planning mode, branch, worktree, baseaf97e9f and owner/reservation match. All52 peer Scope/Intent/Promotion sections read; previous visual variant is rejected, no blocking dependency. Placeholder child-language scope is unknown but unrelated. Revalidate runtime pointer before updating to prevent competing trial changes.
- Project Context Loaded / Planning Gate Passed: startup memory index, own task, positioning/current integrated snapshot, decisions/system, community collaboration, relevant domain/success and evidence/reflection/commitment indexes read. Main owns actual data and external shell opening; local projects and teacher definitions stay authoritative. Current trial Renderer18e4be66 with Main020b366 is the baseline; canonical integrated memory is older. User reference overrides generic design search recommendations; retain the single light system and existing Agent navigation tokens. Source scope is six Renderer files plus tests/README/owned task record only.
## Outcome
- Source implemented: flat icon-badge + text Code navigation; existing pastel icon tones, native project menus and data retained. Welcome mark/star decoration and title/caption remain; mark and Works Square label form one accessible button with existing openCommunity, disabled/pending and failure-retry behavior. Bottom invitation is no longer mounted.
- Temporary app update awaits committed full build, immutable preparation, native saved/idle guard and same-window refresh verification. No runtime update has occurred yet.
## Verification
- Pinned corepack pnpm10.33.4 typecheck and scoped TSX ESLint passed. Community3 unit cases and9 responsive/layout/keyboard/retained-draft E2E cases passed; sidebar/project/menu/layout/teacher91 tests passed. Retained TOOL/SKILL and left-upload focused fixtures each passed.
- Diff check passed; exactly six approved Renderer files differ from predecessor18e4be66, with zero Main/shared/resources/package/lock/Vite-config source delta against actual Main020b366. Full default build and actual native verification pending.
## Follow-ups
- Complete immutable Renderer overlay and same-window native verification without altering Main/login/data. Retain worktree/branch for review; no merge/push requested.
## Promotion Candidates
- After successful trial review, propose the localized Agent-style Code navigation and relocated public-gallery entry for future integrated documentation; no API/data/teacher-policy change. User reference is narrower than the rejected prior visual variant.
+2 -2
View File
@@ -11,7 +11,7 @@ Code 会话保存的模型被下架后,会提示重新选择可用模型;用
Makelore 是一个面向软件、视觉创作、智能机器人与个人云智能体的 AI 桌面工作台。当前版本为 `2.0.0`,首页展示 Agents、Code、Canvas 三个模块入口,Robot 入口隐藏;云智能体需配套配置 WS/Yuxi 服务。桌面采用左侧问候与标题、右侧三个横向插画卡片竖排的布局,Code 入口中心与“今天,想创作什么?”标题中心对齐,三个入口等高、间距均匀;标题组在桌面向右偏移约 1.5cm,小窗口将标题放在卡片上方;Agents 标语为“打造你想象中的AI助手”。各页左下角账户按钮统一沿用 Code 的白色圆角卡片、头像、字号和右箭头,展开时跟随 Code 侧栏宽度偏好,首页和设置页与 Code 保持相同的左侧与底部边距;展开菜单保持原尺寸,可查看本人词元点数与预占、打开词元点数与充值、个人资料、设置和退出登录;沿用侧栏的账号、钱包和资料规则。设置使用独立页面,不显示编程项目栏、项目标题或工作栏收起按钮;“返回”回到打开设置前的页面(保留查询参数),直接打开设置时回模块首页,未保存设置先保存或还原。工作区左上角入口点击后返回模块入口页:
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
会话工作台采用浅蓝项目栏,侧栏入口与项目列表采用 Agent 式的彩色图标徽章+文字,选中项目保留浅色反馈;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;欢迎区的麦洛标识、星装饰和「作品广场」组成同一个可鼠标或键盘操作的广场入口,保留原有主标题与说明,输入框上方不再显示两句广场邀请。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回;智能体模块的侧栏只通过按钮点击或键盘激活展开,鼠标经过左侧、收纳按钮或顶栏不会展开,切换模块不沿用旧悬停预览;其他模块仍保留原预览方式。“我的智能体”使用浅紫对话机器人图标,“渠道”使用浅蓝连接节点图标。macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片恢复原有尺寸与留白,显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、TOOL、SKILL、MCP 服务和知识库纵向呈现,资源选择按钮分别显示「选择TOOL」「选择SKILL」;移除子智能体选择模块,保存其他配置时保留历史子智能体绑定,不再设内部分类切换;不展示 Tool/Skill 解释、资源开放规则提示、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。独立对话沿用奶油底色和浅蓝、浅紫、薄荷色:顶部展示返回、只读头像和名称;有附件时在同一行右侧显示回形针“附件”入口和文件数量,不展示调整、保存/应用状态、客户端/微信来源、费用/版本说明或历史/新对话图标;配置由列表卡片的“调整”进入。空对话展示当前智能体头像与名称,示例只填入草稿;用户消息使用浅紫圆角气泡,助手 Markdown、表格和代码在消息区内滚动。文件或图片从输入框内添加,上传成功后以待发送卡片呈现,可移除本次携带;移除不会删除云端文件,添加不会自动发送。确认失败沿用同一文件和对话重试,发送前需完成或取消添加;支持仅发送附件,使用明确的附件提问。输入框沿用 Code 的浅蓝边框、圆形按钮及一至六行输入规则;Enter 发送、Shift+Enter 换行,保留中文输入法确认。麦克风复用应用级语音输入,识别只追加当前草稿,不自动发送;切换账号、对话或隐藏页面后丢弃过期识别。支持拖入文件、粘贴图片和多文件选择,上传与确认仍由 Main 持有认证并转发;失败重试保留同一批文件身份。输入框的“+”添加文件或图片;右上角“附件”打开右侧面板,按“你上传的”和“助手生成的”分组,支持再次选用已上传附件及保存明确交付的生成文件。生成文件仅取当前对话已保存助手回复中的本地文件链接(含图片与引用式链接),核对实际文件后展示和计数;只读取链接所在的文件夹,不遍历全部工作文件。未交付的脚本、缓存和工具记录不展示、不计数,明确交付的代码照常展示;仅写了路径的旧成果可能暂时隐藏,后续接入 Yuxi 的正式交付记录。没有文件时隐藏入口;打开、关闭面板保留聊天与输入,Escape 关闭并返回入口焦点;最近对话自动恢复、审批、停止和未发送内容的离开保护保留。模型返回的思考按消息独立于正文展示;正文到来后或完成时折叠,可手动展开;无思考或工具内容时不显示过程区。重连事件去重并隔离当前运行与主对话,终态历史未到达时保留已收到的片段并有限补读;服务端返回的最终回答思考可在历史中回看,不进入后Line truncated
个人智能体聊天在每条提问与回答下方显示云端记录的消息时间;发送时间位于紫色消息气泡外下方并靠右,助手回复时间保持在回答下方:今天显示时分,跨日增加日期,悬停可查看完整日期与秒数;发送时间与回复时间沿用原消息记录,重开对话不改为当前时间。流式回答尚无可靠时间时显示「回复中…」,历史到达后展示原时间;缺失或无效时间显示「时间未记录」。正式对话、草稿试用和上次试用对比使用相同规则,不新增 Yuxi 接口。
@@ -39,7 +39,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
- macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。
- 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。
- AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。
- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;更浅底色的“作品广场”与“新建项目”采用相同高度、圆角、边框与阴影,并位于品牌和新建项目之间,上下各留 10px;“我的项目”和项目列表紧接新建项目;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。
- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,并依次位于品牌下方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。
- AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。
- 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。
- 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。
+13 -6
View File
@@ -1,23 +1,30 @@
import { useState } from 'react';
import { ArrowUpRight, Compass } from 'lucide-react';
import { ArrowUpRight, Compass, Sparkles, Star } from 'lucide-react';
import mark from '@/assets/consultations/mark.svg';
import { openCommunity } from '@/lib/community';
import './community.css';
export function CommunityLink({ variant = 'inline', collapsed = false }: {
variant?: 'inline' | 'sidebar'; collapsed?: boolean;
variant?: 'inline' | 'sidebar' | 'welcome'; collapsed?: boolean;
}) {
const [opening, setOpening] = useState(false);
const [error, setError] = useState(false);
return <div>
return <div className="community-link-container" data-variant={variant}>
<button type="button" className="community-link" data-variant={variant} data-collapsed={collapsed}
aria-label={variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'}
data-testid={variant === 'welcome' ? 'community-welcome-link' : undefined}
aria-label={variant === 'inline' ? '去作品广场逛逛' : '作品广场'}
title="在浏览器打开作品广场" disabled={opening}
onClick={() => {
setOpening(true); setError(false);
void openCommunity().catch(() => setError(true)).finally(() => setOpening(false));
}}>
<Compass aria-hidden="true" />
{!collapsed && <><span>{variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'}</span><ArrowUpRight className="community-link-arrow" aria-hidden="true" /></>}
{variant === 'welcome' ? <>
<span className="coding-start-mark"><img src={mark} alt="麦洛" /><Sparkles aria-hidden="true" /><Star aria-hidden="true" /></span>
<span className="coding-start-eyebrow">作品广场<ArrowUpRight className="community-link-arrow" aria-hidden="true" /></span>
</> : <>
{variant === 'sidebar' ? <span className="community-link-icon"><Compass aria-hidden="true" strokeWidth={1.8} /></span> : <Compass aria-hidden="true" />}
{!collapsed && <><span>{variant === 'sidebar' ? '作品广场' : '去作品广场逛逛'}</span><ArrowUpRight className="community-link-arrow" aria-hidden="true" /></>}
</>}
</button>
{error && <span className="community-link-error" role="alert">没能打开作品广场,请再点一次试试。</span>}
</div>;
+11 -3
View File
@@ -3,7 +3,6 @@
.coding-chat-body[data-welcome="true"] { padding: 0 24px 16px; background: radial-gradient(ellipse at 50% 40%, #fff8e8 0%, #fffdf8 28%, #fff 67%); }
.coding-chat-body[data-welcome="true"] .classroom-chat-view { flex: 1 1 0; overflow-y: auto; padding: 24px 0; }
.coding-chat-body[data-welcome="true"] .classroom-composer-view { width: 100%; max-width: 740px; margin: 0 auto; padding-top: 16px; }
.coding-chat-body[data-welcome="true"] .classroom-composer-view > .community-invitation { margin: 0 auto 12px; }
.coding-chat-body[data-welcome="true"] [data-testid="coding-message-composer"] { width: 100%; padding: 0; }
.coding-chat-body[data-welcome="true"] .chat-composer-surface { background: #fff; border-color: #d7dfda; border-radius: 20px; box-shadow: 0 5px 0 #eaf0e8, 0 12px 32px #69774b08; }
.coding-start-heading { position: relative; flex-shrink: 0; width: 100%; max-width: 740px; margin: var(--teacher-top-inset, auto) auto auto; padding: 0 12px; text-align: center; }
@@ -11,7 +10,7 @@
.coding-start-mark img { width: 35px; height: 35px; }
.coding-start-mark > svg { position: absolute; width: 17px; height: 17px; right: -24px; top: -2px; color: #c78b36; }
.coding-start-mark > svg:last-child { width: 12px; height: 12px; left: -23px; top: 39px; color: #83a496; }
.coding-start-eyebrow { margin: 18px 0 7px; color: #7a7769; font-size: 12px; letter-spacing: .08em; }
.coding-start-eyebrow { display: inline-flex; align-items: center; gap: 4px; margin: 18px 0 7px; color: #7a7769; font-size: 12px; letter-spacing: .08em; }
.coding-start-heading h1 { color: #304968; font-size: clamp(24px, 2.5vw, 34px); line-height: 1.45; font-weight: 650; letter-spacing: -.04em; }
.coding-start-heading h1 span { position: relative; white-space: nowrap; background: linear-gradient(transparent 73%, #ffe6a6 73%, #ffe6a6 94%, transparent 94%); }
.coding-start-caption { margin-top: 10px; color: #718096; font-size: 13px; line-height: 1.8; }
@@ -24,8 +23,17 @@
.community-link:focus-visible { outline: 2px solid #547ab0; outline-offset: 3px; }
.community-link svg { width: 17px; height: 17px; flex-shrink: 0; }
.community-link .community-link-arrow { width: 14px; height: 14px; }
.community-link-icon { display: grid; place-items: center; width: 32px; height: 32px; flex-shrink: 0; border: 1px solid #c9dfc3; border-radius: 12px; background: #e1f1d9; color: #526b46; }
.community-link .community-link-icon svg { width: 20px; height: 20px; }
.community-link[data-variant="sidebar"] { justify-content: flex-start; width: 100%; margin-top: 12px; border-color: transparent; background: transparent; text-align: left; }
.community-link[data-variant="sidebar"]:hover { background: #e8f0e7; }
.community-link[data-variant="welcome"] { flex-direction: column; gap: 0; padding: 0 6px; border: 0; border-radius: 18px; background: transparent; color: #7a7769; }
.community-link[data-variant="welcome"] .coding-start-mark { transition: background-color 150ms ease, border-color 150ms ease; }
.community-link[data-variant="welcome"] .coding-start-eyebrow { transition: color 150ms ease; }
.community-link[data-variant="welcome"]:hover:not(:disabled) { background: transparent; }
.community-link[data-variant="welcome"]:hover:not(:disabled) .coding-start-mark { border-color: #dfbf89; background: #ffe9c5; }
.community-link[data-variant="welcome"]:hover:not(:disabled) .coding-start-eyebrow, .community-link[data-variant="welcome"]:focus-visible .coding-start-eyebrow { color: #526b46; text-decoration: underline; text-underline-offset: 4px; }
.community-link[data-variant="welcome"] .community-link-arrow { width: 12px; height: 12px; }
.community-link[data-collapsed="true"] { justify-content: center; padding-inline: 0; }
.community-link-error { display: block; margin-top: 6px; color: #965a45; font-size: 12px; line-height: 1.6; }
@keyframes community-hello { 0% { transform: rotate(-15deg) translateY(5px); opacity: 0; } 70% { transform: rotate(3deg); opacity: 1; } 100% { transform: rotate(-7deg); } }
@@ -34,4 +42,4 @@
.coding-start-caption { max-width: 230px; margin: 9px auto 0; }
.community-invitation { flex-direction: column; align-items: flex-start; gap: 13px; padding: 19px; }
}
@media (prefers-reduced-motion: reduce) { .coding-start-mark { animation: none; } }
@media (prefers-reduced-motion: reduce) { .coding-start-mark { animation: none; } .community-link[data-variant="welcome"] .coding-start-mark, .community-link[data-variant="welcome"] .coding-start-eyebrow { transition: none; } }
+2 -2
View File
@@ -467,7 +467,7 @@ export function Sidebar({
aria-label="新建项目"
onClick={openCreateProject}
>
<Plus className="h-4 w-4 shrink-0 text-brand" />
{workspaceLayout ? <span className="classroom-create-project-icon" aria-hidden="true"><Plus strokeWidth={1.8} /></span> : <Plus className="h-4 w-4 shrink-0 text-brand" />}
{!sidebarCollapsed && '新建项目'}
</button>
{!workspaceLayout && <CommunityLink variant="sidebar" collapsed={sidebarCollapsed} />}
@@ -517,7 +517,7 @@ export function Sidebar({
: 'bg-transparent text-foreground hover:bg-surface-tertiary',
)}
>
{workspaceLayout ? <span className="classroom-project-icon" data-tone={['orange', 'green', 'blue'][Array.from(project.id).reduce((sum, char) => sum + char.charCodeAt(0), 0) % 3]}><Gamepad2 aria-hidden="true" /></span> : <Gamepad2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
{workspaceLayout ? <span className="classroom-project-icon" data-tone={['orange', 'green', 'blue'][Array.from(project.id).reduce((sum, char) => sum + char.charCodeAt(0), 0) % 3]}><Gamepad2 aria-hidden="true" strokeWidth={1.8} /></span> : <Gamepad2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{project.name}</span>
</button>
<DropdownMenu.Root onOpenChange={setProjectMenuOpen}>
-2
View File
@@ -49,7 +49,6 @@ import { CodingInteractionPanel } from './CodingInteractionPanel';
import { CodingWelcomeHero } from './CodingWelcomeHero';
import { CodingStartHeading } from './CodingStartHeading';
import { CodingChatBody } from './CodingChatBody';
import { CommunityInvitation } from '@/components/community/CommunityInvitation';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
import { useAuthStore } from '@/stores/auth';
import { TeacherChatPanel } from './TeacherChatPanel';
@@ -916,7 +915,6 @@ export function CodingChatPanel({
</div>
<div hidden={agentBrowserOpen} className="classroom-composer-view shrink-0">
{showWelcome && <CommunityInvitation />}
{selectedConversation?.archivedAt ? (
<div className="m-4 flex items-center justify-between gap-3 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
<span>{running ? '此对话已归档,任务仍在运行。' : '此对话已归档,恢复后可继续聊天。'}</span>
+2 -4
View File
@@ -1,10 +1,8 @@
import { Sparkles, Star } from 'lucide-react';
import mark from '@/assets/consultations/mark.svg';
import { CommunityLink } from '@/components/community/CommunityLink';
export function CodingStartHeading() {
return <div className="coding-start-heading" data-testid="coding-start-heading">
<span className="coding-start-mark"><img src={mark} alt="麦洛" /><Sparkles aria-hidden="true" /><Star aria-hidden="true" /></span>
<p className="coding-start-eyebrow">小点子,也能变成大惊喜</p>
<CommunityLink variant="welcome" />
<h1>今天,你想<span>创造什么?</span></h1>
<p className="coding-start-caption">一个游戏、一段故事,或是一个天马行空的想法。</p>
</div>;
+17 -11
View File
@@ -8,25 +8,31 @@
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header { padding-top: 0; padding-bottom: 0; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="classroom-sidebar-navigation"] { align-items: center; }
[data-testid="sidebar"][data-classroom="true"] .classroom-brand { height: 46px; gap: 10px; padding: 0 2px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"] { min-height: 41px; margin-top: 10px; gap: 9px; padding: 8px 11px; border: 1px solid #d5e1ed; border-radius: 11px; background: #f8faf5; box-shadow: 0 3px 0 #e3eaf3; font-size: 14px; font-weight: 500; line-height: 20px; white-space: normal; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; border-color: #b7c6a4; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"] { min-height: 48px; margin-top: 10px; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; box-shadow: none; color: #26384d; font-size: 14px; font-weight: 600; line-height: 20px; white-space: normal; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; }
[data-testid="sidebar"][data-classroom="true"] .community-link-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #dce5d0; border-radius: 12px; background: #f0f5e8; color: #526b46; }
[data-testid="sidebar"][data-classroom="true"] .community-link-icon svg { width: 20px; height: 20px; }
[data-testid="sidebar"][data-classroom="true"] .community-link-arrow { margin-left: auto; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-brand:hover { background: #eaf1f8; }
.classroom-brand-mark { display: grid; place-items: center; width: 40px; height: 42px; flex-shrink: 0; border: 1px solid #f1d1aa; border-radius: 12px; background: #fff0dd; box-shadow: 0 3px 0 #ecdbc5; }
.classroom-brand-caption { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-content { padding: 10px 12px 14px; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] { min-height: 41px; gap: 9px; padding: 8px 11px; border: 1px solid #d5e1ed; border-radius: 11px; background: #fff; color: #304968; box-shadow: 0 3px 0 #e3eaf3; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] { min-height: 48px; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; color: #26384d; box-shadow: none; font-size: 14px; font-weight: 600; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] svg { color: #c44b23; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"]:hover { background: #fff7ef; border-color: #ecc5a5; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"]:hover { background: #fff0e6; }
.classroom-create-project-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #f0d9c7; border-radius: 12px; background: #ffead9; }
.classroom-create-project-icon svg { width: 20px; height: 20px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-projects { margin-top: 8px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-projects-heading { padding: 6px 3px; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-projects-heading > svg { display: none; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card { min-height: 56px; gap: 9px; padding: 8px 40px 8px 8px; border: 1px solid transparent; border-radius: 13px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card:hover { background: #fff; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card[aria-current="page"] { background: #fff; border-color: #c9d9e9; box-shadow: 0 3px 0 #e2eaf3; }
.classroom-project-icon { display: grid; place-items: center; flex-shrink: 0; width: 34px; height: 37px; border-radius: 10px; background: #ffead9; color: #a85026; }
.classroom-project-icon svg { width: 21px; height: 21px; }
.classroom-project-icon[data-tone="green"] { background: #e1f1d9; color: #507d39; }
.classroom-project-icon[data-tone="blue"] { background: #deedfb; color: #3a6d9d; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card { min-height: 48px; gap: 8px; padding: 8px 40px 8px 8px; border: 0; border-radius: 8px; box-shadow: none; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card:hover { background: #eaf1f8; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card[aria-current="page"] { background: hsl(var(--brand-soft)); box-shadow: none; }
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card > span:last-child { font-size: 14px; font-weight: 600; }
.classroom-project-icon { display: grid; place-items: center; flex-shrink: 0; width: 32px; height: 32px; border: 1px solid #f0d9c7; border-radius: 12px; background: #ffead9; color: #a85026; }
.classroom-project-icon svg { width: 20px; height: 20px; }
.classroom-project-icon[data-tone="green"] { border-color: #cfe3c6; background: #e1f1d9; color: #507d39; }
.classroom-project-icon[data-tone="blue"] { border-color: #cddfec; background: #deedfb; color: #3a6d9d; }
[data-testid="sidebar"][data-classroom="true"] [data-testid="project-conversations"] { padding: 8px 0 4px; font-size: 13px; }
[data-testid="sidebar"][data-classroom="true"] .classroom-conversation-actions { gap: 2px; margin-bottom: 4px; color: #64758a; }
[data-testid="sidebar"][data-classroom="true"] .classroom-conversation-actions button { min-height: 32px; min-width: 30px; border-radius: 8px; }
+54 -14
View File
@@ -39,10 +39,18 @@ test.afterAll(async () => { await server?.close(); });
test.beforeEach(async ({ page }) => {
page.on('pageerror', error => { throw error; });
await page.addInitScript(() => {
const scope = window as typeof window & { __communityCalls: unknown[][] };
const scope = window as typeof window & { __communityCalls: unknown[][]; __communityFailures: number };
scope.__communityCalls = [];
scope.__communityFailures = 0;
Object.defineProperty(window, 'electron', { value: { ipcRenderer: {
invoke: async (...args: unknown[]) => { scope.__communityCalls.push(args); return undefined; },
invoke: async (...args: unknown[]) => {
scope.__communityCalls.push(args);
if (args[0] === 'shell:openExternal' && scope.__communityFailures > 0) {
scope.__communityFailures--;
throw new Error('unavailable');
}
return undefined;
},
} } });
});
});
@@ -62,17 +70,16 @@ for (const width of [375, 768, 1280, 1470]) {
}).toBe(true);
}
await expect(page.locator('.coding-start-mark')).toHaveCSS('animation-name', 'none');
await page.getByTestId('community-invitation').scrollIntoViewIfNeeded();
await page.getByTestId('community-welcome-link').scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await expect(page.getByRole('button', { name: '去作品广场逛逛' })).toBeVisible();
await expect(page.getByTestId('community-welcome-link')).toBeVisible();
await expect(page.getByTestId('community-invitation')).toHaveCount(0);
while (await teachers.count()) await teachers.getByRole('button', { name: /^收起全部老师介绍/ }).first().click();
await heading.scrollIntoViewIfNeeded();
const composer = page.getByTestId('coding-message-composer');
await expect(composer).toBeInViewport({ ratio: 1 });
const box = (await composer.boundingBox())!;
expect(850 - box.y - box.height).toBeLessThanOrEqual(40);
const invitation = (await page.getByTestId('community-invitation').boundingBox())!;
expect(box.y - invitation.y - invitation.height).toBeCloseTo(12, 0);
await page.screenshot({ path: test.info().outputPath(`community-welcome-${width}.png`) });
});
}
@@ -87,6 +94,12 @@ test('a short window scrolls the welcome while keeping the composer at the botto
const after = (await composer.boundingBox())!;
expect(after.y).toBeCloseTo(before.y, 0);
expect(600 - after.y - after.height).toBeLessThanOrEqual(40);
const teachers = page.getByTestId('teacher-welcomes');
while (await teachers.count()) await teachers.getByRole('button', { name: /^收起全部老师介绍/ }).first().click();
await page.getByTestId('community-welcome-link').scrollIntoViewIfNeeded();
await expect(page.getByTestId('community-welcome-link')).toBeInViewport({ ratio: 1 });
await expect(page.getByTestId('community-invitation')).toHaveCount(0);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
});
for (const width of [1280, 1470]) {
@@ -104,10 +117,7 @@ for (const width of [1280, 1470]) {
return Math.abs(input!.x + input!.width / 2 - (side!.x + side!.width + width) / 2);
}).toBeLessThan(2);
const input = (await composer.boundingBox())!;
const invitation = (await page.getByTestId('community-invitation').boundingBox())!;
expect(invitation.x).toBeCloseTo(input.x, 0);
expect(invitation.width).toBeCloseTo(input.width, 0);
expect(input.y - invitation.y - invitation.height).toBeCloseTo(12, 0);
await expect(page.getByTestId('community-invitation')).toHaveCount(0);
const heading = page.getByTestId('coding-start-heading');
await expect.poll(async () => {
const [title, content] = await Promise.all([heading.boundingBox(), page.locator('.classroom-chat-view').boundingBox()]);
@@ -136,7 +146,7 @@ for (const width of [1280, 1470]) {
});
}
test('the community link opens only on click and leaves the draft mounted', async ({ page }) => {
test('the welcome entry opens only on click and leaves the draft mounted', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 850 });
const communityRequests: string[] = [];
page.on('request', request => { if (request.url().includes('square.nianxx.cn')) communityRequests.push(request.url()); });
@@ -144,14 +154,44 @@ test('the community link opens only on click and leaves the draft mounted', asyn
const composer = page.getByRole('textbox');
await composer.fill('我想做一个自己的小镇');
await composer.evaluate(node => node.setAttribute('data-mounted', 'same'));
await page.getByRole('button', { name: '去作品广场逛逛' }).click();
const calls = () => page.evaluate(() => (window as typeof window & { __communityCalls: unknown[][] }).__communityCalls);
expect(await calls()).toEqual([]);
await expect(page.getByTestId('community-invitation')).toHaveCount(0);
await page.getByTestId('community-welcome-link').click();
expect(await page.evaluate(() => (window as typeof window & { __communityCalls: unknown[][] }).__communityCalls))
.toContainEqual(['shell:openExternal', 'https://square.nianxx.cn/#explore']);
.toEqual([['shell:openExternal', 'https://square.nianxx.cn/#explore']]);
expect(communityRequests).toEqual([]);
await expect(page.locator('iframe')).toHaveCount(0);
await expect(composer).toHaveValue('我想做一个自己的小镇');
await expect(composer).toHaveAttribute('data-mounted', 'same');
await expect(page.getByTestId('coding-start-heading')).toBeVisible();
await expect(page.getByText('第一条消息已发送(测试)')).toHaveCount(0);
await page.getByRole('button', { name: '发送', exact: true }).click();
await expect(page.getByTestId('community-invitation')).toHaveCount(0);
await expect(page.getByTestId('community-welcome-link')).toHaveCount(0);
await expect(composer).toHaveAttribute('data-mounted', 'same');
});
test('the welcome entry supports keyboard activation and retry while preserving the draft', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 600 });
await page.goto(baseUrl);
const composer = page.getByRole('textbox');
await composer.fill('保留我的草稿');
await page.evaluate(() => { (window as typeof window & { __communityFailures: number }).__communityFailures = 1; });
const entry = page.getByTestId('community-welcome-link');
await entry.focus();
await expect(entry).toBeFocused();
await expect(entry).toHaveCSS('outline-style', 'solid');
await entry.press('Enter');
await expect(page.getByRole('alert')).toHaveText('没能打开作品广场,请再点一次试试。');
await expect(entry).toBeEnabled();
await entry.press('Space');
await expect(page.getByRole('alert')).toHaveCount(0);
expect(await page.evaluate(() => (window as typeof window & { __communityCalls: unknown[][] }).__communityCalls))
.toEqual([
['shell:openExternal', 'https://square.nianxx.cn/#explore'],
['shell:openExternal', 'https://square.nianxx.cn/#explore'],
]);
await expect(composer).toHaveValue('保留我的草稿');
await expect(page.getByTestId('coding-start-heading')).toBeVisible();
await expect(page.getByText('第一条消息已发送(测试)')).toHaveCount(0);
});
-2
View File
@@ -4,7 +4,6 @@ import { CodingChatBody } from '../../../src/pages/Chat/CodingChatBody';
import { CodingStartHeading } from '../../../src/pages/Chat/CodingStartHeading';
import { CodingComposer } from '../../../src/pages/Chat/CodingComposer';
import { TeacherCompanion } from '../../../src/pages/Chat/TeacherCompanion';
import { CommunityInvitation } from '../../../src/components/community/CommunityInvitation';
import '../../../src/styles/globals.css';
import '../../../src/pages/Chat/classroom-workspace.css';
@@ -37,7 +36,6 @@ export function Layout() {
<CodingChatBody welcome={!sent} hidden={false} bubble={bubble}>
<div className="classroom-chat-view min-h-0 flex-1 flex-col">{!sent ? <CodingStartHeading /> : <p>第一条消息已发送(测试)</p>}</div>
<div className="classroom-composer-view shrink-0">
{!sent && <CommunityInvitation />}
<CodingComposer value={draft} editable canSend={Boolean(draft)} preparing={false} recovering={false}
runStatus="idle" mode="prompt" queue={{ items: [] }} error={null} recoverableError={false}
attachments={[]} submitting={false} placeholder="说说你的想法,接下来想做什么?" onChange={setDraft}
+29 -4
View File
@@ -1,21 +1,29 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { FormEvent } from 'react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { invokeIpc } from '@/lib/api-client';
import { COMMUNITY_URL } from '@/lib/community';
import { CommunityLink } from '@/components/community/CommunityLink';
import { CommunityInvitation } from '@/components/community/CommunityInvitation';
import { CodingStartHeading } from '@/pages/Chat/CodingStartHeading';
vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
beforeEach(() => { vi.mocked(invokeIpc).mockReset().mockResolvedValue(undefined); });
afterEach(cleanup);
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
it('only opens the public explore URL after a click, without fetching or embedding works', async () => {
const fetch = vi.spyOn(globalThis, 'fetch');
render(<CommunityInvitation />);
render(<CodingStartHeading />);
expect(screen.getByRole('heading', { name: '今天,你想创造什么?' })).toBeInTheDocument();
expect(screen.getByText('一个游戏、一段故事,或是一个天马行空的想法。')).toBeInTheDocument();
expect(screen.queryByTestId('community-invitation')).not.toBeInTheDocument();
expect(invokeIpc).not.toHaveBeenCalled();
expect(fetch).not.toHaveBeenCalled();
expect(document.querySelector('iframe')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: '去作品广场逛逛' }));
const button = screen.getByRole('button', { name: '作品广场' });
expect(button).toHaveAttribute('type', 'button');
expect(button.querySelector('.coding-start-mark img')).not.toBeNull();
expect(button.querySelectorAll('.coding-start-mark > svg')).toHaveLength(2);
fireEvent.click(button);
await waitFor(() => expect(invokeIpc).toHaveBeenCalledExactlyOnceWith('shell:openExternal', COMMUNITY_URL));
fetch.mockRestore();
});
@@ -24,6 +32,7 @@ it('provides an accessible collapsed sidebar entry and a retry after opening fai
vi.mocked(invokeIpc).mockRejectedValueOnce(new Error('unavailable'));
render(<CommunityLink variant="sidebar" collapsed />);
const button = screen.getByRole('button', { name: '作品广场' });
expect(button.querySelector('.community-link-icon svg')).not.toBeNull();
fireEvent.click(button);
await screen.findByRole('alert');
await waitFor(() => expect(button).toBeEnabled());
@@ -31,3 +40,19 @@ it('provides an accessible collapsed sidebar entry and a retry after opening fai
await waitFor(() => expect(invokeIpc).toHaveBeenCalledTimes(2));
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
it('keeps the welcome entry available for retry without submitting its surrounding form', async () => {
const submit = vi.fn((event: FormEvent) => event.preventDefault());
vi.mocked(invokeIpc).mockRejectedValueOnce(new Error('unavailable'));
render(<form onSubmit={submit}><CodingStartHeading /></form>);
const button = screen.getByRole('button', { name: '作品广场' });
fireEvent.click(button);
await screen.findByRole('alert');
await waitFor(() => expect(button).toBeEnabled());
expect(submit).not.toHaveBeenCalled();
fireEvent.click(button);
await waitFor(() => expect(invokeIpc).toHaveBeenCalledTimes(2));
expect(invokeIpc).toHaveBeenLastCalledWith('shell:openExternal', COMMUNITY_URL);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(submit).not.toHaveBeenCalled();
});