From 86347af0b1cc8726c1166b3f9fa3d7f12a0cc5e3 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Mon, 28 Sep 2026 22:38:35 +0800 Subject: [PATCH] fix: refine sidebar search and composer shortcut hints --- UI-DESIGN.md | 5 +- .../application-tool-navigation/progress.md | 51 +++++++ .../application-tool-navigation/ui-design.md | 16 +- src/main/ipc.test.ts | 15 +- src/main/shortcut-settings-service.test.ts | 17 +++ src/renderer/src/App.test.tsx | 119 ++++++++++----- src/renderer/src/App.tsx | 132 ++++++++++------- src/renderer/src/AppSidebarLayout.test.ts | 29 +++- src/renderer/src/i18n/locales/en-US/app.ts | 6 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 6 +- src/renderer/src/styles.css | 99 ++++++++----- tests/magic-notes-capture.electron.test.ts | 2 +- tests/support/magic-notes-capture-main.ts | 2 + tests/support/sidebar-search-probe.ts | 140 ++++++++++++++++++ 14 files changed, 505 insertions(+), 134 deletions(-) create mode 100644 tests/support/sidebar-search-probe.ts diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 54848035..26c397b8 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -296,11 +296,12 @@ - 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `8px`(`--space-2`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 - 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。 -- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称;搜索框固定在区域顶部,仅下方列表滚动。搜索框使用透明背景和 `--border-control` 边框,保留内部清除按钮与焦点环。 +- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称,列表独立滚动。搜索与新对话共用主导航上方的一行,不在会话区域另设搜索头部;展开、关闭和键盘规则见[会话搜索](./docs/features/application-tool-navigation/ui-design.md#会话搜索)。搜索框使用透明背景、`--border-control` 边框与完整焦点环。 - 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。 - 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守 [会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。 - 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。 +- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。 - 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。 - 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守 [项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。 @@ -698,7 +699,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证 - 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。 - 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。 - 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。 -- “Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴文件、图片或文本”等输入操作提示放在空输入框内部,作为主占位文案的次级行;不得在输入框下方单独占用第二行。输入框下方只保留一行当前模式、安全边界或全局快捷键说明。 +- 空输入框使用三行原生占位文案:第一行为消息提示(生图模式保留图片描述提示),第二行为“Enter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴”,第三行为“Ctrl+N 新建对话 · 实际已注册快捷键 快捷唤起”。新建与粘贴快捷键按平台显示,macOS 使用 Command;全局快捷键不可用时省略该项。输入文字后占位提示自然隐藏,输入框下方不再重复全局快捷键,保留模式、安全边界和其他元信息。 - 输入操作提示不能替代表单的可访问名称,输入框始终保留持久的程序化标签。 - 草稿与消息中的附件操作使用紧凑图标按钮,并提供悬停和键盘焦点提示;文件名、解析状态与操作行的布局遵循[附件操作规则](./docs/features/document-processing/ui-design.md#12-附件操作菜单)。 - 空对话展示可执行的起始建议,发送失败保留输入并提供重试。 diff --git a/docs/features/application-tool-navigation/progress.md b/docs/features/application-tool-navigation/progress.md index d406ee4f..964a03aa 100644 --- a/docs/features/application-tool-navigation/progress.md +++ b/docs/features/application-tool-navigation/progress.md @@ -1,5 +1,56 @@ # 应用导航实施进度 +## 2026-09-28: Composer shortcut initialization follow-up + +The composer now retains successful shortcut-settings snapshots independently of +the startup app-info request. A failed or pending app-info read no longer drops a +successful shortcut save, and a late startup response cannot restore a disabled +hint. Registration remains authoritative; no default shortcut is advertised when +registration is unavailable. This preserves the existing [composer rule](../../../UI-DESIGN.md#131-聊天). + +The failed/pending read regressions reproduced the missing hint before the fix. +The reported screenshot's actual registration status and IPC result were not +captured, so its specific runtime cause remains unconfirmed. + +Validation: App, sidebar layout, main IPC, and shortcut-service suites passed +(500 tests, one skipped); `npm run typecheck`, ESLint on the four changed source +and test files, and `git diff --check` passed. The first broader run hit the +60-second command limit; the rerun completed in 95 seconds. No full-repository +suite, native Electron screenshot reproduction, packaging, or OS registration +probe was run for this follow-up. + +## 2026-09-28:新建对话与搜索共用一行 + +已按[会话搜索](./ui-design.md#会话搜索)接入收起与展开状态,移除列表上方的常驻搜索头部。 +沿用会话创建、延迟搜索和项目过滤接口。后续按用户反馈恢复默认按钮的聊天加号图标、原有 +字号字重和整体左对齐;搜索展开后保留居中的同一图标。快捷键说明移至聊天输入框原生占位文案, +具体规则见[聊天 UI 规范](../../../UI-DESIGN.md#131-聊天)。 + +本次调整验证:`App.test.tsx` 与 `AppSidebarLayout.test.ts` 合计 316 项通过;最终长标签换行 +调整后,布局 5 项与 `magic-notes-capture.electron.test.ts` 1 项复验通过。Windows Electron +在上述 6 种侧栏组合检查原字号字重、左对齐、图标居中、文字无溢出及搜索交互,并验证 +原生输入后占位提示隐藏、清空后恢复。App 回归覆盖中英文、生图提示、macOS 快捷键格式、 +全局快捷键缺省以及设置保存后的更新和停用。类型检查、修改文件 ESLint 与差异空白检查通过。 +未运行全仓测试、打包或 macOS/Linux 原生窗口验证。模型调用 0 次。 + +此前搜索展开改动的验证记录: + +```text +npm test -- src/renderer/src/App.test.tsx src/renderer/src/AppSidebarLayout.test.ts tests/magic-notes-capture.electron.test.ts +npm run typecheck +``` + +三文件合计 311 项通过。最终动画边界与文字宽度调整后,布局及 Electron 共 5 项、App 搜索 +交互 1 项复跑通过,typecheck 通过。Electron 复用现有 App、生产 Preload、IPC 和临时 SQLite +测试环境,以英文界面检查浅深主题下 220px、420px 侧栏和短窗口抽屉,共 6 种组合;检查 +展开过程及结束后的右边缘、列表位置、完整按钮文字、原生鼠标与 Escape、自动聚焦、关闭 +焦点恢复、点击列表保留关键词及减少动态效果。中文 App 回归同时覆盖标题、正文、项目 +过滤和加号创建。模型调用 0 次。 + +修改文件的 ESLint 与 `git diff --check` 通过。全仓 ESLint 有 10 个错误,均来自本任务未修改 +的未跟踪 `sidebar-final-probe.ts` 和 `sidebar-final-run.mjs`。未跑全仓测试、打包程序或 +macOS/Linux 窗口验证。中文新增规则经 deai-writing 扫描无阻断项,并已人工核对。 + ## 2026-09-28:并行会话输出时保持列表顺序 修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index bc2ecca4..44b03810 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -21,8 +21,7 @@ 品牌 项目选择 项目活动汇总(仅有活动时显示) -新对话 -搜索 +[新对话] [搜索图标] 当前对话 应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项) @@ -39,6 +38,19 @@ 知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`; 排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。 +### 会话搜索 + +新建对话与搜索共用原新建对话位置的一行。默认显示 `MessageSquarePlus` 图标与完整“新建对话”文字,整体左对齐,沿用原有 13px、600 字重和 12px 左内边距,右侧为搜索图标; +点击搜索后,输入框从右向左展开,新建对话只显示居中的同一图标,保留“新建对话”可访问名称与悬停提示。 +最窄侧栏中的长标签允许在固定行高内换行,保留字号和图标。搜索右边缘和整行高度保持不变,列表不因展开或关闭而上下移动。宽度动画使用 +`--motion-normal`,减少动态效果时立即切换;沿用桌面侧栏调宽和窄窗口抽屉尺寸。 +通道项目继续不提供新对话按钮,搜索展开后使用整行宽度。 +控件行与新建对话按钮高 `44px`;搜索框本体高 `36px`,上下各留 `4px`,垂直居中。保留 `2px` 焦点外框与 `2px` 外间距,聚焦后外沿与新建对话按钮上下对齐。 + +展开时聚焦输入框。X 始终可用,X 或控件行内的 Escape 清空关键词并收起输入框, +焦点返回搜索图标。失焦、点击会话列表保持搜索状态和关键词;新建对话图标继续调用原新对话操作。 +无结果区域的“清除搜索”只清空关键词,保留展开状态。项目活动跳转仍沿用既有清空搜索规则。 + ## 2. 底部应用中心入口 底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等宽、等高 `nav-item` 独立按钮,内容各自居中,竖分隔线位于底部入口区域的正中间: diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 58d2eb80..2359b05f 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -520,7 +520,8 @@ describe('registerIpcHandlers computer capabilities', () => { let browserStateListener: | ((state: BrowserLiveState) => void) | undefined - const shortcutSnapshot = { + const shortcutSnapshot: import('../shared/shortcut').GlobalShortcutSettingsSnapshot = { + platform: 'win32', settings: { enabled: true, accelerator: 'Control+Alt+K' @@ -609,6 +610,18 @@ describe('registerIpcHandlers computer capabilities', () => { shortcut: 'Ctrl + Alt + K', shortcutStatus: 'registered' }) + for (const status of ['disabled', 'conflict', 'failed'] as const) { + shortcutSettingsService.getSnapshot.mockReturnValueOnce({ + ...shortcutSnapshot, + registered: false, + registeredAccelerator: undefined, + status + }) + expect(electronMocks.handlers.get(ipcChannels.appInfo)?.(event)).toMatchObject({ + shortcut: '', + shortcutStatus: status + }) + } expect( electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.( event diff --git a/src/main/shortcut-settings-service.test.ts b/src/main/shortcut-settings-service.test.ts index 11dec8c9..5759378f 100644 --- a/src/main/shortcut-settings-service.test.ts +++ b/src/main/shortcut-settings-service.test.ts @@ -102,6 +102,23 @@ describe('ShortcutSettingsService', () => { ) }) + it.each(['disabled', 'conflict', 'failed'] as const)('reports an unavailable shortcut at startup when %s', async (status) => { + const fixture = createFixture({ + enabled: status !== 'disabled', + accelerator: 'CommandOrControl+Shift+Space' + }) + if (status === 'conflict') fixture.registry.register.mockReturnValueOnce(false) + if (status === 'failed') fixture.registry.register.mockImplementationOnce(() => { throw new Error('Registration failed') }) + + await expect(fixture.service.initialize()).resolves.toMatchObject({ + registered: false, + displayAccelerator: 'Ctrl+Shift+Space', + status + }) + expect(fixture.service.getSnapshot().registeredAccelerator).toBeUndefined() + if (status === 'disabled') expect(fixture.registry.register).not.toHaveBeenCalled() + }) + it.each([ [ 'win32', diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 515aa14c..ed7fdfb8 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -1416,6 +1416,7 @@ describe("App", () => { const initialTasks = deferred(); vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise); render(); + fireEvent.click(await screen.findByRole("button", { name: "搜索对话" })); const search = await screen.findByLabelText("搜索对话"); fireEvent.change(search, { target: { value: "no matching conversation" } }); await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }])); @@ -1564,6 +1565,7 @@ describe("App", () => { fireEvent.click(await screen.findByLabelText("展开或折叠“Current discussion”中的 1 个任务")); fireEvent.click(screen.getByText("Current task", { selector: ".conversation-task-child__title" }).closest("button")!); expect(await screen.findByRole("region", { name: "当前会话的任务" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "搜索对话" })); fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "Current discussion" } }); const originalWidth = window.innerWidth; try { @@ -1836,6 +1838,7 @@ describe("App", () => { .mockResolvedValueOnce(second); const { container } = render(); expect(await screen.findByText("Opened body")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "搜索对话" })); fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "rare searchable" } }); const list = within(container.querySelector(".conversation-list")!); fireEvent.click(await list.findByText("Unopened")); @@ -2031,11 +2034,11 @@ describe("App", () => { ).toBeInTheDocument(); expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute( "placeholder", - "Message GoodBuddy…\nEnter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text", + "Message GoodBuddy…\nEnter to send · Shift+Enter for a new line · Ctrl+V to paste\nCtrl+N for a new conversation · Ctrl+Shift+Space for quick access", ); expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute( "title", - "Enter to send, Shift+Enter for a new line, Ctrl+V to paste files, images, or text", + "Enter to send · Shift+Enter for a new line · Ctrl+V to paste\nCtrl+N for a new conversation · Ctrl+Shift+Space for quick access", ); } finally { cleanup(); @@ -3105,15 +3108,23 @@ describe("App", () => { }, ]); const { container } = render(); - const search = await screen.findByLabelText("搜索对话"); - const conversationSection = search.closest(".sidebar-conversations"); - expect(conversationSection).toBeInTheDocument(); + const trigger = await screen.findByRole("button", { name: "搜索对话" }); + expect(screen.queryByRole("textbox", { name: "搜索对话" })).not.toBeInTheDocument(); + const controls = trigger.closest(".sidebar-conversation-controls")!; + const create = within(controls as HTMLElement).getByRole("button", { name: "新建对话" }); + expect(create).toHaveTextContent("新建对话"); + expect(create.querySelector(".lucide-message-square-plus")).not.toBeNull(); + fireEvent.click(trigger); + let search = screen.getByRole("textbox", { name: "搜索对话" }); + expect(search).toHaveFocus(); + expect(create).toHaveAttribute("title", "新建对话"); + expect(create).toHaveTextContent(/^$/); + expect(create.querySelector(".lucide-message-square-plus")).not.toBeNull(); + expect(search.closest(".sidebar-conversation-controls")).toBe(controls); + expect(container.querySelector(".sidebar-conversations__header")).toBeNull(); + expect(search.closest(".sidebar-conversations")).toBeNull(); expect(search.closest(".conversation-list")).toBeNull(); - expect(conversationSection?.querySelector(".conversation-list")).toBeInTheDocument(); - const searchControls = within(search.parentElement!); - expect( - searchControls.queryByRole("button", { name: "清除搜索" }), - ).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "关闭并清除搜索" })).toBeInTheDocument(); const conversationList = container.querySelector(".conversation-list"); if (!conversationList) { @@ -3150,16 +3161,19 @@ describe("App", () => { ).not.toBeInTheDocument(); }); - const clearSearch = searchControls.getByRole("button", { - name: "清除搜索", + fireEvent.blur(search); + fireEvent.click(within(conversationList).getByText("标题里的 Alpha")); + expect(search).toHaveValue("ALPHA"); + expect(search).toBeInTheDocument(); + + const clearSearch = screen.getByRole("button", { + name: "关闭并清除搜索", }); clearSearch.focus(); fireEvent.click(clearSearch); - expect(search).toHaveValue(""); - expect(search).toHaveFocus(); - expect( - searchControls.queryByRole("button", { name: "清除搜索" }), - ).not.toBeInTheDocument(); + expect(search).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole("button", { name: "搜索对话" })).toHaveFocus()); + expect(create).toHaveTextContent("新建对话"); await waitFor(() => { expect( within(conversationList).getByText("标题里的 Alpha"), @@ -3171,10 +3185,14 @@ describe("App", () => { within(conversationList).queryByText("其他项目里的 Alpha"), ).not.toBeInTheDocument(); }); + fireEvent.click(screen.getByRole("button", { name: "搜索对话" })); + search = screen.getByRole("textbox", { name: "搜索对话" }); + expect(search).toHaveValue(""); fireEvent.change(search, { target: { value: " " } }); - fireEvent.click( - searchControls.getByRole("button", { name: "清除搜索" }), - ); + fireEvent.keyDown(search, { key: "Escape" }); + await waitFor(() => expect(screen.getByRole("button", { name: "搜索对话" })).toHaveFocus()); + fireEvent.click(screen.getByRole("button", { name: "搜索对话" })); + search = screen.getByRole("textbox", { name: "搜索对话" }); expect(search).toHaveValue(""); fireEvent.change(search, { target: { value: "missing topic" } }); @@ -3195,6 +3213,9 @@ describe("App", () => { expect( await within(conversationList).findByText("标题里的 Alpha"), ).toBeInTheDocument(); + fireEvent.click(create); + await waitFor(() => expect(container.querySelector(".conversation-item--active")).toHaveTextContent("新对话")); + expect(search).toBeInTheDocument(); }); it("keeps Settings open when the interface language changes", async () => { @@ -3535,8 +3556,8 @@ describe("App", () => { ), ); expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument(); - expect(screen.getByText("快捷唤起:", { exact: false })).toHaveTextContent( - "快捷唤起:Ctrl+Shift+Space", + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( + "placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起"), ); expect(screen.queryByText(/CommandOrControl/)).not.toBeInTheDocument(); }); @@ -5301,9 +5322,8 @@ describe("App", () => { }), ).not.toBeInTheDocument(); expect(screen.queryByText("只读问答,不修改文件")).not.toBeInTheDocument(); - expect( - await screen.findByText("快捷唤起:", { exact: false }), - ).toHaveTextContent("快捷唤起:Ctrl+Shift+Space"); + await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")) + .toHaveAttribute("placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起"))); fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "中".repeat(1_000) }, @@ -5712,7 +5732,27 @@ describe("App", () => { await waitFor(() => expect(composer).toHaveFocus()); }); - it("updates and removes the composer shortcut hint immediately after saving Settings", async () => { + it.each([ + ["darwin", "Command", "Command+Alt+K"], + ["linux", "Ctrl", ""], + ])("formats composer shortcuts for %s using the actual available global shortcut", async (platform, modifier, shortcut) => { + const info = await api.app.getInfo(); + vi.mocked(api.app.getInfo).mockResolvedValueOnce({ ...info, platform, shortcut }); + render(); + const composer = await screen.findByLabelText("向 GoodBuddy 提问"); + await waitFor(() => expect(composer).toHaveAttribute("placeholder", + `给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · ${modifier}+V 粘贴\n${modifier}+N 新建对话${shortcut ? ` · ${shortcut} 快捷唤起` : ""}`)); + expect(document.querySelector(".composer-meta__shortcut")).toBeNull(); + }); + + it.each(["loaded", "failed", "pending"] as const)("updates and removes the composer shortcut hint after saving Settings with %s app info", async (infoState) => { + const initialInfo = await api.app.getInfo(); + const pendingInfo = deferred(); + if (infoState === "failed") { + vi.mocked(api.app.getInfo).mockRejectedValueOnce(new Error("App info unavailable")); + } else if (infoState === "pending") { + vi.mocked(api.app.getInfo).mockReturnValueOnce(pendingInfo.promise); + } let applicationSettings: ApplicationSettings = { checkUpdatesOnStartup: false, desktopNotificationsEnabled: true, @@ -5774,7 +5814,12 @@ describe("App", () => { }; try { render(); - expect(await screen.findByText("Ctrl+Shift+Space")).toBeInTheDocument(); + const composer = await screen.findByLabelText("向 GoodBuddy 提问"); + await waitFor(() => expect(composer).toHaveAttribute("placeholder", + `给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话${infoState === "loaded" ? " · Ctrl+Shift+Space 快捷唤起" : ""}`)); + expect(document.querySelector(".composer-meta__shortcut")).toBeNull(); + fireEvent.change(composer, { target: { value: "草稿" } }); + expect(composer).toHaveValue("草稿"); fireEvent.click(await screen.findByRole('button', { name: '设置' })); await screen.findByRole("heading", { name: "设置中心" }); fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); @@ -5790,7 +5835,9 @@ describe("App", () => { }), ); fireEvent.click(screen.getByRole("button", { name: "对话" })); - expect(await screen.findByText("Ctrl+Alt+K")).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")) + .toHaveAttribute("placeholder", expect.stringContaining("Ctrl+N 新建对话 · Ctrl+Alt+K 快捷唤起"))); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveValue("草稿"); fireEvent.click(await screen.findByRole('button', { name: '设置' })); await screen.findByRole("heading", { name: "设置中心" }); @@ -5809,6 +5856,13 @@ describe("App", () => { fireEvent.click(screen.getByRole("button", { name: "对话" })); await screen.findByLabelText("向 GoodBuddy 提问"); expect(screen.queryByText("Ctrl+Alt+K")).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")) + .toHaveAttribute("placeholder", "给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话")); + if (infoState === "pending") { + await act(async () => pendingInfo.resolve(initialInfo)); + expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute("placeholder", + "给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话"); + } } finally { delete api.shortcuts; delete api.updates; @@ -7792,7 +7846,7 @@ describe("App", () => { ).toHaveTextContent(/^Ask$/u); expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( "placeholder", - "给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本", + "给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起", ); expect( within(conversationSettings).getByRole("button", { @@ -9237,9 +9291,8 @@ describe("App", () => { }); expect(mode).toBeEnabled(); expect(mode.closest(".composer")).not.toBeNull(); - expect( - await screen.findByText("快捷唤起:", { exact: false }), - ).toBeInTheDocument(); + await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")) + .toHaveAttribute("placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起"))); selectComposerOption("工作模式", "Execute · 完全权限"); expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限"); expect(mode).toHaveTextContent(/^Execute$/u); @@ -11477,7 +11530,7 @@ describe("App", () => { expect((await screen.findAllByText("生图")).length).toBeGreaterThan(0); expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( "placeholder", - "描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本", + "描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起", ); await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled()); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d1d3fe7c..962e2a69 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -223,7 +223,7 @@ import { OverflowMarquee } from "./OverflowMarquee"; import { findTaskSchedule } from "./TaskScheduleActions"; import type { SettingsCategoryId } from "./settings-categories"; import type { SettingsLeaveRequester } from "./SettingsPanel"; -import type { GlobalShortcutSettingsSnapshot } from "../../shared/shortcut"; +import { formatShortcutForDisplay, type GlobalShortcutSettingsSnapshot } from "../../shared/shortcut"; import goodbuddyDarkIcon from "./assets/goodbuddy-dark.png"; import goodbuddyLightIcon from "./assets/goodbuddy-light.png"; import { loadBrandingPreferences, saveBrandingPreferences } from "./branding"; @@ -2036,6 +2036,18 @@ function App(): React.JSX.Element { [t], ); const [appInfo, setAppInfo] = useState(); + const [savedShortcut, setSavedShortcut] = useState(); + const shortcutPlatform = savedShortcut?.platform ?? appInfo?.platform ?? ""; + // A settings save is newer than the startup app-info request, even if it finishes first. + const composerShortcut = savedShortcut + ? savedShortcut.registered ? savedShortcut.displayAccelerator : "" + : appInfo?.shortcut; + const composerKeyboardHint = t("composer.keyboardHint", { + pasteShortcut: formatShortcutForDisplay("CommandOrControl+V", shortcutPlatform), + }); + const composerConversationHint = t("composer.newConversationHint", { + shortcut: formatShortcutForDisplay("CommandOrControl+N", shortcutPlatform), + }) + (composerShortcut ? ` · ${t("composer.shortcut", { shortcut: composerShortcut })}` : ""); const [narrowWindow, setNarrowWindow] = useState( () => window.innerWidth < 900, ); @@ -2215,15 +2227,7 @@ function App(): React.JSX.Element { ); const handleShortcutSettingsChanged = useCallback( (snapshot: GlobalShortcutSettingsSnapshot): void => { - setAppInfo((current) => - current - ? { - ...current, - shortcut: snapshot.registered ? snapshot.displayAccelerator : "", - shortcutStatus: snapshot.status, - } - : current, - ); + setSavedShortcut(snapshot); }, [], ); @@ -2339,7 +2343,13 @@ function App(): React.JSX.Element { } }, [applicationSettingsUnconfirmed, applyApplicationSettings, guardNoteDraft, setNoteDraft, t]); const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const searchTriggerRef = useRef(null); + const closeConversationSearch = (): void => { + setSearchQuery(""); + setSearchOpen(false); + requestAnimationFrame(() => searchTriggerRef.current?.focus()); + }; const deferredSearchQuery = useDeferredValue(searchQuery); const [localSearchMatches, setLocalSearchMatches] = useState<{ query: string; ids: Set }>({ query: "", ids: new Set() }); @@ -8723,13 +8733,63 @@ function App(): React.JSX.Element { onOpenConversation={openActivityConversation} /> - {activeProject?.kind !== "channel" && ( - - )} +
{ + if (searchOpen && event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + closeConversationSearch(); + } + }} + > + {activeProject?.kind !== "channel" && ( + + )} +
+ {searchOpen ? ( +
+ setSearchQuery(event.target.value)} + placeholder={t("sidebar.searchPlaceholder")} + value={searchQuery} + /> + +
+ ) : ( + + )} +
+