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 {