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.
This commit is contained in:
mesalogo
2026-09-13 00:05:05 +08:00
parent 719c9fd6cd
commit 032ebc313f
11 changed files with 871 additions and 189 deletions
+4 -2
View File
@@ -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 粘贴图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。
- 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。
@@ -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` 等片段直接打开指定方案。
## 术语
@@ -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; } }
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>GoodBuddy · 应用中心设计实验</title>
<link rel="stylesheet" href="navigation-demo.css">
<script src="navigation-demo.js" defer></script>
</head>
<body>
<header class="lab-header">
<div><strong>GoodBuddy <span class="muted">/ 设计实验</span></strong><p>左下角应用中心 · 六个交互方案</p></div>
<div class="variants" role="group" aria-label="选择设计方案">
<button data-variant="dock" aria-pressed="true">A · 紧凑入口</button>
<button data-variant="grid" aria-pressed="false">B · 应用宫格</button>
<button data-variant="cards" aria-pressed="false">C · 应用预览</button>
<button data-variant="launcher" aria-pressed="false">D · 搜索启动台</button>
<button data-variant="favorites" aria-pressed="false">E · 常驻管理</button>
<button data-variant="scenes" aria-pressed="false">F · 商店发现</button>
</div>
<button id="theme">切换深色</button>
</header>
<div class="design-note"><span id="variant-label">A / 紧凑 Dock</span><p id="variant-description"></p><span>交互原型 · 商店与指标均为演示数据</span></div>
<main class="app" data-variant="dock">
<aside class="sidebar" aria-label="主侧栏">
<div class="brand"><span class="brand-mark">GB</span><strong>GoodBuddy</strong><span class="muted">个人空间</span></div>
<button class="new-chat" data-page="当前对话"><span>+</span> 新对话</button>
<nav class="navigation" aria-label="应用导航">
<button class="selected" data-page="当前对话"><span data-icon="chat"></span>当前对话</button>
</nav>
<section class="resident-apps" aria-label="我的应用"><div class="section-label"><span>我的应用</span><div><button id="edit-apps">编辑</button><button data-catalog="apps" aria-label="管理常驻应用">+</button></div></div><nav id="resident-list" aria-label="常驻应用"></nav><button class="all-apps" data-catalog="apps">全部应用 <span>›</span></button></section>
<div class="history"><span class="eyebrow">最近对话</span><button data-page="整理产品设计思路">整理产品设计思路</button><button data-page="本周工作计划">本周工作计划</button><button data-page="项目文档检索">项目文档检索</button></div>
<div class="fixed-entry"><button data-page="运行记录"><span data-icon="history"></span>运行记录</button></div>
<div class="sidebar-footer"><div id="center-entry"></div><button data-page="设置" aria-label="设置" title="设置"><span data-icon="settings"></span></button></div>
</aside>
<section class="workspace">
<header class="workspace-header"><span id="page-label">当前对话</span><div id="page-actions"></div><button id="toggle-workbar" aria-expanded="true" aria-controls="workbar">助手工作栏</button></header>
<div class="conversation" id="page-content">
<span class="eyebrow">LET'S MAKE SOMETHING</span>
<h1>从一个想法开始。</h1>
<p class="muted">对话留在这里,常用能力随手打开。</p>
<div class="suggestions"><button data-page="项目文档检索">查找项目资料 <span>↗</span></button><button data-page="整理产品设计思路">梳理一个想法 <span>↗</span></button></div>
</div>
<section class="composer-area" aria-label="会话输入">
<div id="demo-queue" aria-label="待发送消息" hidden></div>
<div class="composer">
<div id="composer-tags" aria-label="当前附件与选项" hidden></div>
<textarea id="message" aria-label="演示消息输入" placeholder="向 GoodBuddy 提问…&#10;Enter 发送 · Shift+Enter 换行"></textarea>
<div class="composer-toolbar">
<button id="attach" class="composer-icon" aria-label="添加附件" title="添加附件">+</button>
<input id="attachment-input" type="file" multiple hidden>
<button id="voice" class="composer-icon" aria-label="模拟语音输入" aria-pressed="false" title="模拟语音输入"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 10v2a7 7 0 0 0 14 0v-2M12 19v3m-4 0h8"/></svg></button>
<label class="composer-select"><span class="sr-only">Runtime</span><select id="composer-runtime"><option>OpenCode</option><option>Continue</option><option>直连模型</option></select></label>
<label class="composer-select"><span class="sr-only">工作模式</span><select id="composer-mode"><option value="Execute">Execute</option><option value="Ask">Ask</option></select></label>
<button id="composer-options-trigger" popovertarget="composer-options" aria-expanded="false">选项 <span class="muted">⌃</span></button>
<div class="composer-actions"><button id="stop" hidden aria-label="停止演示回复">停止</button><button id="send" aria-label="发送演示消息" title="发送" disabled>↑</button></div>
</div>
<div id="composer-options" popover aria-label="会话选项">
<header><strong>会话选项</strong><button popovertarget="composer-options" popovertargetaction="hide" aria-label="关闭会话选项">×</button></header>
<label class="option-row">专家<select id="composer-expert"><option value="">默认助手</option><option>产品顾问</option><option>代码审查专家</option></select></label>
<fieldset><legend>知识范围</legend><label><input type="checkbox" data-knowledge="产品文档"> 产品文档</label><label><input type="checkbox" data-knowledge="团队知识"> 团队知识</label></fieldset>
<label class="option-row">检索方式<select id="composer-retrieval"><option value="auto">自动检索</option><option value="always">每次检索</option></select></label>
<div id="runtime-options"><label class="option-row"><span id="agent-label">Agent</span><select id="composer-agent"><option value="">默认</option><option value="plan">Plan</option><option value="review">Review</option></select></label><label class="option-row">快捷操作<select id="composer-command"><option value="">无</option><option value="prompt">填入总结模板</option><option value="command">原生命令 /init</option></select></label></div>
<p class="muted">非默认选项会显示在输入框上方。</p>
</div>
</div>
<div class="composer-meta"><span id="composer-status" role="status">交互演示 · 不调用 AI</span><button id="context-trigger" popovertarget="context-details">上下文 <span id="context-value">42%</span></button></div>
<div id="context-details" popover aria-label="上下文用量"><strong>上下文用量 · 示例</strong><p id="context-description">本次模型调用:84k / 200k tokens</p><progress id="context-progress" value="42" max="100"></progress><button id="compact">压缩上下文</button></div>
</section>
</section>
<aside class="workbar" id="workbar" aria-label="助手工作栏"><header><strong>助手工作栏</strong><button id="close-tool" aria-label="关闭工作栏">×</button></header><div class="tool-tabs" role="tablist" aria-label="系统工具"><button role="tab" id="tab-files" data-tool="files" aria-selected="true" aria-controls="tool-content">文件</button><button role="tab" id="tab-terminal" data-tool="terminal" aria-selected="false" aria-controls="tool-content" tabindex="-1">终端</button><button role="tab" id="tab-browser" data-tool="browser" aria-selected="false" aria-controls="tool-content" tabindex="-1">浏览器</button><button role="tab" id="tab-resources" data-tool="resources" aria-selected="false" aria-controls="tool-content" tabindex="-1">资源</button></div><div id="tool-content" role="tabpanel" aria-labelledby="tab-files" tabindex="0"></div></aside>
</main>
<footer class="lab-footer"><span id="design-tradeoff"></span><button id="reset">恢复默认应用</button></footer>
<div class="reference-note">设计参考:<a href="https://www.raycast.com/" target="_blank" rel="noreferrer">Raycast · 搜索与扩展</a><a href="https://www.notion.com/help/intro-to-workspaces" target="_blank" rel="noreferrer">Notion · 收藏与折叠分区</a><a href="https://code.visualstudio.com/docs/configure/profiles" target="_blank" rel="noreferrer">VS Code · Profiles</a><span>借鉴交互思路,保留 GoodBuddy 的视觉与入口分类。</span></div>
<dialog id="catalog" aria-labelledby="catalog-title"><button id="back-to-apps" hidden>‹ 返回应用中心</button><header><div><span class="eyebrow">GOODBUDDY COLLECTION</span><h2 id="catalog-title">应用中心</h2></div><button id="close-catalog" aria-label="关闭">×</button></header><p class="muted" id="catalog-description"></p><div class="center-switch" role="group" aria-label="应用中心视图"><button data-catalog="apps">我的应用</button><button data-catalog="store">商店</button></div><input id="search" type="search" aria-label="搜索应用" placeholder="搜索应用…"><div id="catalog-items"></div><section id="app-settings" aria-label="魔法笔记设置" hidden></section><footer>常驻只表示菜单入口,不表示后台运行。安装与内容均为演示。</footer></dialog>
<div id="toast" role="status" aria-live="polite"></div>
</body>
</html>
@@ -0,0 +1,291 @@
/* global document, window, history, location, matchMedia, innerWidth, Event, setTimeout, clearTimeout */
const paths = {
note: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h8M8 16h5"/>',
book: '<path d="M12 5v16M12 5C8 2 4 3 2 4v15c4-1 7-1 10 2 3-3 6-3 10-2V4c-2-1-6-2-10 1Z"/>',
chat: '<path d="M21 11a9 9 0 0 1-9 9H3l1-5A9 9 0 1 1 21 11Z"/>',
history: '<path d="M3 4v5h5M3 9a9 9 0 1 1 0 6m9-8v5l3 2"/>',
settings: '<circle cx="12" cy="12" r="4"/><path d="M12 2v3m0 14v3M2 12h3m14 0h3M5 5l2 2m10 10 2 2M5 19l2-2M17 7l2-2"/>',
activity: '<path d="M2 12h5l3-8 4 16 3-8h5"/>',
layers: '<path d="m12 3 10 5-10 5L2 8Zm-10 9 10 5 10-5M2 17l10 5 10-5"/>',
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>'
};
const icon = name => `<svg viewBox="0 0 24 24" aria-hidden="true">${paths[name] || paths.layers}</svg>`;
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 = `<div class="note-setting-row"><div><strong>常驻左侧菜单</strong><p>对应“显示魔法笔记入口”,移除后仍可从应用中心打开。</p></div><button class="demo-switch" role="switch" aria-label="常驻左侧菜单" aria-checked="${residentApps.includes('notes')}" data-note-toggle="resident"></button></div>
<div class="note-setting-row"><div><strong>显示未完成待办数量</strong><p>在左侧入口显示数量,超过 99 项显示 99+。本 Demo 使用 3 项示例。</p></div><button class="demo-switch" role="switch" aria-label="显示未完成待办数量" aria-checked="${noteSettings.showCount}" data-note-toggle="showCount"></button></div>
<fieldset class="note-setting-options"><legend>AI 评论方式</legend><div class="note-segments" role="group" aria-label="AI 评论方式">${Object.entries(noteModes).map(([value, label]) => `<button data-note-mode="${value}" aria-pressed="${noteSettings.mode === value}">${label}</button>`).join('')}</div><p>即时:按回车并停止输入 5 秒后评论未保存草稿。保存后自动:保存后评论。保存后手动:点击 AI 分析后评论。</p></fieldset>
<fieldset class="note-setting-options"><legend>AI 评论形式</legend><div class="note-segments" role="group" aria-label="AI 评论形式">${Object.entries(noteFormats).map(([value, label]) => `<button data-note-format="${value}" aria-pressed="${noteSettings.format === value}">${label}</button>`).join('')}</div><p>默认同时生成流式长评和结构化要点,也可只保留其中一种。</p></fieldset>
<div class="note-settings-preview"><span class="eyebrow">当前配置预览</span><p>${noteModes[noteSettings.mode]} · ${noteFormats[noteSettings.format]}</p><small>选择后即时保留于本次 Demo 会话,不调用 AI、不修改真实设置。</small></div>`;
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 `<div class="resident-row" data-resident="${id}" draggable="${editingApps}"><button data-open="${id}" ${$('#page-label').textContent === item.name ? 'aria-current="page"' : ''}>${icon(item.icon)}<span>${item.name}</span>${id === 'notes' && noteSettings.showCount ? '<small class="note-count" aria-label="3 项未完成待办(示例)">3</small>' : ''}</button>${editingApps ? `<div class="edit-actions"><button data-move="${id}" data-direction="-1" aria-label="上移${item.name}" ${index === 0 ? 'disabled' : ''}>↑</button><button data-pin="${id}" aria-label="移除${item.name}">×</button><button data-move="${id}" data-direction="1" aria-label="下移${item.name}" ${index === residentApps.length - 1 ? 'disabled' : ''}>↓</button></div>` : ''}</div>`;
}).join('') || '<p class="empty-apps">点击 +,选择常驻应用。</p>';
const subtitles = { dock: '', grid: `${installed.size} 个已安装应用`, cards: '笔记 · 知识 · 自动化', launcher: 'Ctrl / ⌘ K', favorites: `${residentApps.length} 个应用常驻菜单`, scenes: '发现你的下一个好应用' };
$('#center-entry').innerHTML = `<button id="open-center" aria-haspopup="dialog"><span class="brand-mark">${variant === 'launcher' ? icon('search') : 'GB'}</span><span><strong>${variant === 'launcher' ? '搜索应用…' : '应用中心'}</strong>${subtitles[variant] ? `<small>${subtitles[variant]}</small>` : ''}</span><span class="entry-arrow">${variant === 'scenes' ? '↗' : '⌃'}</span></button>`;
}
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 `<article class="center-app">${icon(item.icon)}<div class="center-app-info"><strong>${item.name}</strong><p>${item.builtin ? '内置应用' : '商店应用 · 示例'}</p><p class="app-detail">${variant === 'cards' ? item.summary : item.detail}</p></div><div class="center-actions">${ready ? `<button data-open="${item.id}">打开</button>${item.id === 'notes' ? '<button data-note-settings aria-label="魔法笔记设置">设置</button>' : ''}<button data-pin="${item.id}" aria-pressed="${pinned}">${pinned ? '取消常驻' : '常驻左侧'}</button>` : `<button data-install="${item.id}">模拟安装</button><button data-install-pin="${item.id}">安装并常驻</button>`}</div></article>`;
}).join('') || '<p class="empty-apps">没有匹配的应用。可以换个关键词,或切换到商店。</p>';
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 === '魔法笔记' ? `<button data-note-settings>${icon('settings')} 笔记设置</button>` : '';
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: '<span class="eyebrow">本机 / goodbuddy · 示例</span><div class="file-row">src /</div><div class="file-row">docs /</div><div class="file-row">resources /</div><div class="file-row">README.md</div>',
terminal: '<span class="eyebrow">当前项目 · 演示终端</span><pre>PS &gt; git status\nOn branch main\nworking tree clean\n\nPS &gt;</pre>',
browser: '<span class="eyebrow">浏览器 · 静态示例</span><div class="file-row">https://example.com</div><h2>Example Domain</h2>',
resources: '<span class="eyebrow">本机 · 静态演示数据</span><div class="metric">CPU <strong>18%</strong><progress value="18" max="100"></progress></div><div class="metric">内存 <strong>9.6 / 32 GB</strong><progress value="30" max="100"></progress></div><p>磁盘 IO:读 12 MB/s · 写 3 MB/s</p>'
};
$('#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);
+46 -10
View File
@@ -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(<App />);
openComposerOptions();
const knowledgeScopeTrigger = await screen.findByRole("button", {
name: "选择知识库,本次已启用 0 个",
});
@@ -4899,18 +4906,22 @@ describe("App", () => {
render(<App />);
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(<App />);
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(<App />);
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<HTMLElement>(".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(<App />);
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(<App />);
await screen.findByText("已就绪");
openComposerOptions();
const runtimeToolbar = await screen.findByRole("group", {
name: "OpenCode 专属功能",
});
@@ -7975,6 +8002,7 @@ describe("App", () => {
render(<App />);
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(<App />);
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(<App />);
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(<App />);
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(<App />);
await screen.findByText("现有消息不应重写");
openComposerOptions();
const knowledgeScope = await screen.findByRole("button", {
name: "选择知识库,本次已启用 1 个",
});
+192 -129
View File
@@ -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<RuntimeSettings>();
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false);
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false);
const [composerOptionsOpen, setComposerOptionsOpen] = useState(false);
const composerOptionsRef = useRef<HTMLDivElement>(null);
const composerOptionsTriggerRef = useRef<HTMLButtonElement>(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<ComposerMenuOption<string>[]>(
@@ -2542,7 +2553,6 @@ function App(): React.JSX.Element {
const knowledgeOperationCountRef = useRef(knowledgeOperationCount);
const knowledgeLoadRequestRef = useRef(0);
const failedKnowledgeLibraryIdRef = useRef<string | undefined>(undefined);
const [knowledgeScopeOpen, setKnowledgeScopeOpen] = useState(false);
const knowledgeScopeTriggerRef = useRef<HTMLButtonElement>(null);
const knowledgeScopePopoverRef = useRef<HTMLDivElement>(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<HTMLButtonElement>("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}
/>
<div className="composer">
{composerOptionSummary && (
<div className="composer__option-summary" aria-label={t("composer.settings")}>
{composerOptionSummary}
</div>
)}
{(attachments.length > 0 ||
selectingContextFiles) && (
<div
@@ -9303,13 +9372,7 @@ function App(): React.JSX.Element {
}}
/>
</div>
<div
className={`composer__toolbar${
runtimeControlsProvider
? " composer__toolbar--with-runtime-controls"
: ""
}`}
>
<div className="composer__toolbar">
<div className="composer__controls">
<div
aria-label={t("composer.addContent")}
@@ -9367,6 +9430,46 @@ function App(): React.JSX.Element {
<Mic aria-hidden="true" size={18} />
</button>
</div>
<button
aria-controls="composer-options"
aria-expanded={composerOptionsOpen}
aria-haspopup="dialog"
aria-label={t("composer.options")}
className="composer__options-trigger"
onClick={() => {
setComposerOptionsOpen(!composerOptionsOpen);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(false);
}}
ref={composerOptionsTriggerRef}
title={t("composer.settings")}
type="button"
>
<SlidersHorizontal aria-hidden="true" size={17} />
</button>
<div
aria-label={t("composer.settings")}
className="composer__options"
id="composer-options"
hidden={!composerOptionsOpen}
ref={composerOptionsRef}
role="dialog"
onKeyDown={(event) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
if (knowledgeScopeOpen) {
setKnowledgeScopeOpen(false);
knowledgeScopeTriggerRef.current?.focus();
} else {
setComposerOptionsOpen(false);
setComposerMenuOpen(undefined);
composerOptionsTriggerRef.current?.focus();
}
}}
>
<strong>{t("composer.settings")}</strong>
{knowledgeSnapshot.libraries.length > 0 && (
<div
className="knowledge-scope"
@@ -9388,11 +9491,13 @@ function App(): React.JSX.Element {
count: enabledKnowledgeLibraryIds.length,
})}
aria-expanded={knowledgeScopeOpen}
onClick={() =>
onClick={() => {
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(false);
setKnowledgeScopeOpen(
(current) => !current,
)
}
);
}}
ref={knowledgeScopeTriggerRef}
title={t("composer.knowledge.title")}
type="button"
@@ -9404,6 +9509,11 @@ function App(): React.JSX.Element {
{enabledKnowledgeLibraryIds.length}
</strong>
</span>
<ChevronDown
aria-hidden="true"
className="knowledge-scope__chevron"
size={14}
/>
</button>
{knowledgeScopeOpen && (
<div
@@ -9509,11 +9619,7 @@ function App(): React.JSX.Element {
)}
</div>
)}
<div
aria-label={t("composer.settings")}
className="composer__configuration"
role="group"
>
<div className="composer__configuration">
<ComposerMenuSelect
ariaLabel={t("composer.expertLabel")}
className="composer-picker--expert"
@@ -9528,34 +9634,55 @@ function App(): React.JSX.Element {
options={assistantExpertOptions}
value={selectedExpertId}
/>
<ComposerMenuSelect
ariaLabel={t("composer.modeLabel")}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
disabled={isRunning}
icon={
effectiveWorkMode === "execute" ? (
<ShieldCheck
aria-hidden="true"
size={15}
</div>
{runtimeControlsProvider && (
<div aria-label={runtimeControlsLabel} className="composer__runtime-toolbar" role="group">
<strong className="composer__runtime-toolbar-label">{runtimeControlsLabel}</strong>
<div className="composer__runtime-controls">
{runtimeAgentControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.agentLabel")}
className="composer-picker--runtime"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-agent"}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
) : (
<CircleHelp
aria-hidden="true"
size={15}
)}
{runtimePresetControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.presetLabel")}
className="composer-picker--runtime"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-preset"}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)
}
menuOpen={composerMenuOpen === "mode"}
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
triggerLabel={
effectiveWorkMode === "execute"
? "Execute"
: "Ask"
}
value={effectiveWorkMode}
/>
)}
{runtimeActionControlAvailable && (
<ComposerMenuSelect
ariaLabel={t("composer.runtimeControls.actionLabel")}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={<TerminalSquare aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "runtime-action"}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={runtimeActionOptions.find((option) => option.action?.type === "command" && option.action.id === selectedRuntimeCommand)?.value ?? ""}
/>
)}
</div>
</div>
)}
</div>
<div className="composer__configuration" role="group" aria-label={t("composer.settings")}>
<div className="runtime-picker">
<button
aria-expanded={runtimeMenuOpen}
@@ -9563,6 +9690,8 @@ function App(): React.JSX.Element {
className="model-button"
disabled={isRunning || runtimeSwitching}
onClick={() => {
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(!runtimeMenuOpen);
}}
@@ -9574,6 +9703,8 @@ function App(): React.JSX.Element {
event.key === " ")
) {
event.preventDefault();
setComposerOptionsOpen(false);
setKnowledgeScopeOpen(false);
setComposerMenuOpen(undefined);
setRuntimeMenuOpen(true);
}
@@ -9847,6 +9978,24 @@ function App(): React.JSX.Element {
</div>
)}
</div>
<ComposerMenuSelect
ariaLabel={t("composer.modeLabel")}
className={`composer-picker--mode composer-picker--${effectiveWorkMode}`}
disabled={isRunning}
icon={
effectiveWorkMode === "execute" ? (
<ShieldCheck aria-hidden="true" size={15} />
) : (
<CircleHelp aria-hidden="true" size={15} />
)
}
menuOpen={composerMenuOpen === "mode"}
onChange={setWorkMode}
onOpenChange={setModeMenuOpen}
options={workModeOptions}
triggerLabel={effectiveWorkMode === "execute" ? "Execute" : "Ask"}
value={effectiveWorkMode}
/>
</div>
</div>
<div className="composer__submit-actions">
@@ -9905,92 +10054,6 @@ function App(): React.JSX.Element {
</button>
</div>
</div>
{runtimeControlsProvider && (
<div
aria-label={runtimeControlsLabel}
className="composer__runtime-toolbar"
role="group"
>
<strong className="composer__runtime-toolbar-label">
{runtimeControlsLabel}
</strong>
<div className="composer__runtime-controls">
{runtimeAgentControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
"composer.runtimeControls.agentLabel",
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === "runtime-agent"
}
onChange={setSelectedRuntimeAgent}
onOpenChange={setRuntimeAgentMenuOpen}
options={runtimeAgentOptions}
value={selectedRuntimeAgent}
/>
)}
{runtimePresetControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
"composer.runtimeControls.presetLabel",
)}
className="composer-picker--runtime"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === "runtime-preset"
}
onChange={setSelectedContinuePreset}
onOpenChange={setRuntimePresetMenuOpen}
options={runtimePresetOptions}
value={selectedContinuePreset}
/>
)}
{runtimeActionControlAvailable && (
<ComposerMenuSelect
ariaLabel={t(
"composer.runtimeControls.actionLabel",
)}
className="composer-picker--runtime-action"
disabled={isRunning}
icon={
<TerminalSquare
aria-hidden="true"
size={15}
/>
}
menuOpen={
composerMenuOpen === "runtime-action"
}
onChange={selectRuntimeAction}
onOpenChange={setRuntimeActionMenuOpen}
options={runtimeActionOptions}
value={
runtimeActionOptions.find(
(option) =>
option.action?.type === "command" &&
option.action.id ===
selectedRuntimeCommand,
)?.value ?? ""
}
/>
)}
</div>
</div>
)}
</div>
<div
className={`composer-meta${
@@ -403,12 +403,12 @@ describe('WorkspacePrimitives', () => {
)
})
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
)
})
@@ -450,6 +450,7 @@ export const app = {
},
removeAttachment: 'Remove {{name}}',
settings: 'Conversation settings',
options: 'Options',
expertLabel: 'Expert role',
modeLabel: 'Work mode',
runtimeControls: {
@@ -430,6 +430,7 @@ export const app = {
},
removeAttachment: '移除 {{name}}',
settings: '对话设置',
options: '选项',
expertLabel: '专家角色',
modeLabel: '工作模式',
runtimeControls: {
+121 -44
View File
@@ -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 {