From 032ebc313fe52091598ccde0adfca0c78f0ffd77 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sun, 13 Sep 2026 00:05:05 +0800 Subject: [PATCH] fix: simplify conversation composer options Consolidate composer settings into a compact popover while preserving runtime, mode, sending, and queue controls. Place the settings icon after voice input, limit the panel to 300px, and show knowledge expansion state. Include the navigation demo, design guidance, localized labels, and composer regression coverage. Release note: Simplify the conversation input toolbar with compact settings, clearer knowledge expansion, and visible active selections. --- UI-DESIGN.md | 6 +- .../application-tool-navigation/README.md | 36 ++ .../navigation-demo.css | 97 ++++++ .../navigation-demo.html | 78 +++++ .../navigation-demo.js | 291 ++++++++++++++++ src/renderer/src/App.test.tsx | 56 ++- src/renderer/src/App.tsx | 321 +++++++++++------- src/renderer/src/WorkspacePrimitives.test.tsx | 8 +- src/renderer/src/i18n/locales/en-US/app.ts | 1 + src/renderer/src/i18n/locales/zh-CN/app.ts | 1 + src/renderer/src/styles.css | 165 ++++++--- 11 files changed, 871 insertions(+), 189 deletions(-) create mode 100644 docs/features/application-tool-navigation/navigation-demo.css create mode 100644 docs/features/application-tool-navigation/navigation-demo.html create mode 100644 docs/features/application-tool-navigation/navigation-demo.js diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 5b31d50e..ba887f83 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -605,7 +605,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 模式、模型或工具权限属于上下文控制,不与页面导航页签混用。 - 模型、专家角色、工作模式、OpenCode Agent、Continue 预设和 Runtime 快捷操作使用统一的上下文单选菜单,并保持菜单互斥、键盘可达和选中状态明确。 - Runtime 选择菜单必须反映当前执行空间的真实能力。托管 SSH 项目当前只支持 OpenCode,因此输入区只渲染已配置的 OpenCode 选项和管理入口,不显示直连模型、Continue 或 DeepSeek Harness 的不可用入口;旧会话保存的其他 Runtime 选择在项目激活时恢复为当前 OpenCode 配置。 -- 输入区第一行工具栏只承载附件、语音、知识范围、专家角色、工作模式、Runtime 选择和发送等通用操作。OpenCode Agent、Continue 预设及 Runtime 快捷操作必须放入其下方独立的 Runtime 专属功能行,通过可见分组名称、顶部边界和差异化表面与通用操作分层;该行只承载对当前消息生效的高频选择,当前 Runtime 没有可选专属功能时不保留空行。 +- 输入区使用一个完整容器,主工具栏保留附件、语音、Runtime、工作模式、会话选项及停止/发送。知识范围、专家角色、OpenCode Agent、Continue 预设和 Runtime 快捷操作收纳在会话选项浮层中;Runtime 专属选择在浮层内按提供方分组,无可选功能时不保留空分组。输入框上方以简短摘要显示生效的专家、知识范围、检索方式和 Runtime 专属选择。 +- 会话选项浮层与主行菜单互斥,内部复用上下文选择菜单;子菜单就地展开,浮层统一限制宽高并滚动。Escape 先关闭子菜单并恢复其触发按钮焦点,再关闭会话选项并恢复入口焦点;点击外部、焦点离开、切换会话或开始/结束回复时关闭浮层。 +- 会话选项入口紧随录音键,使用无常驻边框的调节图标,保留可访问名称、悬停说明和展开态反馈;浮层在工具栏左侧展开,最大宽度为 `300px`,窄窗口不超过输入区。浮层内选择器使用整行轻量列表样式,避免重复的按钮边框;知识库行右侧显示展开箭头,展开时朝上。 - OpenCode、Continue 和 DeepSeek Harness 后续的 Task 级委派与取消、后台执行进度/结果、Workflow/Hook 运行、长任务暂停/恢复/终止及原生会话监督统一进入应用级助手工作栏固定的“Runtime”栏目,不加入 Composer。栏目入口始终存在;只选择 Conversation 或 Task,并按 Task 聚合执行状态,不显示 Job/Run 树或独立操作菜单。内部可选区域按用户所选目标和 Runtime 的真实能力显示,不为未支持能力渲染空卡片或成排禁用按钮。切换跟随目标时必须清理上一归属的监督状态,固定目标则保持不变。 - 设置中心只管理持久 Runtime 配置、默认值和能力清单;右侧 Runtime 栏目管理用户当前跟随或固定目标的生命周期。两处不得复制同一实时操作,栏目中的高风险操作仍须就地确认并保留取消、权限、用量和活动审计。 - Runtime Prompt 快捷操作只把模板填入输入草稿,用户可以继续编辑;OpenCode Command 由 Runtime 原生 API 执行,输入框只承载可选参数,不以普通斜杠文本冒充执行。 @@ -618,7 +620,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 队列区域向上增长并设置有界滚动高度,不提供展开、收起或冗余计数标题。宽容器中的操作采用弱化的行内样式,不渲染成强调卡片或胶囊;窄容器中保留来源、摘要和图标操作并隐藏冗长按钮文字。区域、来源和每个操作必须有可读无障碍名称。队列异步错误进入应用通知,不在 Composer 内复制通知样式。 - 支持上下文状态的 Runtime 在输入区下方复用同一紧凑用量条;文案必须区分“本次模型调用”和“压缩后对话估算”。手动压缩仅在当前 Runtime 明确支持且没有活动回复时显示,作为元信息区左下角的浮动次操作,不参与输入区高度计算;元信息区始终预留稳定高度,切换 Runtime 不得让输入框上下位移。元信息区与窗口底部只保留紧凑安全留白,不形成额外空白区。进行中禁用重复操作,结果通过应用通知反馈。 - 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。 -- 宽度大于 `700px` 时,通用工具栏内的添加内容、知识范围、专家、模式和 Runtime 选择保持同一行,Runtime 专属功能在自己的下一行横向排列。窄输入区中两行分别换行,专属选择器以至少 `220px` 的基准宽度换行而不是被挤压;不能把专属控件重新塞回通用工具栏。 +- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。 - 输入框原生支持 `Ctrl+V`:文本直接进入草稿,图片转换为本次消息附件。文件选择由上传按钮承担,不再提供独立“读取剪贴板”按钮;默认工具栏也不提供“截取当前屏幕”和“选择应用窗口”入口,避免与系统粘贴、文件选择和后续工具执行重复。 - “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。 - 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。 diff --git a/docs/features/application-tool-navigation/README.md b/docs/features/application-tool-navigation/README.md index d5777bbb..82b3f0a2 100644 --- a/docs/features/application-tool-navigation/README.md +++ b/docs/features/application-tool-navigation/README.md @@ -13,6 +13,42 @@ | [功能逻辑设计](./logic-design.md) | 分类规则、入口状态、优先级与失败结果 | | [UI 设计](./ui-design.md) | 侧栏信息架构、控件、文案、键盘和响应式行为 | | [技术设计](./technical-design.md) | 现有模块映射、状态契约、持久化和实施顺序 | +| [应用中心交互 Demo](./navigation-demo.html) | 对比六个候选方案;配套 CSS 和 JS 仅服务原型 | + +Demo 可直接在浏览器中打开,支持方案切换、浅深主题、添加、移除、排序和商店模拟。 +六个方案共用本次页面会话内的应用配置,刷新后恢复默认。商店、资源指标和工具内容均为 +演示数据,不执行真实安装或本机操作。窄屏上下排列侧栏与输入区,桌面展示完整应用布局。 +底部输入区共用一个简化候选布局:附件、语音、Runtime、模式和发送位于主操作行,专家、 +知识范围及 Runtime 专属选项通过“选项”展开,非默认选择显示为可移除标签。可体验文件名 +附件、模拟语音、模板填入、原生命令选择、8 秒模拟回复、发送排队、立即插入、删除、停止, +以及上下文详情和模拟压缩。文件只读取名称,语音使用固定示例文本,不录音、不上传、不调用 AI。 +这是输入区设计讨论用的原型,尚未改变正式产品或替代现有 UI 规范。 + +六个方案共用“我的应用”区:通过 + 或“全部应用”选择常驻应用,编辑时可拖动或使用 +上下箭头排序、移除入口。未常驻的已安装应用仍可从“全部应用”打开。商店包含“项目管理” +和“阅读空间”应用示例,支持仅安装或安装并常驻左侧菜单,常驻不表示后台运行。 +常驻应用在本次页面会话内跨方案保持一致;“恢复默认应用”重置安装与常驻配置。 +移除入口不会卸载应用或关闭当前页面,刷新后恢复默认。 + +魔法笔记提供应用中心“设置”、笔记页面“笔记设置”和全局设置演示入口,均打开同一设置 +面板,共用本次页面会话内的配置。面板沿用现有选项:显示未完成待办数量、AI 评论方式 +(即时/保存后自动/保存后手动)、AI 评论形式(长评 + 要点/长评/要点)。现有“显示 +魔法笔记入口”在原型中与“常驻左侧菜单”统一,不额外增加功能启停开关。侧栏待办数为 +固定的 3 项演示数据。修改即时生效于原型,不调用 AI 或写入真实应用设置。 + +| 方案 | 讨论重点 | +| --- | --- | +| A:紧凑入口 | 从底部向上展开应用列表 | +| B:应用宫格 | 在展开面板中展示应用图标、名称和常驻操作 | +| C:应用预览 | 用卡片说明应用用途 | +| D:搜索启动台 | 搜索打开能力,支持 Ctrl / Command K、方向键与 Enter | +| E:常驻管理 | 已常驻应用优先排列,可取消常驻或打开 | +| F:商店发现(未来) | 默认展开模拟商店,不纳入首期 | + +D 参考 [Raycast](https://www.raycast.com/) 的搜索启动与扩展发现方式。早期方案参考过 +Notion 收藏和 VS Code Profiles,当前版本已移除场景工具包与快捷区。 +原型支持 URL 的 `#launcher`、`#favorites`、 +`#scenes` 等片段直接打开指定方案。 ## 术语 diff --git a/docs/features/application-tool-navigation/navigation-demo.css b/docs/features/application-tool-navigation/navigation-demo.css new file mode 100644 index 00000000..1026e2cf --- /dev/null +++ b/docs/features/application-tool-navigation/navigation-demo.css @@ -0,0 +1,97 @@ +:root { + color-scheme: light; + --surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb; + --text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7; + --accent: #086fc7; --accent-subtle: #e8f4ff; --accent-selected: #a7cfea; + --font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif; + --radius-control: 10px; --radius-card: 14px; +} +[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --accent-selected: #28668f; } +* { box-sizing: border-box; } +body { margin: 0; padding: 24px 32px; font: 13px/1.6 var(--font-family-ui); background: var(--surface-subtle); color: var(--text-primary); } +button, input, textarea { font: inherit; color: inherit; } +button { cursor: pointer; border: 0; background: transparent; border-radius: var(--radius-control); padding: 8px 12px; } +button:hover { background: var(--accent-subtle); color: var(--accent); } +button:focus-visible, input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +button:disabled { opacity: .45; cursor: default; } +svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; vertical-align: middle; flex-shrink: 0; } +p, h1, h2 { margin: 0; } h1 { font-size: 30px; font-weight: 550; margin: 12px 0; letter-spacing: -.8px; } h2 { font-size: 24px; } +.muted, .eyebrow { color: var(--text-secondary); }.eyebrow { font-size: 11px; letter-spacing: 1px; } +.lab-header, .design-note, .lab-footer { display: flex; align-items: center; justify-content: space-between; gap: 24px; max-width: 1440px; margin: auto; } +.lab-header strong { font-size: 16px; }.lab-header p { color: var(--text-secondary); font-size: 12px; margin-top: 4px; } +.variants { display: grid; grid-template-columns: repeat(3, 1fr); padding: 4px; border: 1px solid var(--border-default); border-radius: var(--radius-control); } +.variants button[aria-pressed="true"] { background: var(--surface-raised); color: var(--accent); box-shadow: 0 1px 3px var(--border-default); } +.design-note { margin: 24px auto 12px; font-size: 12px; }.design-note #variant-label { color: var(--accent); font-weight: 600; white-space: nowrap; }.design-note p { flex: 1; }.design-note > span:last-child { color: var(--text-secondary); font-size: 11px; } +.app { display: flex; max-width: 1440px; height: min(780px, calc(100dvh - 204px)); min-height: 590px; margin: auto; border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); } +.sidebar { width: 278px; flex: 0 0 278px; background: var(--surface-canvas); display: flex; flex-direction: column; padding: 20px 12px 8px; border-right: 1px solid var(--border-default); } +.brand { display: flex; gap: 8px; align-items: center; padding: 0 8px 24px; }.brand > .muted { margin-left: auto; font-size: 11px; }.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; background: var(--text-primary); color: var(--surface-raised); border-radius: 10px; font-weight: 650; font-size: 12px; } +.new-chat { border: 1px solid var(--border-default); background: var(--surface-raised); text-align: left; margin-bottom: 16px; }.new-chat span { margin-right: 8px; } +.navigation { display: grid; gap: 3px; }.navigation button, .fixed-entry button { display: flex; align-items: center; gap: 10px; text-align: left; padding: 9px 10px; }.navigation .selected { background: var(--accent-subtle); color: var(--accent); } +.history { flex: 1; min-height: 72px; overflow: auto; padding: 24px 0 8px; }.history .eyebrow { display: block; padding: 0 10px 8px; }.history button { display: block; width: 100%; text-align: left; color: var(--text-secondary); font-size: 12px; } +.fixed-entry { border-top: 1px solid var(--border-default); padding: 8px 0; }.fixed-entry button { width: 100%; } +.shortcuts { border-top: 1px solid var(--border-default); padding-top: 12px; max-height: 52%; overflow: auto; flex-shrink: 0; }.section-label { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; padding-left: 8px; font-size: 11px; color: var(--text-secondary); }.section-label button { font-size: 11px; padding: 4px 8px; } +.shortcut-list { display: grid; gap: 4px; }.shortcut-item { min-width: 0; }.shortcut-button { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; }.shortcut-button.active { color: var(--accent); background: var(--accent-subtle); }.shortcut-button .icon-tile { display: grid; place-items: center; }.shortcut-button .item-name { font-size: 11px; } +[data-variant="dock"] .shortcut-list { grid-template-columns: repeat(5, minmax(0, 1fr)); background: var(--surface-raised); padding: 4px; border: 1px solid var(--border-default); border-radius: var(--radius-card); } +[data-variant="dock"] .shortcut-button { padding: 10px 0; flex-direction: column; gap: 4px; }[data-variant="dock"] .shortcut-button .item-name { display: none; }[data-variant="dock"] .shortcut-list.editing { grid-template-columns: repeat(3, minmax(0, 1fr)); }[data-variant="dock"] .editing .item-name { display: block; } +.store-link { margin-top: 8px; padding: 8px; display: flex; align-items: center; width: 100%; gap: 8px; text-align: left; font-size: 12px; }.store-link span:last-child { margin-left: auto; color: var(--text-secondary); } +[data-variant="grid"] .shortcut-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }[data-variant="grid"] .shortcut-button { flex-direction: column; gap: 4px; padding: 8px 4px; }[data-variant="grid"] .icon-tile { width: 36px; height: 36px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); } +.featured { border: 1px solid var(--border-default); border-radius: var(--radius-control); padding: 12px; margin-top: 12px; background: var(--surface-raised); position: relative; }.featured p { font-size: 11px; color: var(--text-secondary); margin: 4px 0; }.featured .dismiss { position: absolute; right: 4px; top: 4px; padding: 0 8px; }.featured .feature-open { color: var(--accent); padding: 4px 0 0; font-size: 11px; } +[data-variant="cards"] .shortcut-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }[data-variant="cards"] .shortcut-button { background: var(--surface-raised); border: 1px solid var(--border-default); padding: 12px 8px; flex-direction: column; align-items: flex-start; }[data-variant="cards"] .shortcut-button .item-name { font-size: 12px; }.card-detail { font-size: 11px; color: var(--text-secondary); text-align: left; }.mini-meter { display: flex; gap: 3px; align-items: end; height: 24px; }.mini-meter i { display: block; width: 5px; background: var(--accent-selected); border-radius: 2px; }.mini-meter i:nth-child(3n) { background: var(--accent); } +.edit-actions { display: flex; justify-content: center; }.edit-actions button { padding: 2px 6px; font-size: 12px; }.sidebar-footer { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; padding-left: 8px; font-size: 11px; } +.workspace { flex: 1; min-width: 0; display: flex; flex-direction: column; }.workspace-header { display: flex; justify-content: space-between; padding: 16px 24px; border-bottom: 1px solid var(--border-default); font-size: 12px; }.conversation { flex: 1; padding: 64px 40px 24px; max-width: 740px; width: 100%; margin: auto; display: flex; flex-direction: column; justify-content: center; align-items: flex-start; }.suggestions { display: flex; gap: 12px; margin-top: 32px; flex-wrap: wrap; }.suggestions button { border: 1px solid var(--border-default); font-size: 12px; }.suggestions span { margin-left: 24px; } +.composer-area { margin: 16px 24px 8px; flex-shrink: 0; } +.composer { padding: 12px; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-subtle); } +.composer:focus-within { border-color: var(--accent-selected); } +.composer textarea { display: block; width: 100%; height: 76px; min-height: 76px; max-height: 180px; padding: 4px; border: 0; resize: none; background: transparent; line-height: 1.8; } +.composer textarea::placeholder { color: var(--text-secondary); font-size: 12px; } +.composer-toolbar { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; margin-top: 8px; } +.composer-toolbar button { padding: 6px 8px; font-size: 12px; white-space: nowrap; } +.composer-toolbar .composer-icon { width: 32px; height: 32px; padding: 4px; font-size: 20px; } +.composer-select select { max-width: 132px; padding: 6px 2px; border: 0; background: transparent; cursor: pointer; font: inherit; font-size: 12px; color: var(--text-primary); } +select option { background: var(--surface-raised); color: var(--text-primary); } +select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.composer-actions { display: flex; gap: 4px; margin-left: auto; } +#send { width: 32px; height: 32px; padding: 0; font-size: 20px; background: var(--accent); color: var(--surface-raised); } +#voice[aria-pressed="true"] { background: var(--accent-subtle); color: var(--accent); } +.composer-meta { display: flex; justify-content: space-between; align-items: center; gap: 8px; min-height: 32px; color: var(--text-secondary); font-size: 10px; } +.composer-meta button { font-size: 10px; padding: 4px; } +#composer-tags { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 8px; max-height: 96px; overflow: auto; } +#composer-tags button { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 3px 8px; font-size: 11px; background: var(--accent-subtle); color: var(--accent); } +#composer-options, #context-details { position: fixed; inset: auto; margin: 0; width: min(320px, calc(100vw - 32px)); max-height: min(460px, 70dvh); overflow: auto; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); box-shadow: 0 8px 32px #07131f26; font-size: 12px; } +#composer-options header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; } +.option-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; padding: 10px 0; } +.option-row select { width: 166px; padding: 6px; border: 1px solid var(--border-default); border-radius: 6px; font: inherit; background: var(--surface-subtle); color: var(--text-primary); } +#composer-options fieldset { margin: 8px 0; padding: 12px 0; border: 0; border-top: 1px solid var(--border-default); } +#composer-options fieldset label { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; } +#composer-options input { accent-color: var(--accent); } +#composer-options p { margin-top: 12px; font-size: 11px; } +#context-details p { margin: 8px 0; color: var(--text-secondary); } +#context-details progress { width: 100%; accent-color: var(--accent); } +#compact { margin-top: 8px; color: var(--accent); background: var(--accent-subtle); } +#demo-queue { max-height: 96px; overflow: auto; margin-bottom: 8px; } +.queue-row { display: flex; align-items: center; gap: 8px; font-size: 11px; } +.queue-row span { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.queue-row button { flex-shrink: 0; padding: 4px; font-size: 11px; } +.workbar { width: 280px; flex-shrink: 0; border-left: 1px solid var(--border-default); background: var(--surface-subtle); }.workbar header { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border-default); }#tool-content { padding: 24px 16px; }.metric { margin: 20px 0; }.metric strong { float: right; }.metric progress { width: 100%; accent-color: var(--accent); }.file-row { padding: 12px 0; border-bottom: 1px solid var(--border-default); }pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; } +.lab-footer { margin-top: 12px; font-size: 12px; color: var(--text-secondary); } +dialog { width: min(580px, calc(100% - 32px)); max-height: 85dvh; border: 1px solid var(--border-default); padding: 24px; border-radius: var(--radius-card); background: var(--surface-raised); color: var(--text-primary); }dialog::backdrop { background: #07131f80; }dialog header { display: flex; justify-content: space-between; align-items: start; }dialog > p { margin: 12px 0 16px; }dialog input { width: 100%; padding: 10px 12px; background: var(--surface-subtle); border: 1px solid var(--border-default); border-radius: var(--radius-control); }.catalog-row { display: flex; align-items: center; gap: 12px; padding: 16px 0; border-bottom: 1px solid var(--border-default); }.catalog-row > div { flex: 1; }.catalog-row p { font-size: 11px; color: var(--text-secondary); }.catalog-row button { color: var(--accent); background: var(--accent-subtle); font-size: 12px; }dialog footer { font-size: 11px; color: var(--text-secondary); margin-top: 16px; } +#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); padding: 10px 20px; border-radius: var(--radius-control); background: var(--text-primary); color: var(--surface-raised); pointer-events: none; opacity: 0; }#toast.visible { opacity: 1; } +[hidden] { display: none !important; } +#page-actions { margin-left: auto; }#page-actions button { padding: 4px 8px; font-size: 12px; }.note-count { margin-left: auto; padding: 0 6px; background: var(--accent-subtle); border-radius: var(--radius-control); color: var(--accent); }.note-setting-row { display: flex; gap: 16px; align-items: center; justify-content: space-between; padding: 16px 0; border-bottom: 1px solid var(--border-default); }.note-setting-row p, .note-setting-options p { font-size: 11px; color: var(--text-secondary); margin-top: 6px; }.demo-switch { width: 36px; height: 20px; border-radius: 20px; border: 1px solid var(--text-secondary); background: var(--surface-canvas); padding: 2px; flex-shrink: 0; }.demo-switch::after { content: ''; display: block; width: 14px; height: 14px; border-radius: 50%; background: var(--text-secondary); }.demo-switch[aria-checked="true"] { background: var(--accent); border-color: var(--accent); }.demo-switch[aria-checked="true"]::after { margin-left: 16px; background: var(--surface-raised); }.note-setting-options { border: 0; padding: 16px 0; margin: 0; min-width: 0; }.note-setting-options legend { padding-top: 16px; font-weight: 550; }.note-segments { display: flex; padding: 4px; gap: 4px; background: var(--surface-subtle); border: 1px solid var(--border-default); border-radius: var(--radius-control); }.note-segments button { flex: 1; padding: 8px 4px; font-size: 12px; }.note-segments [aria-pressed="true"] { color: var(--accent); background: var(--surface-raised); box-shadow: 0 1px 3px var(--border-default); }.note-settings-preview { padding: 16px; background: var(--accent-subtle); border-radius: var(--radius-control); }.note-settings-preview small { color: var(--text-secondary); font-size: 11px; }#back-to-apps { margin-bottom: 12px; padding-left: 0; color: var(--accent); }#note-settings-summary { margin: 16px 0; } +.sidebar-footer { border-top: 1px solid var(--border-default); padding: 8px 0 0; gap: 4px; }#center-entry { flex: 1; min-width: 0; }#open-center { width: 100%; display: flex; align-items: center; gap: 10px; padding: 8px; text-align: left; }#open-center strong { font-size: 13px; font-weight: 550; }#open-center small { display: block; font-size: 11px; color: var(--text-secondary); }.entry-arrow { margin-left: auto; }.tool-tabs { display: flex; padding: 8px; gap: 2px; }.tool-tabs button { flex: 1; padding: 8px 4px; font-size: 12px; }.tool-tabs [aria-selected="true"] { background: var(--accent-subtle); color: var(--accent); }.center-switch { display: flex; gap: 4px; padding-bottom: 12px; }.center-switch [aria-pressed="true"] { background: var(--accent-subtle); color: var(--accent); }.center-app { display: flex; gap: 12px; align-items: start; padding: 16px 0; border-bottom: 1px solid var(--border-default); }.center-app > svg { margin-top: 4px; color: var(--accent); }.center-app-info { flex: 1; }.center-app p { color: var(--text-secondary); font-size: 11px; }.center-actions { display: flex; gap: 4px; flex-wrap: wrap; }.center-actions button { font-size: 11px; background: var(--accent-subtle); color: var(--accent); }.center-actions [aria-pressed="true"] { border: 1px solid var(--accent-selected); } +dialog[data-variant="dock"], dialog[data-variant="grid"], dialog[data-variant="favorites"] { position: fixed; left: 48px; bottom: 100px; top: auto; right: auto; margin: 0; width: min(520px, calc(100% - 32px)); max-height: 72dvh; } +dialog[data-variant="grid"] #catalog-items { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 16px; }dialog[data-variant="grid"] .center-app { flex-direction: column; padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-control); }dialog[data-variant="cards"] .center-app { padding: 24px 0; }dialog[data-variant="cards"] .center-app > svg { width: 32px; height: 32px; }dialog[data-variant="cards"] .app-detail { margin-top: 8px; font-size: 13px; }dialog[data-variant="favorites"] .app-detail { display: none; }dialog[data-variant="scenes"] .center-app { background: var(--surface-subtle); border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 20px; margin-top: 16px; flex-wrap: wrap; }dialog[data-variant="scenes"] .center-actions { width: 100%; justify-content: flex-end; } +@media (max-width: 640px) { dialog[data-variant] { left: 16px; right: 16px; bottom: 16px; top: auto; margin: 0; max-height: 85dvh; width: calc(100% - 32px); padding: 16px; }.center-app { flex-wrap: wrap; }.center-actions { width: 100%; }.app { flex-direction: column; }.app .sidebar { flex: 1; min-height: 0; }.app .workspace { display: block; flex: 0 0 auto; }.app .workspace .conversation, .app .workspace .composer { display: none; }.app .workspace-header { padding: 8px 16px; align-items: center; }.app .workbar { z-index: 2; } } +.reference-note { max-width: 1440px; margin: 12px auto 0; display: flex; flex-wrap: wrap; gap: 8px 16px; color: var(--text-secondary); font-size: 11px; }.reference-note a { color: var(--accent); text-underline-offset: 3px; } +.sidebar > * { flex-shrink: 0; }.sidebar .history { flex: 1 1 80px; min-height: 0; }.sidebar .shortcuts { flex-shrink: 1; min-height: 80px; } +.sidebar .resident-apps { margin-top: 12px; min-height: 96px; flex-shrink: 1; display: flex; flex-direction: column; max-height: 240px; }.resident-apps .section-label { margin-bottom: 4px; flex-shrink: 0; }#resident-list { overflow: auto; min-height: 0; }.resident-row { display: flex; align-items: center; }.resident-row > button { display: flex; align-items: center; gap: 10px; padding: 8px 10px; flex: 1; min-width: 0; text-align: left; }.resident-row > button span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }.resident-row [aria-current="page"] { background: var(--accent-subtle); color: var(--accent); }.resident-row[draggable="true"] { cursor: grab; }.resident-row .edit-actions { flex-shrink: 0; }.all-apps { display: flex; justify-content: space-between; padding: 6px 10px; font-size: 11px; color: var(--text-secondary); flex-shrink: 0; }.empty-apps { padding: 8px 10px; font-size: 11px; color: var(--text-secondary); }.app-placement { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }.app-placement button { font-size: 11px; padding: 6px 8px; }.app-placement button[aria-pressed="true"] { border: 1px solid var(--accent-selected); }.app-catalog-row { align-items: flex-start; }.app-catalog-row > svg { margin-top: 4px; } +.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); } +.launcher-trigger { display: flex; align-items: center; gap: 8px; width: 100%; padding: 12px 8px; border: 1px solid var(--border-default); background: var(--surface-raised); margin-bottom: 12px; font-size: 12px; }.launcher-trigger kbd { margin-left: auto; font: 10px var(--font-family-ui); color: var(--text-secondary); white-space: nowrap; } +[data-variant="launcher"] .shortcut-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }[data-variant="launcher"] .shortcut-button { flex-direction: column; padding: 8px 0; }[data-variant="launcher"] .icon-tile { height: 24px; }[data-variant="launcher"] .store-link { border-top: 1px solid var(--border-default); border-radius: 0; } +[data-variant="favorites"] .section-label { padding-left: 0; }[data-variant="favorites"] .shortcut-list { gap: 0; }[data-variant="favorites"] .shortcut-button { justify-content: flex-start; padding: 8px 12px; }[data-variant="favorites"] .item-name { font-size: 12px; }[data-variant="favorites"] .shortcut-item { display: flex; align-items: center; }[data-variant="favorites"] .edit-actions { margin-left: auto; }[data-variant="favorites"] .store-link { margin-top: 12px; border: 1px dashed var(--border-default); }.count { padding: 0 6px; color: var(--text-secondary); } +.scene-banner { display: flex; align-items: center; gap: 12px; margin: 0 4px 12px; padding: 12px; background: var(--accent-subtle); border-radius: var(--radius-control); }.scene-banner > svg { color: var(--accent); width: 24px; height: 24px; }.scene-banner .eyebrow { letter-spacing: 0; font-size: 10px; }.scene-count { margin-left: auto; font-size: 11px; color: var(--text-secondary); }.scene-label { display: block; }.scene-label select { font: inherit; font-weight: 600; color: var(--text-primary); background: transparent; border: 0; padding: 0; cursor: pointer; }.scene-label option { background: var(--surface-raised); } +[data-variant="scenes"] .shortcut-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }[data-variant="scenes"] .shortcut-button { border: 1px solid var(--border-default); background: var(--surface-raised); padding: 10px 8px; justify-content: flex-start; }.pack-preview { display: flex; gap: 8px; align-items: center; width: 100%; margin-top: 12px; padding: 12px 8px; text-align: left; background: var(--surface-raised); border: 1px solid var(--border-default); }.pack-preview strong { font-size: 11px; font-weight: 550; }.pack-preview small { display: block; color: var(--text-secondary); font-size: 10px; }.pack-preview > span:last-child { margin-left: auto; }.pack-symbol { color: var(--accent); } +.catalog-pack { padding: 20px 0; border-bottom: 1px solid var(--border-default); }.catalog-pack h3 { margin: 8px 0; font-size: 18px; font-weight: 550; }.pack-art { display: grid; place-items: center; float: right; width: 56px; height: 56px; border: 1px solid var(--accent-selected); background: var(--accent-subtle); color: var(--accent); border-radius: var(--radius-card); }.pack-art svg { width: 28px; height: 28px; }.pack-includes { display: flex; flex-wrap: wrap; gap: 8px; margin: 16px 0; }.pack-includes span { padding: 4px 8px; font-size: 11px; background: var(--surface-subtle); border-radius: var(--radius-control); }.pack-includes svg { width: 14px; height: 14px; }.catalog-pack > button { border: 1px solid var(--accent-selected); color: var(--accent); background: var(--accent-subtle); } +dialog[data-mode="launch"] { width: min(640px, calc(100% - 32px)); }dialog[data-mode="launch"] .catalog-row:focus-within { background: var(--accent-subtle); }.catalog-row { padding-left: 8px; padding-right: 8px; } +@media (max-width: 1080px) { body { padding: 16px; }.app { position: relative; }.workbar { position: absolute; right: 0; top: 52px; bottom: 0; width: min(320px, 100%); box-shadow: -8px 0 24px var(--border-default); }.lab-header { flex-wrap: wrap; gap: 12px; }.design-note > span:last-child { display: none; }.conversation { padding: 32px 24px; } } +@media (max-width: 640px) { body { padding: 12px; }.lab-header > div:first-child { flex: 1; }.variants { order: 3; width: 100%; }.variants button { flex: 1; padding: 8px 4px; font-size: 12px; }.design-note { align-items: flex-start; gap: 12px; }.app { height: auto; min-height: 0; }.app .sidebar { width: 100%; flex: 0 0 auto; border: 0; padding-top: 12px; }.brand { padding-bottom: 12px; }.app .history { display: none; }.app .workspace { display: flex; min-height: 320px; border-top: 1px solid var(--border-default); }.app .workspace .composer { display: block; }.composer-area { margin: 12px 12px 4px; margin-top: auto; }.composer-toolbar { gap: 2px; }.composer-select select { max-width: 108px; font-size: 11px; }.composer-toolbar button { font-size: 11px; padding: 6px; }.composer-toolbar .composer-icon { width: 28px; }.composer-meta { flex-wrap: wrap; gap: 0; }.shortcut-list { max-height: 300px; overflow: auto; }.lab-footer { align-items: flex-start; }.lab-footer button { white-space: nowrap; }.workbar { top: 0; width: 100%; }.design-note p { font-size: 11px; } } diff --git a/docs/features/application-tool-navigation/navigation-demo.html b/docs/features/application-tool-navigation/navigation-demo.html new file mode 100644 index 00000000..36e90b11 --- /dev/null +++ b/docs/features/application-tool-navigation/navigation-demo.html @@ -0,0 +1,78 @@ + + + + + + GoodBuddy · 应用中心设计实验 + + + + +
+
GoodBuddy / 设计实验

左下角应用中心 · 六个交互方案

+
+ + + + + + +
+ +
+
A / 紧凑 Dock

交互原型 · 商店与指标均为演示数据
+
+ +
+
当前对话
+
+ LET'S MAKE SOMETHING +

从一个想法开始。

+

对话留在这里,常用能力随手打开。

+
+
+
+ +
+ + +
+ + + + + + +
+
+
+
会话选项
+ +
知识范围
+ +
+

非默认选项会显示在输入框上方。

+
+
+
交互演示 · 不调用 AI
+
上下文用量 · 示例

本次模型调用:84k / 200k tokens

+
+
+ +
+ +
设计参考:Raycast · 搜索与扩展Notion · 收藏与折叠分区VS Code · Profiles借鉴交互思路,保留 GoodBuddy 的视觉与入口分类。
+
GOODBUDDY COLLECTION

应用中心

常驻只表示菜单入口,不表示后台运行。安装与内容均为演示。
+
+ + diff --git a/docs/features/application-tool-navigation/navigation-demo.js b/docs/features/application-tool-navigation/navigation-demo.js new file mode 100644 index 00000000..a83ff57f --- /dev/null +++ b/docs/features/application-tool-navigation/navigation-demo.js @@ -0,0 +1,291 @@ +/* global document, window, history, location, matchMedia, innerWidth, Event, setTimeout, clearTimeout */ +const paths = { + note: '', + book: '', + chat: '', + history: '', + settings: '', + activity: '', + layers: '', + search: '' +}; +const icon = name => ``; +const items = [ + { id: 'notes', name: '魔法笔记', icon: 'note', detail: '记录想法、整理待办', summary: '把刚才的灵感留在这里', builtin: true }, + { id: 'knowledge', name: '知识库', icon: 'book', detail: '整理资料、检索知识', summary: '下一次提问,从已有知识开始', builtin: true }, + { id: 'heartbeat', name: '智能心跳', icon: 'activity', detail: '管理定时任务与主动提醒', summary: '让例行任务按计划发生', builtin: true }, + { id: 'projects', name: '项目管理', icon: 'layers', detail: '跟踪项目、任务与进度', summary: '把一个想法变成可跟进的项目', builtin: false }, + { id: 'reading', name: '阅读空间', icon: 'book', detail: '集中阅读、批注与摘录', summary: '给长文和深度阅读留一个空间', builtin: false } +]; +const variants = { + dock: ['A / 紧凑入口', '最底部保留一个应用中心入口,向上展开紧凑列表。', '适合希望把侧栏空间留给对话的用户。'], + grid: ['B / 应用宫格', '底部入口展示应用数量,展开后用宫格浏览已安装应用。', '图标与名称同时可见,常驻位置由用户选择。'], + cards: ['C / 应用预览', '入口展示内置应用图标,展开后用卡片说明每个应用的用途。', '适合通过内容理解应用,卡片不代表后台持续运行。'], + launcher: ['D / 搜索启动台', '最底部就是搜索入口,也可以按 Ctrl / ⌘ K 打开。', '输入名称,用方向键与 Enter 打开已安装应用。'], + favorites: ['E / 常驻管理', '从底部管理哪些应用常驻菜单,未常驻应用也可直接打开。', '常驻应用优先排列;入口显隐不影响安装和运行状态。'], + scenes: ['F / 商店发现', '底部入口强调应用发现,展开后查看商店应用与安装位置。', '先模拟安装,再决定是否常驻;我的应用始终可切换访问。'] +}; +const $ = selector => document.querySelector(selector); +let variant = 'dock'; +let catalogMode = 'apps'; +let residentApps = ['notes', 'knowledge']; +let editingApps = false; +let draggedApp = ''; +let installed = new Set(items.filter(item => item.builtin).map(item => item.id)); +let toastTimer; +let settingsOpen = false; +const noteSettings = { showCount: true, mode: 'immediate', format: 'combined' }; +const noteModes = { immediate: '即时', 'after-save-auto': '保存后自动', 'after-save-manual': '保存后手动' }; +const noteFormats = { combined: '长评 + 要点', narrative: '长评', structured: '要点' }; +function renderNoteSettings() { + $('#app-settings').innerHTML = `
常驻左侧菜单

对应“显示魔法笔记入口”,移除后仍可从应用中心打开。

+
显示未完成待办数量

在左侧入口显示数量,超过 99 项显示 99+。本 Demo 使用 3 项示例。

+
AI 评论方式
${Object.entries(noteModes).map(([value, label]) => ``).join('')}

即时:按回车并停止输入 5 秒后评论未保存草稿。保存后自动:保存后评论。保存后手动:点击 AI 分析后评论。

+
AI 评论形式
${Object.entries(noteFormats).map(([value, label]) => ``).join('')}

默认同时生成流式长评和结构化要点,也可只保留其中一种。

+
当前配置预览

${noteModes[noteSettings.mode]} · ${noteFormats[noteSettings.format]}

选择后即时保留于本次 Demo 会话,不调用 AI、不修改真实设置。
`; + if ($('#note-settings-summary')) $('#note-settings-summary').textContent = `当前设置:${noteModes[noteSettings.mode]} · ${noteFormats[noteSettings.format]}。待办数量${noteSettings.showCount ? '显示' : '隐藏'}。`; +} +function showNoteSettings() { + if (!$('#catalog').open) { catalogMode = 'apps'; $('#search').value = ''; } + settingsOpen = true; + $('#catalog').dataset.variant = variant; + $('#catalog-title').textContent = '魔法笔记设置'; + $('#catalog-description').textContent = '调整入口与 AI 评论行为。应用中心和笔记页面共享这份演示配置。'; + $('#back-to-apps').hidden = false; $('#app-settings').hidden = false; + for (const selector of ['.center-switch', '#search', '#catalog-items']) $(selector).hidden = true; + renderNoteSettings(); + if (!$('#catalog').open) $('#catalog').showModal(); + $('#back-to-apps').focus(); +} +function notify(text) { + $('#toast').textContent = text; $('#toast').classList.add('visible'); + clearTimeout(toastTimer); toastTimer = setTimeout(() => $('#toast').classList.remove('visible'), 2200); +} +function renderApps() { + $('#edit-apps').textContent = editingApps ? '完成' : '编辑'; + $('#resident-list').innerHTML = residentApps.map((id, index) => { + const item = items.find(entry => entry.id === id); + return `
${editingApps ? `
` : ''}
`; + }).join('') || '

点击 +,选择常驻应用。

'; + const subtitles = { dock: '', grid: `${installed.size} 个已安装应用`, cards: '笔记 · 知识 · 自动化', launcher: 'Ctrl / ⌘ K', favorites: `${residentApps.length} 个应用常驻菜单`, scenes: '发现你的下一个好应用' }; + $('#center-entry').innerHTML = ``; +} +function renderCatalog() { + const query = $('#search').value.trim().toLowerCase(); + const matches = items.filter(item => (catalogMode === 'store' ? !item.builtin : installed.has(item.id)) && `${item.name}${item.detail}`.toLowerCase().includes(query)); + if (variant === 'favorites') matches.sort((a, b) => Number(residentApps.includes(b.id)) - Number(residentApps.includes(a.id))); + $('#catalog-items').innerHTML = matches.map(item => { + const ready = installed.has(item.id); const pinned = residentApps.includes(item.id); + return `
${icon(item.icon)}
${item.name}

${item.builtin ? '内置应用' : '商店应用 · 示例'}

${variant === 'cards' ? item.summary : item.detail}

${ready ? `${item.id === 'notes' ? '' : ''}` : ``}
`; + }).join('') || '

没有匹配的应用。可以换个关键词,或切换到商店。

'; + document.querySelectorAll('.center-switch button').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.catalog === catalogMode))); +} +function openCatalog(mode = 'apps') { + settingsOpen = false; $('#back-to-apps').hidden = true; $('#app-settings').hidden = true; + for (const selector of ['.center-switch', '#search', '#catalog-items']) $(selector).hidden = false; + catalogMode = mode; $('#catalog').dataset.variant = variant; + $('#catalog-title').textContent = mode === 'store' ? '发现新应用' : '应用中心'; + $('#catalog-description').textContent = mode === 'store' ? '安装你需要的应用,自主决定是否常驻左侧菜单。' : '打开应用,或把常用应用留在左侧。系统工具请使用右侧工作栏。'; + $('#search').value = ''; renderCatalog(); + if (!$('#catalog').open) $('#catalog').showModal(); + $('#search').focus(); +} +function openPage(name) { + if ($('#catalog').open) $('#catalog').close(); + $('#page-label').textContent = name; + $('#page-content').replaceChildren(); + const title = document.createElement('h1'); title.textContent = name; + const description = document.createElement('p'); description.className = 'muted'; description.textContent = '主内容区页面预览。右侧工作栏保持当前工具状态。'; + $('#page-content').append(title, description); + $('#page-actions').innerHTML = name === '魔法笔记' ? `` : ''; + if (name === '魔法笔记' || name === '设置') { + const summary = document.createElement('p'); summary.id = 'note-settings-summary'; summary.className = 'muted'; $('#page-content').append(summary); + if (name === '设置') { const button = document.createElement('button'); button.dataset.noteSettings = ''; button.textContent = '魔法笔记设置'; $('#page-content').append(button); } + renderNoteSettings(); + } + document.querySelectorAll('.navigation button').forEach(button => button.classList.toggle('selected', button.dataset.page === name)); + renderApps(); notify(`已打开${name}`); +} +function selectVariant(name) { + variant = name; $('.app').dataset.variant = name; + document.querySelectorAll('button[data-variant]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.variant === name))); + [$('#variant-label').textContent, $('#variant-description').textContent, $('#design-tradeoff').textContent] = variants[name]; + history.replaceState(null, '', `#${name}`); renderApps(); +} +function selectTool(id) { + const content = { + files: '本机 / goodbuddy · 示例
src /
docs /
resources /
README.md
', + terminal: '当前项目 · 演示终端
PS > git status\nOn branch main\nworking tree clean\n\nPS >
', + browser: '浏览器 · 静态示例
https://example.com

Example Domain

', + resources: '本机 · 静态演示数据
CPU 18%
内存 9.6 / 32 GB

磁盘 IO:读 12 MB/s · 写 3 MB/s

' + }; + $('#tool-content').innerHTML = content[id]; $('#tool-content').setAttribute('aria-labelledby', `tab-${id}`); + document.querySelectorAll('[data-tool]').forEach(button => { button.setAttribute('aria-selected', String(button.dataset.tool === id)); button.tabIndex = button.dataset.tool === id ? 0 : -1; }); +} +document.addEventListener('click', event => { + const button = event.target.closest('button'); if (!button) return; + const data = button.dataset; + if (data.variant) selectVariant(data.variant); + if (data.catalog) openCatalog(data.catalog); + if (data.page) openPage(data.page); + if (data.open) openPage(items.find(item => item.id === data.open).name); + if (data.tool) selectTool(data.tool); + if (button.hasAttribute('data-note-settings')) showNoteSettings(); + if (data.noteToggle || data.noteMode || data.noteFormat) { + if (data.noteToggle === 'resident') residentApps = residentApps.includes('notes') ? residentApps.filter(id => id !== 'notes') : [...residentApps, 'notes']; + if (data.noteToggle === 'showCount') noteSettings.showCount = !noteSettings.showCount; + if (data.noteMode) noteSettings.mode = data.noteMode; + if (data.noteFormat) noteSettings.format = data.noteFormat; + renderApps(); renderNoteSettings(); + const selector = data.noteToggle ? `[data-note-toggle="${data.noteToggle}"]` : data.noteMode ? `[data-note-mode="${data.noteMode}"]` : `[data-note-format="${data.noteFormat}"]`; + $(selector).focus(); + } + if (button.id === 'open-center') openCatalog(variant === 'scenes' ? 'store' : 'apps'); + if (button.id === 'edit-apps') { editingApps = !editingApps; renderApps(); $('#edit-apps').focus(); } + if (data.pin || data.install || data.installPin) { + const id = data.pin || data.install || data.installPin; + if (data.install || data.installPin) installed.add(id); + if (data.pin || data.installPin) residentApps = residentApps.includes(id) ? residentApps.filter(entry => entry !== id) : [...residentApps, id]; + renderApps(); + if ($('#catalog').open) { renderCatalog(); ($(`#catalog [data-pin="${id}"]`) || $('#search')).focus(); } + else $('#edit-apps').focus(); + } + if (data.move) { + const from = residentApps.indexOf(data.move); const to = from + Number(data.direction); + if (to >= 0 && to < residentApps.length) [residentApps[from], residentApps[to]] = [residentApps[to], residentApps[from]]; + renderApps(); $(`#resident-list [data-pin="${data.move}"]`).focus(); + } +}); +$('#resident-list').addEventListener('dragstart', event => { draggedApp = event.target.closest('[data-resident]')?.dataset.resident || ''; if (!editingApps || !draggedApp) { event.preventDefault(); return; } event.dataTransfer.setData('text/plain', draggedApp); }); +$('#resident-list').addEventListener('dragover', event => { if (editingApps && draggedApp) event.preventDefault(); }); +$('#resident-list').addEventListener('drop', event => { + event.preventDefault(); const target = event.target.closest('[data-resident]')?.dataset.resident; + if (!editingApps || !residentApps.includes(draggedApp) || !target || target === draggedApp) return; + const to = residentApps.indexOf(target); residentApps.splice(residentApps.indexOf(draggedApp), 1); residentApps.splice(to, 0, draggedApp); draggedApp = ''; renderApps(); $('#edit-apps').focus(); +}); +$('#resident-list').addEventListener('dragend', () => { draggedApp = ''; }); +$('#search').addEventListener('input', renderCatalog); +$('#close-catalog').onclick = () => $('#catalog').close(); +$('#back-to-apps').onclick = () => { const query = $('#search').value; openCatalog(catalogMode); $('#search').value = query; renderCatalog(); ($('#catalog [data-note-settings]') || $('#search')).focus(); }; +function toggleWorkbar(visible) { $('.workbar').hidden = !visible; $('#toggle-workbar').setAttribute('aria-expanded', String(visible)); } +$('#toggle-workbar').onclick = () => toggleWorkbar($('.workbar').hidden); +$('#close-tool').onclick = () => { toggleWorkbar(false); $('#toggle-workbar').focus(); }; +$('.tool-tabs').addEventListener('keydown', event => { + const tabs = [...document.querySelectorAll('[data-tool]')]; const index = tabs.indexOf(event.target); + if (index < 0 || !['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length; + selectTool(tabs[next].dataset.tool); tabs[next].focus(); +}); +document.addEventListener('keydown', event => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { event.preventDefault(); openCatalog(); } + if (!$('#catalog').open || settingsOpen || variant !== 'launcher') return; + const actions = [...document.querySelectorAll('#catalog [data-open]')]; if (!actions.length) return; + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); const index = actions.indexOf(document.activeElement); const next = index < 0 ? (event.key === 'ArrowDown' ? 0 : actions.length - 1) : (index + (event.key === 'ArrowDown' ? 1 : -1) + actions.length) % actions.length; actions[next].focus(); } + if (event.key === 'Enter' && document.activeElement === $('#search')) { event.preventDefault(); actions[0].click(); } +}); +$('#theme').onclick = () => { const dark = document.documentElement.dataset.theme !== 'dark'; document.documentElement.dataset.theme = dark ? 'dark' : 'light'; $('#theme').textContent = dark ? '切换浅色' : '切换深色'; }; +$('#reset').onclick = () => { installed = new Set(items.filter(item => item.builtin).map(item => item.id)); residentApps = ['notes', 'knowledge']; editingApps = false; renderApps(); notify('应用安装与常驻配置已恢复默认'); }; +let demoRunning = false; +let demoRecording = false; +let demoAttachments = []; +let demoQueue = []; +let demoReplyTimer; +function renderComposer() { + const tags = $('#composer-tags'); tags.replaceChildren(); + const addTag = (text, clear) => { + const button = document.createElement('button'); button.textContent = `${text} ×`; button.setAttribute('aria-label', `移除${text}`); + button.onclick = () => { clear(); renderComposer(); $('#message').focus(); }; tags.append(button); + }; + demoAttachments.forEach((name, index) => addTag(name, () => demoAttachments.splice(index, 1))); + for (const id of ['composer-expert', 'composer-agent', 'composer-command']) { + const select = $(`#${id}`); + if (select.value) addTag(select.selectedOptions[0].textContent, () => { select.value = ''; }); + } + document.querySelectorAll('[data-knowledge]:checked').forEach(input => addTag(input.dataset.knowledge, () => { input.checked = false; })); + if ($('#composer-retrieval').value === 'always') addTag('每次检索', () => { $('#composer-retrieval').value = 'auto'; }); + tags.hidden = !tags.childElementCount; + $('#stop').hidden = !demoRunning; + $('#send').disabled = !$('#message').value.trim() && $('#composer-command').value !== 'command'; + $('#send').title = demoRunning ? '加入队列' : '发送'; + $('#send').setAttribute('aria-label', demoRunning ? '加入演示队列' : '发送演示消息'); + $('#composer-status').textContent = demoRecording ? '模拟录音中 · 再次点击结束' : demoRunning ? '模拟回复中 · 可继续发送加入队列' : '交互演示 · 不调用 AI'; + for (const id of ['composer-runtime', 'composer-mode', 'composer-expert', 'composer-agent', 'composer-command']) $(`#${id}`).disabled = demoRunning; + $('#compact').disabled = demoRunning; + $('#runtime-options').hidden = $('#composer-runtime').value === '直连模型'; + $('#compact').hidden = $('#composer-runtime').value === '直连模型'; + $('#demo-queue').replaceChildren(); + demoQueue.forEach((text, index) => { + const row = document.createElement('div'); row.className = 'queue-row'; + const label = document.createElement('span'); label.textContent = text; label.title = text; + const insert = document.createElement('button'); insert.textContent = '立即插入'; insert.setAttribute('aria-label', `立即中断并插入:${text}`); + insert.onclick = () => { demoQueue.splice(index, 1); startDemoReply(text); }; + const remove = document.createElement('button'); remove.textContent = '×'; remove.setAttribute('aria-label', `删除待发送消息:${text}`); + remove.onclick = () => { demoQueue.splice(index, 1); renderComposer(); }; + row.append(label, insert, remove); $('#demo-queue').append(row); + }); + $('#demo-queue').hidden = !demoQueue.length; +} +function startDemoReply(text) { + clearTimeout(demoReplyTimer); demoRunning = true; + if ($('#composer-options').matches(':popover-open')) $('#composer-options').hidePopover(); + notify(`模拟处理:${text}`); renderComposer(); + demoReplyTimer = setTimeout(() => { + demoRunning = false; + if (demoQueue.length) startDemoReply(demoQueue.shift()); + else { renderComposer(); notify('演示回复已完成,未调用 AI'); } + }, 8000); +} +$('#send').onclick = () => { + if ($('#send').disabled) return; + const text = $('#composer-command').value === 'command' ? `/init ${$('#message').value.trim()}` : $('#message').value.trim(); + $('#message').value = ''; $('#message').style.height = ''; demoAttachments = []; + if (demoRunning) { demoQueue.push(text); renderComposer(); } + else startDemoReply(text); +}; +$('#stop').onclick = () => { + clearTimeout(demoReplyTimer); demoRunning = false; + if (demoQueue.length) startDemoReply(demoQueue.shift()); + else { renderComposer(); notify('已停止演示回复'); } +}; +$('#message').addEventListener('input', () => { $('#message').style.height = 'auto'; $('#message').style.height = `${Math.min(180, $('#message').scrollHeight)}px`; renderComposer(); }); +$('#message').addEventListener('keydown', event => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); $('#send').click(); } }); +$('#attach').onclick = () => $('#attachment-input').click(); +$('#attachment-input').onchange = () => { demoAttachments.push(...Array.from($('#attachment-input').files, file => file.name)); $('#attachment-input').value = ''; renderComposer(); }; +$('#voice').onclick = () => { + demoRecording = !demoRecording; $('#voice').setAttribute('aria-pressed', String(demoRecording)); + if (!demoRecording) { $('#message').value += '请帮我整理一下今天的工作计划。'; $('#message').dispatchEvent(new Event('input')); } + renderComposer(); +}; +$('#composer-options').addEventListener('change', event => { + if (event.target.id === 'composer-command' && event.target.value === 'prompt') { + $('#message').value = '请总结当前讨论,列出关键结论和下一步行动。'; event.target.value = ''; $('#message').dispatchEvent(new Event('input')); + } + renderComposer(); +}); +$('#composer-runtime').onchange = () => { + $('#composer-agent').value = ''; $('#composer-command').value = ''; + $('#agent-label').textContent = $('#composer-runtime').value === 'Continue' ? '预设' : 'Agent'; + $('#composer-command').querySelector('[value="command"]').hidden = $('#composer-runtime').value !== 'OpenCode'; + renderComposer(); +}; +$('#compact').onclick = () => { + $('#context-value').textContent = '18%'; $('#context-progress').value = 18; + $('#context-description').textContent = '压缩后对话估算:36k / 200k tokens'; + $('#context-details').hidePopover(); notify('已模拟压缩上下文'); +}; +for (const [panelId, triggerId] of [['composer-options', 'composer-options-trigger'], ['context-details', 'context-trigger']]) { + const panel = $(`#${panelId}`); const trigger = $(`#${triggerId}`); + const position = () => { + const rect = trigger.getBoundingClientRect(); + panel.style.left = `${Math.max(16, Math.min(rect.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 16))}px`; + panel.style.top = `${Math.max(16, rect.top - panel.offsetHeight - 8)}px`; + }; + panel.addEventListener('toggle', event => { trigger.setAttribute('aria-expanded', String(event.newState === 'open')); if (event.newState === 'open') position(); }); + window.addEventListener('resize', () => { if (panel.matches(':popover-open')) position(); }); + window.addEventListener('scroll', () => { if (panel.matches(':popover-open')) position(); }, true); +} +renderComposer(); +document.querySelectorAll('[data-icon]').forEach(element => { element.innerHTML = icon(element.dataset.icon); }); +window.addEventListener('hashchange', () => { if (Object.hasOwn(variants, location.hash.slice(1))) selectVariant(location.hash.slice(1)); }); +selectVariant(Object.hasOwn(variants, location.hash.slice(1)) ? location.hash.slice(1) : 'dock'); selectTool('files'); +if (matchMedia('(max-width: 1080px)').matches) toggleWorkbar(false); diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index c8f796fd..ada841cb 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -940,11 +940,17 @@ const api: DesktopApi = { function composerMenuTrigger( label: "专家角色" | "工作模式", ): HTMLButtonElement { + if (label === "专家角色") openComposerOptions(); return screen.getByRole("button", { name: new RegExp(`^${label}:`, "u"), }); } +function openComposerOptions(): void { + const trigger = screen.getByRole("button", { name: "选项" }); + if (trigger.getAttribute("aria-expanded") !== "true") fireEvent.click(trigger); +} + function openComposerMenu(label: "专家角色" | "工作模式"): HTMLElement { fireEvent.click(composerMenuTrigger(label)); return screen.getByRole("menu", { name: label }); @@ -4726,6 +4732,7 @@ describe("App", () => { evidence: [], }); render(); + openComposerOptions(); const knowledgeScopeTrigger = await screen.findByRole("button", { name: "选择知识库,本次已启用 0 个", }); @@ -4899,18 +4906,22 @@ describe("App", () => { render(); + await screen.findByRole("button", { name: /^带知识范围/u }); + openComposerOptions(); expect( await screen.findByRole("button", { name: "选择知识库,本次已启用 1 个", }), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /^空知识范围/u })); + openComposerOptions(); expect( screen.getByRole("button", { name: "选择知识库,本次已启用 0 个", }), ).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: /^带知识范围/u })); + openComposerOptions(); expect( screen.getByRole("button", { name: "选择知识库,本次已启用 1 个", @@ -4941,6 +4952,7 @@ describe("App", () => { evidence: [], }); render(); + openComposerOptions(); const knowledgeScope = await screen.findByRole("button", { name: "选择知识库,本次已启用 0 个", }); @@ -6204,6 +6216,7 @@ describe("App", () => { it("matches expert and work mode keyboard menus to the model picker", async () => { render(); + openComposerOptions(); const expertTrigger = await screen.findByRole("button", { name: "专家角色:通用助手", @@ -6230,11 +6243,20 @@ describe("App", () => { screen.queryByRole("menu", { name: "专家角色" }), ).not.toBeInTheDocument(); + fireEvent.keyDown(expertTrigger, { key: "Escape" }); + const optionsTrigger = screen.getByRole("button", { name: "选项" }); + expect(optionsTrigger).toHaveFocus(); + expect(optionsTrigger).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument(); const modeTrigger = composerMenuTrigger("工作模式"); fireEvent.click(modeTrigger); const modeMenu = screen.getByRole("menu", { name: "工作模式" }); expect(modeMenu).toHaveClass("runtime-picker__menu"); + fireEvent.click(optionsTrigger); + expect(screen.queryByRole("menu", { name: "工作模式" })).not.toBeInTheDocument(); + await waitFor(() => expect(expertTrigger).toHaveFocus()); fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问")); + expect(optionsTrigger).toHaveAttribute("aria-expanded", "false"); expect( screen.queryByRole("menu", { name: "工作模式" }), ).not.toBeInTheDocument(); @@ -6247,6 +6269,8 @@ describe("App", () => { await screen.findByLabelText("向 GoodBuddy 提问") ).closest(".composer"); expect(composer).not.toBeNull(); + expect(screen.queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "选项" })).toHaveAttribute("aria-expanded", "false"); const contentTools = within(composer!).getByRole("group", { name: "添加内容", @@ -6261,8 +6285,9 @@ describe("App", () => { const conversationSettings = within(composer!).getByRole("group", { name: "对话设置", }); + openComposerOptions(); expect( - within(conversationSettings).getByRole("button", { + within(screen.getByRole("dialog", { name: "对话设置" })).getByRole("button", { name: "专家角色:通用助手", }), ).toBeInTheDocument(); @@ -7750,21 +7775,17 @@ describe("App", () => { render(); + openComposerOptions(); const agentPicker = await screen.findByRole("button", { name: /OpenCode Runtime Agent/u, }); const runtimeToolbar = screen.getByRole("group", { name: "OpenCode 专属功能", }); - const universalSettings = screen.getByRole("group", { - name: "对话设置", - }); + const options = screen.getByRole("dialog", { name: "对话设置" }); expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar"); expect(runtimeToolbar).toContainElement(agentPicker); - expect(universalSettings).not.toContainElement(agentPicker); - expect(universalSettings.closest(".composer__toolbar")).toHaveClass( - "composer__toolbar--with-runtime-controls", - ); + expect(options).toContainElement(agentPicker); fireEvent.click(agentPicker); fireEvent.click( within( @@ -7788,6 +7809,10 @@ describe("App", () => { fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "src/main" }, }); + fireEvent.pointerDown(screen.getByLabelText("向 GoodBuddy 提问")); + expect(screen.queryByRole("dialog", { name: "对话设置" })).not.toBeInTheDocument(); + expect(document.querySelector(".composer__option-summary")).toHaveTextContent("Planner"); + expect(document.querySelector(".composer__option-summary")).toHaveTextContent("/review"); fireEvent.click(screen.getByLabelText("发送")); await waitFor(() => @@ -7906,6 +7931,8 @@ describe("App", () => { render(); + await screen.findByText("已就绪"); + openComposerOptions(); const runtimeToolbar = await screen.findByRole("group", { name: "OpenCode 专属功能", }); @@ -7975,6 +8002,7 @@ describe("App", () => { render(); + openComposerOptions(); fireEvent.click(await screen.findByRole("button", { name: /OpenCode Runtime Agent/u })); expect(screen.getByRole("menuitemradio", { name: /Planner/u })).toBeVisible(); fireEvent.keyDown(screen.getByRole("menu", { name: "OpenCode Runtime Agent" }), { key: "Escape" }); @@ -8008,6 +8036,7 @@ describe("App", () => { .mockResolvedValueOnce(healthy) .mockResolvedValueOnce(healthy); render(); + openComposerOptions(); await screen.findByRole("button", { name: /OpenCode Runtime Agent/u }); selectProjectOption(secondProject.name); await waitFor(() => expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenLastCalledWith( @@ -8020,6 +8049,7 @@ describe("App", () => { .mockResolvedValueOnce(failed); const callsBefore = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length; selectProjectOption(firstProject.name); + openComposerOptions(); const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" }); await act(async () => { pending.resolve(failed); @@ -8071,6 +8101,8 @@ describe("App", () => { vi.mocked(api.conversations.list).mockResolvedValueOnce(rows); render(); + await screen.findByRole("button", { name: new RegExp(`^${rows[0]!.title}`, "u") }); + openComposerOptions(); const toolbar = await screen.findByRole("group", { name: "OpenCode 专属功能" }); fireEvent.click(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })); fireEvent.click(screen.getByRole("menuitemradio", { name: /Planner/u })); @@ -8078,6 +8110,7 @@ describe("App", () => { fireEvent.click(screen.getByRole("menuitemradio", { name: /\/review/u })); const inventoryCalls = vi.mocked(api.runtimeCustomization.getNativeSnapshot).mock.calls.length; fireEvent.click(screen.getByText(rows[1]!.title).closest("button")!); + openComposerOptions(); expect(screen.getByRole("group", { name: "OpenCode 专属功能" })).toBe(toolbar); expect(api.runtimeCustomization.getNativeSnapshot).toHaveBeenCalledTimes(inventoryCalls); await waitFor(() => expect(screen.getByRole("button", { name: /OpenCode Runtime Agent/u })).not.toHaveTextContent("Planner")); @@ -8161,6 +8194,7 @@ describe("App", () => { render(); + openComposerOptions(); const presetPicker = await screen.findByRole("button", { name: /Continue 配置预设.*使用设置默认预设/u, }); @@ -8170,8 +8204,8 @@ describe("App", () => { expect(runtimeToolbar).toHaveClass("composer__runtime-toolbar"); expect(runtimeToolbar).toContainElement(presetPicker); expect( - screen.getByRole("group", { name: "对话设置" }), - ).not.toContainElement(presetPicker); + screen.getByRole("dialog", { name: "对话设置" }), + ).toContainElement(presetPicker); fireEvent.click(presetPicker); fireEvent.click( within( @@ -8891,6 +8925,8 @@ describe("App", () => { })); render(); + await screen.findByText("现有消息不应重写"); + openComposerOptions(); const knowledgeScope = await screen.findByRole("button", { name: "选择知识库,本次已启用 1 个", }); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6b147eed..08887128 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -31,6 +31,7 @@ import { Settings, RefreshCw, ShieldCheck, + SlidersHorizontal, PanelRightClose, PanelRightOpen, Sparkles, @@ -2134,6 +2135,10 @@ function App(): React.JSX.Element { const [runtimeStatusKey, setRuntimeStatusKey] = useState(""); const [runtimeSettings, setRuntimeSettings] = useState(); const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false); + const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false); + const [composerOptionsOpen, setComposerOptionsOpen] = useState(false); + const composerOptionsRef = useRef(null); + const composerOptionsTriggerRef = useRef(null); const [composerMenuOpen, setComposerMenuOpen] = useState< | "expert" | "mode" @@ -2191,30 +2196,36 @@ function App(): React.JSX.Element { setComposerMenuOpen(open ? "expert" : undefined); if (open) { setRuntimeMenuOpen(false); + setKnowledgeScopeOpen(false); } }, []); const setModeMenuOpen = useCallback((open: boolean): void => { setComposerMenuOpen(open ? "mode" : undefined); if (open) { setRuntimeMenuOpen(false); + setComposerOptionsOpen(false); + setKnowledgeScopeOpen(false); } }, []); const setRuntimeAgentMenuOpen = useCallback((open: boolean): void => { setComposerMenuOpen(open ? "runtime-agent" : undefined); if (open) { setRuntimeMenuOpen(false); + setKnowledgeScopeOpen(false); } }, []); const setRuntimeActionMenuOpen = useCallback((open: boolean): void => { setComposerMenuOpen(open ? "runtime-action" : undefined); if (open) { setRuntimeMenuOpen(false); + setKnowledgeScopeOpen(false); } }, []); const setRuntimePresetMenuOpen = useCallback((open: boolean): void => { setComposerMenuOpen(open ? "runtime-preset" : undefined); if (open) { setRuntimeMenuOpen(false); + setKnowledgeScopeOpen(false); } }, []); const assistantExpertOptions = useMemo[]>( @@ -2542,7 +2553,6 @@ function App(): React.JSX.Element { const knowledgeOperationCountRef = useRef(knowledgeOperationCount); const knowledgeLoadRequestRef = useRef(0); const failedKnowledgeLibraryIdRef = useRef(undefined); - const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false); const knowledgeScopeTriggerRef = useRef(null); const knowledgeScopePopoverRef = useRef(null); const [legacyActivityHistory] = useState(loadLegacyActivityHistory); @@ -2876,7 +2886,7 @@ function App(): React.JSX.Element { } }; const closeOnEscape = (event: KeyboardEvent): void => { - if (event.key !== "Escape") { + if (event.key !== "Escape" || event.defaultPrevented) { return; } event.preventDefault(); @@ -2892,6 +2902,32 @@ function App(): React.JSX.Element { }; }, [knowledgeScopeOpen]); + useEffect(() => { + if (!composerOptionsOpen) { + return; + } + const isOptionsTarget = (target: EventTarget | null): boolean => + target instanceof Node && + (composerOptionsRef.current?.contains(target) === true || + composerOptionsTriggerRef.current?.contains(target) === true); + const dismissOutside = (event: Event): void => { + if (!isOptionsTarget(event.target)) { + setComposerOptionsOpen(false); + setKnowledgeScopeOpen(false); + } + }; + const frame = requestAnimationFrame(() => { + composerOptionsRef.current?.querySelector("button:not(:disabled)")?.focus(); + }); + document.addEventListener("pointerdown", dismissOutside); + document.addEventListener("focusin", dismissOutside); + return () => { + cancelAnimationFrame(frame); + document.removeEventListener("pointerdown", dismissOutside); + document.removeEventListener("focusin", dismissOutside); + }; + }, [composerOptionsOpen]); + useLayoutEffect(() => { conversationsRef.current = conversations; }, [conversations]); @@ -7943,6 +7979,14 @@ function App(): React.JSX.Element { activeConversation?.messages.some( (message) => message.state === "streaming", ) ?? false; + const [composerContext, setComposerContext] = useState({ activeId, isRunning, view }); + if (composerContext.activeId !== activeId || composerContext.isRunning !== isRunning || composerContext.view !== view) { + setComposerContext({ activeId, isRunning, view }); + setComposerOptionsOpen(false); + setKnowledgeScopeOpen(false); + setComposerMenuOpen(undefined); + setRuntimeMenuOpen(false); + } const activeConversationQueueItems = useMemo( () => conversationQueueItems.filter((item) => item.conversationId === activeId), @@ -7999,6 +8043,26 @@ function App(): React.JSX.Element { runtime: runtimeControlsProvider, }) : ""; + const composerOptionSummary = [ + selectedExpertId && runtime?.capability !== "image-generation" + ? assistantExpertOptions.find((option) => option.value === selectedExpertId)?.label + : undefined, + enabledKnowledgeLibraryIds.length > 0 + ? t("composer.knowledge.select", { count: enabledKnowledgeLibraryIds.length }) + : undefined, + activeConversation?.knowledgeRetrievalMode === "always" + ? t("composer.knowledge.always") + : undefined, + runtimeAgentControlAvailable && selectedRuntimeAgent + ? runtimeAgentOptions.find((option) => option.value === selectedRuntimeAgent)?.label + : undefined, + runtimePresetControlAvailable && selectedContinuePreset + ? runtimePresetOptions.find((option) => option.value === selectedContinuePreset)?.label + : undefined, + runtimeActionControlAvailable && selectedRuntimeCommand + ? runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.label + : undefined, + ].filter(Boolean).join(" · "); const runtimeContextCompactAvailable = (activeRuntimeSelection?.provider === "opencode" || activeRuntimeSelection?.provider === "continue") && @@ -9132,6 +9196,11 @@ function App(): React.JSX.Element { running={conversationExecutionRunning} />
+ {composerOptionSummary && ( +
+ {composerOptionSummary} +
+ )} {(attachments.length > 0 || selectingContextFiles) && (
-
+
+ +
{ ) }) - it('separates Runtime-specific controls from the main composer toolbar', () => { + it('keeps composer options bounded with inline nested menus', () => { expect(stylesheet).toMatch( - /\.composer__toolbar--with-runtime-controls\s*\{[^}]*border-radius:\s*0;/u + /\.composer__options\s*\{[^}]*width:\s*min\(100%, 300px\);[^}]*max-height:\s*min\(480px, 60vh\);[^}]*overflow-y:\s*auto;/u ) expect(stylesheet).toMatch( - /\.composer__runtime-toolbar\s*\{[^}]*display:\s*flex;[^}]*min-height:\s*52px;[^}]*border-top:\s*1px solid var\(--border-default\);[^}]*background:\s*var\(--surface-muted\);/u + /\.composer__options :is\(\.runtime-picker__menu, \.knowledge-scope__popover\)\s*\{[^}]*position:\s*static;[^}]*width:\s*100%;/u ) expect(stylesheet).toMatch( /\.composer__runtime-controls\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;/u @@ -417,7 +417,7 @@ describe('WorkspacePrimitives', () => { /\.composer-picker--runtime > \.model-button,\s*\.composer-picker--runtime-action > \.model-button\s*\{[^}]*width:\s*220px;/u ) expect(stylesheet).toMatch( - /\.composer__runtime-controls \.composer-picker\s*\{[^}]*flex-basis:\s*220px;/u + /\.composer__options\[hidden\]\s*\{[^}]*display:\s*none;/u ) }) diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index d97f2bf3..bd4942e0 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -450,6 +450,7 @@ export const app = { }, removeAttachment: 'Remove {{name}}', settings: 'Conversation settings', + options: 'Options', expertLabel: 'Expert role', modeLabel: 'Work mode', runtimeControls: { diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index d171cc1d..020d09c9 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -430,6 +430,7 @@ export const app = { }, removeAttachment: '移除 {{name}}', settings: '对话设置', + options: '选项', expertLabel: '专家角色', modeLabel: '工作模式', runtimeControls: { diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index ffb2a1cf..07496188 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -7187,41 +7187,131 @@ button > svg { } .composer__toolbar { + position: relative; display: flex; width: 100%; max-width: 100%; min-width: 0; - min-height: 56px; - padding: 10px var(--space-3); + min-height: 48px; + padding: var(--space-2) var(--space-3); align-items: center; - border-top: 1px solid var(--border-default); border-radius: 0 0 calc(var(--radius-card) - 1px) calc(var(--radius-card) - 1px); - background: var(--surface-subtle); - gap: var(--space-3); + background: transparent; + flex-wrap: wrap; + gap: var(--space-2); } -.composer__toolbar--with-runtime-controls { - border-radius: 0; +.composer__option-summary { + padding: var(--space-3) var(--space-4) 0; + color: var(--text-secondary); + font-size: var(--font-caption); + overflow-wrap: anywhere; +} + +.composer__options-trigger { + display: inline-flex; + width: 34px; + height: 34px; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid transparent; + border-radius: var(--radius-control); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + flex: 0 0 auto; +} + +.composer__options-trigger:hover, +.composer__options-trigger[aria-expanded="true"] { + background: var(--accent-subtle); + color: var(--accent); +} + +.composer__options { + position: absolute; + z-index: 25; + left: 0; + bottom: calc(100% + var(--space-2)); + display: grid; + width: min(100%, 300px); + max-height: min(480px, 60vh); + padding: var(--space-3); + border: 1px solid var(--border-default); + border-radius: var(--radius-card); + overflow-y: auto; + background: var(--surface-raised); + box-shadow: var(--shadow-dialog); + gap: var(--space-2); +} + +.composer__options > strong { + padding: var(--space-1) var(--space-2); + color: var(--text-secondary); + font-size: var(--font-caption); +} + +.composer__options[hidden] { + display: none; +} + +.composer__options :is(.runtime-picker__menu, .knowledge-scope__popover) { + position: static; + width: 100%; + max-height: none; + margin-top: var(--space-2); + box-shadow: none; +} + +.composer__options .composer-picker, +.composer__options .composer-picker > .model-button { + width: 100%; +} + +.composer__options :is(.composer-picker > .model-button, .knowledge-scope > button) { + width: 100%; + min-height: 36px; + padding: 0 var(--space-2); + border-color: transparent; + background: transparent; + font-size: var(--font-body); + font-weight: 400; + gap: var(--space-2); +} + +.composer__options :is(.composer-picker > .model-button, .knowledge-scope > button):hover:not(:disabled), +.composer__options :is(.composer-picker > .model-button, .knowledge-scope > button)[aria-expanded="true"] { + border-color: transparent; + background: var(--accent-subtle); + color: var(--accent); +} + +.composer__options .composer__runtime-toolbar-label { + padding: var(--space-1) var(--space-2); + font-weight: 500; +} + +.knowledge-scope__chevron { + margin-left: auto; +} + +.knowledge-scope > button[aria-expanded="true"] > .knowledge-scope__chevron { + transform: rotate(180deg); } .composer__runtime-toolbar { - display: flex; + display: grid; min-width: 0; - min-height: 52px; - padding: var(--space-2) var(--space-3); + padding-top: var(--space-2); align-items: center; - border-top: 1px solid var(--border-default); - border-radius: 0 0 calc(var(--radius-card) - 1px) - calc(var(--radius-card) - 1px); - background: var(--surface-muted); - gap: var(--space-3); + border-top: 1px solid var(--border-subtle); + gap: var(--space-2); } .composer__runtime-toolbar-label { min-width: 0; - padding-right: var(--space-3); - border-right: 1px solid var(--border-default); color: var(--text-secondary); font-size: var(--font-caption); font-weight: 650; @@ -7242,8 +7332,8 @@ button > svg { min-width: 0; flex: 1; align-items: center; - flex-wrap: nowrap; - gap: var(--space-3); + flex-wrap: wrap; + gap: var(--space-2); } .composer__tool-group, @@ -7319,8 +7409,7 @@ button > svg { .composer__configuration { min-width: 0; - padding-left: var(--space-3); - border-left: 1px solid var(--border-default); + flex-wrap: wrap; } .composer-picker--expert > .model-button { @@ -7654,38 +7743,26 @@ button > svg { flex-wrap: wrap; } + .composer__controls, .composer__configuration { - width: 100%; - padding-left: 0; - padding-top: 10px; - border-top: 1px solid var(--border-default); - border-left: 0; + display: contents; } - .composer-picker, - .runtime-picker { - flex: 1 1 150px; + .composer__submit-actions { + margin-left: auto; } - .composer__runtime-toolbar { - align-items: stretch; - flex-direction: column; + .composer__toolbar > .composer__controls .runtime-picker__menu { + max-width: 100%; } - .composer__runtime-toolbar-label { - padding-right: 0; - padding-bottom: var(--space-2); - border-right: 0; - border-bottom: 1px solid var(--border-default); + .composer__configuration > .runtime-picker { + position: static; } - .composer__runtime-controls .composer-picker { - flex-basis: 220px; - } - - .composer-picker > .model-button, - .model-button { - width: 100%; + .composer__configuration > .composer-picker > .composer-picker__menu { + right: 0; + left: auto; } .composer-context-meter {