fix: refine sidebar search and composer shortcut hints

This commit is contained in:
mesalogo
2026-09-28 22:38:35 +08:00
parent 4baf4ec142
commit 86347af0b1
14 changed files with 505 additions and 134 deletions
+3 -2
View File
@@ -296,11 +296,12 @@
- 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `8px`(`--space-2`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。 - 固定导航行高至少 `36px`、行间距 `4px`,图标统一为 `16px`;导航与会话区域之间、会话区域与底部入口之间的分隔线上下均各留 `8px`(`--space-2`)。普通待办和待处理建议数量使用中性色徽标,不使用危险红色;徽标高度不得改变导航行高。搜索框获得内部焦点时显示完整焦点环。
- 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。 - 未选中项保持平整,不为每一行添加卡片边框或阴影。悬停反馈不得强于选中状态。
- 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。 - 最近会话列表在精细指针设备上使用细原生滚动条,滑块使用 `--border-control`、轨道透明;强制颜色与粗指针设备保留系统默认。
- 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称;搜索框固定在区域顶部,仅下方列表滚动。搜索框使用透明背景和 `--border-control` 边框,保留内部清除按钮与焦点环。 - 会话区域使用 `--surface-raised` 与主导航区分,浅色为白底,深色为相邻的深蓝灰底。不显示“最近会话”标题,保留区域的无障碍名称,列表独立滚动。搜索与新对话共用主导航上方的一行,不在会话区域另设搜索头部;展开、关闭和键盘规则见[会话搜索](./docs/features/application-tool-navigation/ui-design.md#会话搜索)。搜索框使用透明背景、`--border-control` 边框与完整焦点环。
- 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。 - 窗口高度不超过 `600px` 时,侧栏允许整体纵向滚动,会话区域至少保留 `200px` 高度,避免搜索框、列表与底部入口相互覆盖。
- 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守 - 最近会话的顺序不随流式输出增量反复变化;置顶与新消息排序遵守
[会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。 [会话列表排序规则](./docs/features/application-tool-navigation/ui-design.md#会话置顶与操作菜单)。
- 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。 - 项目选择器顶行保留项目切换与新建按钮;项目设置统一从下拉列表对应行的管理按钮进入,关闭后焦点返回项目选择器。
- 项目选择器与下方默认的新建对话按钮等宽,两行右侧图标按钮均宽 `36px`,与左侧控件间距均为 `8px`;搜索展开时项目选择器宽度保持不变。
- 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。 - 项目下拉列表通过 `FloatingPortal` 浮在侧栏与会话区上方,默认宽度 `380px`,不受侧栏裁剪;距窗口边缘至少 `16px`,窄窗口自动收窄,长列表内部滚动。浮层锚定项目选择按钮,窗口、滚动和尺寸变化后重新定位。
- 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守 - 项目选择器下方仅在有运行中、待处理或已完成未查看会话时显示跨项目活动汇总,无活动时隐藏;项目计数与项目、会话级联菜单遵守
[项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。 [项目活动汇总 UI 规则](./docs/features/application-tool-navigation/ui-design.md#10-项目活动汇总)。
@@ -698,7 +699,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
- 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。 - 已选择的工作模式在触发按钮中只显示 `Ask` 或 `Execute`;完整中文含义和说明保留在菜单选项、可访问名称及输入区下方的模式说明中。
- 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。 - 宽度大于 `700px` 时,主工具栏的添加内容、Runtime、模式、会话选项与发送保持同一行。窄输入区按容器宽度换行,保留发送及停止入口;选项浮层不得超出输入区宽度,不再常驻独立的 Runtime 专属功能行。
- 输入框原生支持 `Ctrl+V`:文本直接进入草稿,系统截图和从文件管理器复制的一个或多个支持文件加入本次消息附件。文件粘贴与上传按钮共用读取、解析进度和数量限制;读取期间阻止重复导入及发送,错误就地关联输入框和附件按钮。具体类型、大小及批次失败规则见[聊天附件要求](./docs/features/document-processing/prd.md#321-聊天附件入口)。默认工具栏不提供独立“读取剪贴板”“截取当前屏幕”和“选择应用窗口”入口。 - 输入框原生支持 `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-附件操作菜单)。 - 草稿与消息中的附件操作使用紧凑图标按钮,并提供悬停和键盘焦点提示;文件名、解析状态与操作行的布局遵循[附件操作规则](./docs/features/document-processing/ui-design.md#12-附件操作菜单)。
- 空对话展示可执行的起始建议,发送失败保留输入并提供重试。 - 空对话展示可执行的起始建议,发送失败保留输入并提供重试。
@@ -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:并行会话输出时保持列表顺序 ## 2026-09-28:并行会话输出时保持列表顺序
修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建 修复多个会话交替输出时,左侧列表随每个增量反复换位的问题。展示顺序统一使用消息创建
@@ -21,8 +21,7 @@
品牌 品牌
项目选择 项目选择
项目活动汇总(仅有活动时显示) 项目活动汇总(仅有活动时显示)
新对话 [新对话] [搜索图标]
搜索
当前对话 当前对话
应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项) 应用(按保存顺序;知识库始终显示,可选应用仅显示启用且常驻项)
@@ -39,6 +38,19 @@
知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`; 知识库、智能心跳及本轮追加的本机推理监控,应用中心与设置始终可见。侧栏底部不再显示“本地工作区”和 `platform · arch`;
排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。 排障信息移至关于或诊断页面。左侧不再提供文件、终端、浏览器、资源监控的重复入口。
### 会话搜索
新建对话与搜索共用原新建对话位置的一行。默认显示 `MessageSquarePlus` 图标与完整“新建对话”文字,整体左对齐,沿用原有 13px、600 字重和 12px 左内边距,右侧为搜索图标;
点击搜索后,输入框从右向左展开,新建对话只显示居中的同一图标,保留“新建对话”可访问名称与悬停提示。
最窄侧栏中的长标签允许在固定行高内换行,保留字号和图标。搜索右边缘和整行高度保持不变,列表不因展开或关闭而上下移动。宽度动画使用
`--motion-normal`,减少动态效果时立即切换;沿用桌面侧栏调宽和窄窗口抽屉尺寸。
通道项目继续不提供新对话按钮,搜索展开后使用整行宽度。
控件行与新建对话按钮高 `44px`;搜索框本体高 `36px`,上下各留 `4px`,垂直居中。保留 `2px` 焦点外框与 `2px` 外间距,聚焦后外沿与新建对话按钮上下对齐。
展开时聚焦输入框。X 始终可用,X 或控件行内的 Escape 清空关键词并收起输入框,
焦点返回搜索图标。失焦、点击会话列表保持搜索状态和关键词;新建对话图标继续调用原新对话操作。
无结果区域的“清除搜索”只清空关键词,保留展开状态。项目活动跳转仍沿用既有清空搜索规则。
## 2. 底部应用中心入口 ## 2. 底部应用中心入口
底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等宽、等高 `nav-item` 独立按钮,内容各自居中,竖分隔线位于底部入口区域的正中间: 底部使用连续平整表面和顶部分隔线,“应用”在前,“设置”靠右,二者为横向排列的等宽、等高 `nav-item` 独立按钮,内容各自居中,竖分隔线位于底部入口区域的正中间:
+14 -1
View File
@@ -520,7 +520,8 @@ describe('registerIpcHandlers computer capabilities', () => {
let browserStateListener: let browserStateListener:
| ((state: BrowserLiveState) => void) | ((state: BrowserLiveState) => void)
| undefined | undefined
const shortcutSnapshot = { const shortcutSnapshot: import('../shared/shortcut').GlobalShortcutSettingsSnapshot = {
platform: 'win32',
settings: { settings: {
enabled: true, enabled: true,
accelerator: 'Control+Alt+K' accelerator: 'Control+Alt+K'
@@ -609,6 +610,18 @@ describe('registerIpcHandlers computer capabilities', () => {
shortcut: 'Ctrl + Alt + K', shortcut: 'Ctrl + Alt + K',
shortcutStatus: 'registered' 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( expect(
electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.( electronMocks.handlers.get(ipcChannels.shortcutSettingsGet)?.(
event event
@@ -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([ it.each([
[ [
'win32', 'win32',
+86 -33
View File
@@ -1416,6 +1416,7 @@ describe("App", () => {
const initialTasks = deferred<AssistantTask[]>(); const initialTasks = deferred<AssistantTask[]>();
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise); vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
render(<App />); render(<App />);
fireEvent.click(await screen.findByRole("button", { name: "搜索对话" }));
const search = await screen.findByLabelText("搜索对话"); const search = await screen.findByLabelText("搜索对话");
fireEvent.change(search, { target: { value: "no matching conversation" } }); fireEvent.change(search, { target: { value: "no matching conversation" } });
await act(async () => initialTasks.resolve([task, { ...task, id: "duplicate-task" }])); 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(await screen.findByLabelText("展开或折叠“Current discussion”中的 1 个任务"));
fireEvent.click(screen.getByText("Current task", { selector: ".conversation-task-child__title" }).closest("button")!); fireEvent.click(screen.getByText("Current task", { selector: ".conversation-task-child__title" }).closest("button")!);
expect(await screen.findByRole("region", { name: "当前会话的任务" })).toBeInTheDocument(); expect(await screen.findByRole("region", { name: "当前会话的任务" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "Current discussion" } }); fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "Current discussion" } });
const originalWidth = window.innerWidth; const originalWidth = window.innerWidth;
try { try {
@@ -1836,6 +1838,7 @@ describe("App", () => {
.mockResolvedValueOnce(second); .mockResolvedValueOnce(second);
const { container } = render(<App />); const { container } = render(<App />);
expect(await screen.findByText("Opened body")).toBeInTheDocument(); expect(await screen.findByText("Opened body")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "rare searchable" } }); fireEvent.change(screen.getByLabelText("搜索对话"), { target: { value: "rare searchable" } });
const list = within(container.querySelector<HTMLElement>(".conversation-list")!); const list = within(container.querySelector<HTMLElement>(".conversation-list")!);
fireEvent.click(await list.findByText("Unopened")); fireEvent.click(await list.findByText("Unopened"));
@@ -2031,11 +2034,11 @@ describe("App", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute( expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
"placeholder", "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( expect(screen.getByLabelText("Message GoodBuddy")).toHaveAttribute(
"title", "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 { } finally {
cleanup(); cleanup();
@@ -3105,15 +3108,23 @@ describe("App", () => {
}, },
]); ]);
const { container } = render(<App />); const { container } = render(<App />);
const search = await screen.findByLabelText("搜索对话"); const trigger = await screen.findByRole("button", { name: "搜索对话" });
const conversationSection = search.closest(".sidebar-conversations"); expect(screen.queryByRole("textbox", { name: "搜索对话" })).not.toBeInTheDocument();
expect(conversationSection).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(search.closest(".conversation-list")).toBeNull();
expect(conversationSection?.querySelector(".conversation-list")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "关闭并清除搜索" })).toBeInTheDocument();
const searchControls = within(search.parentElement!);
expect(
searchControls.queryByRole("button", { name: "清除搜索" }),
).not.toBeInTheDocument();
const conversationList = const conversationList =
container.querySelector<HTMLElement>(".conversation-list"); container.querySelector<HTMLElement>(".conversation-list");
if (!conversationList) { if (!conversationList) {
@@ -3150,16 +3161,19 @@ describe("App", () => {
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
const clearSearch = searchControls.getByRole("button", { fireEvent.blur(search);
name: "清除搜索", fireEvent.click(within(conversationList).getByText("标题里的 Alpha"));
expect(search).toHaveValue("ALPHA");
expect(search).toBeInTheDocument();
const clearSearch = screen.getByRole("button", {
name: "关闭并清除搜索",
}); });
clearSearch.focus(); clearSearch.focus();
fireEvent.click(clearSearch); fireEvent.click(clearSearch);
expect(search).toHaveValue(""); expect(search).not.toBeInTheDocument();
expect(search).toHaveFocus(); await waitFor(() => expect(screen.getByRole("button", { name: "搜索对话" })).toHaveFocus());
expect( expect(create).toHaveTextContent("新建对话");
searchControls.queryByRole("button", { name: "清除搜索" }),
).not.toBeInTheDocument();
await waitFor(() => { await waitFor(() => {
expect( expect(
within(conversationList).getByText("标题里的 Alpha"), within(conversationList).getByText("标题里的 Alpha"),
@@ -3171,10 +3185,14 @@ describe("App", () => {
within(conversationList).queryByText("其他项目里的 Alpha"), within(conversationList).queryByText("其他项目里的 Alpha"),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
search = screen.getByRole("textbox", { name: "搜索对话" });
expect(search).toHaveValue("");
fireEvent.change(search, { target: { value: " " } }); fireEvent.change(search, { target: { value: " " } });
fireEvent.click( fireEvent.keyDown(search, { key: "Escape" });
searchControls.getByRole("button", { name: "清除搜索" }), await waitFor(() => expect(screen.getByRole("button", { name: "搜索对话" })).toHaveFocus());
); fireEvent.click(screen.getByRole("button", { name: "搜索对话" }));
search = screen.getByRole("textbox", { name: "搜索对话" });
expect(search).toHaveValue(""); expect(search).toHaveValue("");
fireEvent.change(search, { target: { value: "missing topic" } }); fireEvent.change(search, { target: { value: "missing topic" } });
@@ -3195,6 +3213,9 @@ describe("App", () => {
expect( expect(
await within(conversationList).findByText("标题里的 Alpha"), await within(conversationList).findByText("标题里的 Alpha"),
).toBeInTheDocument(); ).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 () => { it("keeps Settings open when the interface language changes", async () => {
@@ -3535,8 +3556,8 @@ describe("App", () => {
), ),
); );
expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument(); expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument();
expect(screen.getByText("快捷唤起:", { exact: false })).toHaveTextContent( expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
"快捷唤起:Ctrl+Shift+Space", "placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起"),
); );
expect(screen.queryByText(/CommandOrControl/)).not.toBeInTheDocument(); expect(screen.queryByText(/CommandOrControl/)).not.toBeInTheDocument();
}); });
@@ -5301,9 +5322,8 @@ describe("App", () => {
}), }),
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
expect(screen.queryByText("只读问答,不修改文件")).not.toBeInTheDocument(); expect(screen.queryByText("只读问答,不修改文件")).not.toBeInTheDocument();
expect( await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
await screen.findByText("快捷唤起:", { exact: false }), .toHaveAttribute("placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起")));
).toHaveTextContent("快捷唤起:Ctrl+Shift+Space");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "中".repeat(1_000) }, target: { value: "中".repeat(1_000) },
@@ -5712,7 +5732,27 @@ describe("App", () => {
await waitFor(() => expect(composer).toHaveFocus()); 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(<App />);
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<typeof initialInfo>();
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 = { let applicationSettings: ApplicationSettings = {
checkUpdatesOnStartup: false, checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true, desktopNotificationsEnabled: true,
@@ -5774,7 +5814,12 @@ describe("App", () => {
}; };
try { try {
render(<App />); render(<App />);
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: '设置' })); fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" }); await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
@@ -5790,7 +5835,9 @@ describe("App", () => {
}), }),
); );
fireEvent.click(screen.getByRole("button", { name: "对话" })); 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: '设置' })); fireEvent.click(await screen.findByRole('button', { name: '设置' }));
await screen.findByRole("heading", { name: "设置中心" }); await screen.findByRole("heading", { name: "设置中心" });
@@ -5809,6 +5856,13 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "对话" })); fireEvent.click(screen.getByRole("button", { name: "对话" }));
await screen.findByLabelText("向 GoodBuddy 提问"); await screen.findByLabelText("向 GoodBuddy 提问");
expect(screen.queryByText("Ctrl+Alt+K")).not.toBeInTheDocument(); 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 { } finally {
delete api.shortcuts; delete api.shortcuts;
delete api.updates; delete api.updates;
@@ -7792,7 +7846,7 @@ describe("App", () => {
).toHaveTextContent(/^Ask$/u); ).toHaveTextContent(/^Ask$/u);
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
"placeholder", "placeholder",
"给 GoodBuddy 发消息…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本", "给 GoodBuddy 发消息…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起",
); );
expect( expect(
within(conversationSettings).getByRole("button", { within(conversationSettings).getByRole("button", {
@@ -9237,9 +9291,8 @@ describe("App", () => {
}); });
expect(mode).toBeEnabled(); expect(mode).toBeEnabled();
expect(mode.closest(".composer")).not.toBeNull(); expect(mode.closest(".composer")).not.toBeNull();
expect( await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问"))
await screen.findByText("快捷唤起:", { exact: false }), .toHaveAttribute("placeholder", expect.stringContaining("Ctrl+Shift+Space 快捷唤起")));
).toBeInTheDocument();
selectComposerOption("工作模式", "Execute · 完全权限"); selectComposerOption("工作模式", "Execute · 完全权限");
expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限"); expect(mode).toHaveAccessibleName("工作模式:Execute · 完全权限");
expect(mode).toHaveTextContent(/^Execute$/u); expect(mode).toHaveTextContent(/^Execute$/u);
@@ -11477,7 +11530,7 @@ describe("App", () => {
expect((await screen.findAllByText("生图")).length).toBeGreaterThan(0); expect((await screen.findAllByText("生图")).length).toBeGreaterThan(0);
expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute( expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveAttribute(
"placeholder", "placeholder",
"描述你想生成的图片…\nEnter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本", "描述你想生成的图片…\nEnter 发送 · Shift+Enter 换行 · Ctrl+V 粘贴\nCtrl+N 新建对话 · Ctrl+Shift+Space 快捷唤起",
); );
await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled()); await waitFor(() => expect(api.artifacts.list).toHaveBeenCalled());
+80 -52
View File
@@ -223,7 +223,7 @@ import { OverflowMarquee } from "./OverflowMarquee";
import { findTaskSchedule } from "./TaskScheduleActions"; import { findTaskSchedule } from "./TaskScheduleActions";
import type { SettingsCategoryId } from "./settings-categories"; import type { SettingsCategoryId } from "./settings-categories";
import type { SettingsLeaveRequester } from "./SettingsPanel"; 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 goodbuddyDarkIcon from "./assets/goodbuddy-dark.png";
import goodbuddyLightIcon from "./assets/goodbuddy-light.png"; import goodbuddyLightIcon from "./assets/goodbuddy-light.png";
import { loadBrandingPreferences, saveBrandingPreferences } from "./branding"; import { loadBrandingPreferences, saveBrandingPreferences } from "./branding";
@@ -2036,6 +2036,18 @@ function App(): React.JSX.Element {
[t], [t],
); );
const [appInfo, setAppInfo] = useState<AppInfo>(); const [appInfo, setAppInfo] = useState<AppInfo>();
const [savedShortcut, setSavedShortcut] = useState<GlobalShortcutSettingsSnapshot>();
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( const [narrowWindow, setNarrowWindow] = useState(
() => window.innerWidth < 900, () => window.innerWidth < 900,
); );
@@ -2215,15 +2227,7 @@ function App(): React.JSX.Element {
); );
const handleShortcutSettingsChanged = useCallback( const handleShortcutSettingsChanged = useCallback(
(snapshot: GlobalShortcutSettingsSnapshot): void => { (snapshot: GlobalShortcutSettingsSnapshot): void => {
setAppInfo((current) => setSavedShortcut(snapshot);
current
? {
...current,
shortcut: snapshot.registered ? snapshot.displayAccelerator : "",
shortcutStatus: snapshot.status,
}
: current,
);
}, },
[], [],
); );
@@ -2339,7 +2343,13 @@ function App(): React.JSX.Element {
} }
}, [applicationSettingsUnconfirmed, applyApplicationSettings, guardNoteDraft, setNoteDraft, t]); }, [applicationSettingsUnconfirmed, applyApplicationSettings, guardNoteDraft, setNoteDraft, t]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null); const [searchOpen, setSearchOpen] = useState(false);
const searchTriggerRef = useRef<HTMLButtonElement>(null);
const closeConversationSearch = (): void => {
setSearchQuery("");
setSearchOpen(false);
requestAnimationFrame(() => searchTriggerRef.current?.focus());
};
const deferredSearchQuery = useDeferredValue(searchQuery); const deferredSearchQuery = useDeferredValue(searchQuery);
const [localSearchMatches, setLocalSearchMatches] = const [localSearchMatches, setLocalSearchMatches] =
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() }); useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
@@ -8723,13 +8733,63 @@ function App(): React.JSX.Element {
onOpenConversation={openActivityConversation} onOpenConversation={openActivityConversation}
/> />
{activeProject?.kind !== "channel" && ( <div
<button className="new-chat" onClick={newConversation} type="button"> className={`sidebar-conversation-controls${searchOpen ? " sidebar-conversation-controls--searching" : ""}`}
<MessageSquarePlus size={17} /> onKeyDown={(event) => {
<span>{t("sidebar.newConversation")}</span> if (searchOpen && event.key === "Escape") {
<kbd>Ctrl N</kbd> event.preventDefault();
</button> event.stopPropagation();
)} closeConversationSearch();
}
}}
>
{activeProject?.kind !== "channel" && (
<button
className="new-chat"
onClick={newConversation}
type="button"
aria-label={t("sidebar.newConversation")}
title={t("sidebar.newConversation")}
>
<MessageSquarePlus aria-hidden="true" size={17} />
{!searchOpen && <span>{t("sidebar.newConversation")}</span>}
</button>
)}
<div className="sidebar-search-slot">
{searchOpen ? (
<div className="sidebar-search">
<input
autoFocus
aria-label={t("sidebar.searchLabel")}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
value={searchQuery}
/>
<button
aria-label={t("sidebar.closeSearch")}
className="icon-button sidebar-search__clear"
onClick={closeConversationSearch}
title={t("sidebar.closeSearch")}
type="button"
>
<X aria-hidden="true" size={16} />
</button>
</div>
) : (
<button
ref={searchTriggerRef}
className="icon-button sidebar-search-trigger"
aria-label={t("sidebar.searchLabel")}
aria-expanded={false}
title={t("sidebar.searchLabel")}
onClick={() => setSearchOpen(true)}
type="button"
>
<Search aria-hidden="true" size={17} />
</button>
)}
</div>
</div>
<nav className="primary-nav" aria-label={t("navigation.label")}> <nav className="primary-nav" aria-label={t("navigation.label")}>
<button <button
@@ -8791,32 +8851,6 @@ function App(): React.JSX.Element {
</nav> </nav>
<section className="sidebar-conversations" aria-label={t("sidebar.recent")}> <section className="sidebar-conversations" aria-label={t("sidebar.recent")}>
<div className="sidebar-conversations__header">
<div className="sidebar-search">
<Search aria-hidden="true" size={15} />
<input
aria-label={t("sidebar.searchLabel")}
ref={searchInputRef}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
value={searchQuery}
/>
{searchQuery.length > 0 && (
<button
aria-label={t("conversation.clearSearch")}
className="icon-button sidebar-search__clear"
onClick={() => {
setSearchQuery("");
searchInputRef.current?.focus();
}}
title={t("conversation.clearSearch")}
type="button"
>
<X aria-hidden="true" size={14} />
</button>
)}
</div>
</div>
<div className="conversation-list"> <div className="conversation-list">
{!conversationLoadError && {!conversationLoadError &&
filteredConversations.map((conversation) => { filteredConversations.map((conversation) => {
@@ -9865,10 +9899,10 @@ function App(): React.JSX.Element {
runtime?.capability === "image-generation" runtime?.capability === "image-generation"
? t("composer.imagePlaceholder") ? t("composer.imagePlaceholder")
: t("composer.placeholder") : t("composer.placeholder")
}\n${t("composer.keyboardHint")}`} }\n${composerKeyboardHint}\n${composerConversationHint}`}
ref={inputRef} ref={inputRef}
rows={3} rows={3}
title={t("composer.keyboardHint")} title={`${composerKeyboardHint}\n${composerConversationHint}`}
value={input} value={input}
onChange={(event) => setInput(event.target.value)} onChange={(event) => setInput(event.target.value)}
onPaste={(event) => { onPaste={(event) => {
@@ -10801,12 +10835,6 @@ function App(): React.JSX.Element {
{contextError} {contextError}
</span> </span>
)} )}
{appInfo?.shortcut && (
<span className="composer-meta__shortcut">
{t("composer.shortcut")}
<kbd>{appInfo.shortcut}</kbd>
</span>
)}
</div> </div>
</> </>
)} )}
+26 -3
View File
@@ -8,14 +8,28 @@ const stylesheet = readFileSync(
).replaceAll('\r\n', '\n') ).replaceAll('\r\n', '\n')
describe('primary sidebar layout', () => { describe('primary sidebar layout', () => {
it('keeps search above an independently scrolling conversation list', () => { it('keeps the original new conversation typography and left alignment, centering only its compact icon', () => {
const button = stylesheet.match(/\.new-chat\s*\{([^}]*)\}/u)?.[1]
const label = stylesheet.match(/\.new-chat span\s*\{([^}]*)\}/u)?.[1]
const compact = stylesheet.match(/\.sidebar-conversation-controls--searching \.new-chat\s*\{([^}]*)\}/u)?.[1]
expect(button).toContain('justify-content: flex-start;')
expect(button).toContain('padding: 0 var(--space-3);')
expect(label).toContain('font-size: 13px;')
expect(label).toContain('font-weight: 600;')
expect(label).not.toContain('white-space: nowrap;')
expect(compact).toContain('justify-content: center;')
expect(compact).toContain('padding: 0;')
})
it('keeps conversation controls fixed above an independently scrolling list', () => {
const section = stylesheet.match(/\.sidebar-conversations\s*\{([^}]*)\}/u)?.[1] const section = stylesheet.match(/\.sidebar-conversations\s*\{([^}]*)\}/u)?.[1]
const header = stylesheet.match(/\.sidebar-conversations__header\s*\{([^}]*)\}/u)?.[1] const controls = stylesheet.match(/\.sidebar-conversation-controls\s*\{([^}]*)\}/u)?.[1]
const list = stylesheet.match(/\.conversation-list\s*\{([^}]*)\}/u)?.[1] const list = stylesheet.match(/\.conversation-list\s*\{([^}]*)\}/u)?.[1]
expect(section).toContain('min-height: 0;') expect(section).toContain('min-height: 0;')
expect(section).toContain('flex-direction: column;') expect(section).toContain('flex-direction: column;')
expect(section).toContain('background: var(--surface-raised);') expect(section).toContain('background: var(--surface-raised);')
expect(header).toContain('flex: 0 0 auto;') expect(controls).toContain('flex: 0 0 44px;')
expect(controls).toContain('height: 44px;')
expect(stylesheet).not.toContain('.sidebar-conversations__header')
expect(list).toContain('min-width: 0;') expect(list).toContain('min-width: 0;')
expect(list).toContain('min-height: 0;') expect(list).toContain('min-height: 0;')
expect(list).toContain('overflow: auto;') expect(list).toContain('overflow: auto;')
@@ -25,6 +39,15 @@ describe('primary sidebar layout', () => {
expect(shortWindow).toContain('min-height: 200px;') expect(shortWindow).toContain('min-height: 200px;')
}) })
it('expands search within the row and disables its width animation for reduced motion', () => {
const slot = stylesheet.match(/\.sidebar-search-slot\s*\{([^}]*)\}/u)?.[1]
expect(slot).toContain('margin-left: auto;')
expect(slot).toContain('transition: width var(--motion-normal) ease-out;')
expect(stylesheet).toMatch(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.sidebar-search-slot\s*\{\s*transition: none;/u)
const responsive = stylesheet.split('@media (max-width: 1020px) {')[1]?.split('@media')[0]
expect(responsive).not.toMatch(/\.(?:new-chat|sidebar-search),/u)
})
it('floats conversation actions outside list layout with viewport limits', () => { it('floats conversation actions outside list layout with viewport limits', () => {
const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1] const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1]
expect(rule).toContain('position: fixed;') expect(rule).toContain('position: fixed;')
+4 -2
View File
@@ -82,6 +82,7 @@ export const app = {
label: 'Main sidebar', label: 'Main sidebar',
newConversation: 'New conversation', newConversation: 'New conversation',
searchLabel: 'Search conversations', searchLabel: 'Search conversations',
closeSearch: 'Close and clear search',
searchPlaceholder: 'Search titles or messages', searchPlaceholder: 'Search titles or messages',
recent: 'Recent conversations', recent: 'Recent conversations',
localWorkspace: 'Local workspace', localWorkspace: 'Local workspace',
@@ -490,7 +491,8 @@ export const app = {
placeholder: 'Message GoodBuddy…', placeholder: 'Message GoodBuddy…',
imagePlaceholder: 'Describe the image you want to generate…', imagePlaceholder: 'Describe the image you want to generate…',
keyboardHint: keyboardHint:
'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 · {{pasteShortcut}} to paste',
newConversationHint: '{{shortcut}} for a new conversation',
addContent: 'Add content', addContent: 'Add content',
addAttachment: 'Add attachment', addAttachment: 'Add attachment',
attachmentProgress: { attachmentProgress: {
@@ -547,7 +549,7 @@ export const app = {
removeAria: 'Remove “{{title}}” from the send queue', removeAria: 'Remove “{{title}}” from the send queue',
actionFailed: 'Failed to update the send queue' actionFailed: 'Failed to update the send queue'
}, },
shortcut: 'Quick access: ', shortcut: '{{shortcut}} for quick access',
context: { context: {
confirmedTokenCount: 'Latest call {{used}}', confirmedTokenCount: 'Latest call {{used}}',
tokenCount: 'Estimated latest call ≈{{used}}', tokenCount: 'Estimated latest call ≈{{used}}',
+4 -2
View File
@@ -79,6 +79,7 @@ export const app = {
label: '主侧栏', label: '主侧栏',
newConversation: '新建对话', newConversation: '新建对话',
searchLabel: '搜索对话', searchLabel: '搜索对话',
closeSearch: '关闭并清除搜索',
searchPlaceholder: '搜索标题或消息', searchPlaceholder: '搜索标题或消息',
recent: '最近会话', recent: '最近会话',
localWorkspace: '本地工作区', localWorkspace: '本地工作区',
@@ -471,7 +472,8 @@ export const app = {
placeholder: '给 GoodBuddy 发消息…', placeholder: '给 GoodBuddy 发消息…',
imagePlaceholder: '描述你想生成的图片…', imagePlaceholder: '描述你想生成的图片…',
keyboardHint: keyboardHint:
'Enter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本', 'Enter 发送 · Shift+Enter 换行 · {{pasteShortcut}} 粘贴',
newConversationHint: '{{shortcut}} 新建对话',
addContent: '添加内容', addContent: '添加内容',
addAttachment: '添加附件', addAttachment: '添加附件',
attachmentProgress: { attachmentProgress: {
@@ -524,7 +526,7 @@ export const app = {
removeAria: '从待发送队列删除“{{title}}”', removeAria: '从待发送队列删除“{{title}}”',
actionFailed: '待发送队列操作失败' actionFailed: '待发送队列操作失败'
}, },
shortcut: '快捷唤起:', shortcut: '{{shortcut}} 快捷唤起',
context: { context: {
confirmedTokenCount: '本次调用 {{used}}', confirmedTokenCount: '本次调用 {{used}}',
tokenCount: '本次调用估算 ≈{{used}}', tokenCount: '本次调用估算 ≈{{used}}',
+63 -36
View File
@@ -2581,7 +2581,7 @@ textarea:focus-visible {
.sidebar > .brand, .sidebar > .brand,
.sidebar > .project-switcher, .sidebar > .project-switcher,
.sidebar > .new-chat, .sidebar > .sidebar-conversation-controls,
.sidebar > .primary-nav, .sidebar > .primary-nav,
.sidebar > .sidebar-conversations, .sidebar > .sidebar-conversations,
.sidebar > .sidebar-footer { .sidebar > .sidebar-footer {
@@ -2695,8 +2695,12 @@ textarea:focus-visible {
.project-switcher__row { .project-switcher__row {
display: grid; display: grid;
align-items: center; align-items: center;
gap: 5px; gap: var(--space-2);
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) 36px;
}
.project-switcher__row > .icon-button {
width: 100%;
} }
.project-switcher__picker { .project-switcher__picker {
@@ -3483,14 +3487,52 @@ textarea:focus-visible {
align-self: auto; align-self: auto;
} }
.sidebar-conversation-controls {
display: flex;
flex: 0 0 44px;
height: 44px;
min-width: 0;
margin-bottom: var(--space-3);
gap: var(--space-2);
}
.sidebar-search-slot {
width: 36px;
min-width: 0;
flex: 0 0 auto;
margin-left: auto;
transition: width var(--motion-normal) ease-out;
}
.sidebar-conversation-controls--searching .sidebar-search-slot {
width: calc(100% - 36px - var(--space-2));
}
.sidebar-conversation-controls--searching .sidebar-search-slot:only-child {
width: 100%;
}
.sidebar-search-slot .sidebar-search-trigger {
width: 100%;
height: 100%;
}
@media (prefers-reduced-motion: reduce) {
.sidebar-search-slot {
transition: none;
}
}
.new-chat { .new-chat {
display: flex; display: flex;
min-width: 248px; overflow: hidden;
min-width: 36px;
flex: 1;
align-items: center; align-items: center;
padding: var(--space-3); justify-content: flex-start;
padding: 0 var(--space-3);
border: 1px solid var(--accent-solid); border: 1px solid var(--accent-solid);
border-radius: var(--radius-control); border-radius: var(--radius-control);
margin-bottom: var(--space-3);
background: var(--accent-solid); background: var(--accent-solid);
color: var(--text-on-accent); color: var(--text-on-accent);
cursor: pointer; cursor: pointer;
@@ -3504,27 +3546,27 @@ textarea:focus-visible {
} }
.new-chat span { .new-chat span {
flex: 1;
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
} }
.new-chat kbd { .new-chat > svg {
padding: 2px 5px; flex-shrink: 0;
border: 1px solid transparent; }
border-radius: 5px;
background: transparent; .sidebar-conversation-controls--searching .new-chat {
color: var(--text-on-accent); justify-content: center;
font-size: var(--font-caption); padding: 0;
} }
.sidebar-search { .sidebar-search {
position: relative; position: relative;
display: flex; display: flex;
min-width: 0; min-width: 0;
height: 36px;
margin-block: var(--space-1);
align-items: center; align-items: center;
padding: 9px 11px; padding: 0;
padding-right: 40px;
border: 1px solid var(--border-control); border: 1px solid var(--border-control);
border-radius: var(--radius-control); border-radius: var(--radius-control);
background: transparent; background: transparent;
@@ -3539,7 +3581,8 @@ textarea:focus-visible {
.sidebar-search input { .sidebar-search input {
min-width: 0; min-width: 0;
width: 100%; width: calc(100% - 40px - var(--space-2));
margin-left: var(--space-2);
border: 0; border: 0;
outline: 0; outline: 0;
background: transparent; background: transparent;
@@ -3551,16 +3594,12 @@ textarea:focus-visible {
color: var(--text-muted); color: var(--text-muted);
} }
.sidebar-search > svg {
flex-shrink: 0;
}
.sidebar-search .sidebar-search__clear { .sidebar-search .sidebar-search__clear {
position: absolute; position: absolute;
top: 50%; top: 50%;
right: var(--space-2); right: var(--space-1);
width: 24px; width: 32px;
height: 24px; height: 32px;
transform: translateY(-50%); transform: translateY(-50%);
} }
@@ -3652,11 +3691,6 @@ textarea:focus-visible {
background: var(--surface-raised); background: var(--surface-raised);
} }
.sidebar-conversations__header {
flex: 0 0 auto;
padding: var(--space-2) var(--space-1) var(--space-3);
}
.conversation-list { .conversation-list {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
@@ -7950,11 +7984,6 @@ button > svg {
color: var(--danger); color: var(--danger);
} }
.composer-meta__shortcut {
color: var(--text-muted);
white-space: nowrap;
}
.composer-context-compact { .composer-context-compact {
display: inline-flex; display: inline-flex;
min-width: var(--composer-context-compact-width); min-width: var(--composer-context-compact-width);
@@ -16833,8 +16862,6 @@ textarea.field-control {
.brand, .brand,
.project-switcher, .project-switcher,
.new-chat,
.sidebar-search,
.primary-nav, .primary-nav,
.conversation-list, .conversation-list,
.sidebar-footer { .sidebar-footer {
+1 -1
View File
@@ -60,7 +60,7 @@ it('captures messages and full history from App through real Electron preload, I
process.stdout.write(`Magic notes capture Electron evidence: ${JSON.stringify(result)}\n`) process.stdout.write(`Magic notes capture Electron evidence: ${JSON.stringify(result)}\n`)
expect(result.error, output).toBeUndefined() expect(result.error, output).toBeUndefined()
expect(result.dangerButtonStyles.map((style: { theme: string }) => style.theme)).toEqual(['light', 'dark']) expect(result.dangerButtonStyles.map((style: { theme: string }) => style.theme)).toEqual(['light', 'dark'])
expect(result).toMatchObject({ existingSaved: true, newSaved: true, fullHistoryCount: 502, expect(result).toMatchObject({ sidebarSearch: 6, existingSaved: true, newSaved: true, fullHistoryCount: 502,
sourceOpened: true, workspaceOpened: true, keyboard: true, narrowPanel: true, sourceOpened: true, workspaceOpened: true, keyboard: true, narrowPanel: true,
sqliteReopened: true, sourceSurvivesDeletion: true, modelAttempts: 0 }) sqliteReopened: true, sourceSurvivesDeletion: true, modelAttempts: 0 })
} finally { } finally {
@@ -8,6 +8,7 @@ import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database' import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
import { ApplicationSettingsStore } from '../../src/main/application-settings-store' import { ApplicationSettingsStore } from '../../src/main/application-settings-store'
import { defaultRuntimeSettings } from '../../src/shared/contracts' import { defaultRuntimeSettings } from '../../src/shared/contracts'
import { verifySidebarSearch } from './sidebar-search-probe'
const directory = process.env.GB_CAPTURE_DIRECTORY! const directory = process.env.GB_CAPTURE_DIRECTORY!
app.setPath('userData', join(directory, 'profile')) app.setPath('userData', join(directory, 'profile'))
@@ -84,6 +85,7 @@ app.whenReady().then(async () => {
win.webContents.focus() win.webContents.focus()
await wait('document.hasFocus()', 'visible capture window focus') await wait('document.hasFocus()', 'visible capture window focus')
await wait('document.body.innerText.includes("CAPTURE_HISTORY_501_END")', 'App persisted history') await wait('document.body.innerText.includes("CAPTURE_HISTORY_501_END")', 'App persisted history')
evidence.sidebarSearch = await verifySidebarSearch(win)
assert.equal(await run('document.body.innerText.includes("CAPTURE_HISTORY_0_END")'), false, 'old history must initially be folded') assert.equal(await run('document.body.innerText.includes("CAPTURE_HISTORY_0_END")'), false, 'old history must initially be folded')
// The last assistant reply is rendered by the real ChatTimeline. // The last assistant reply is rendered by the real ChatTimeline.
await run(`[...document.querySelectorAll('button')].filter(e => e.getAttribute('aria-label') === 'Add to note' || e.title === 'Add to note').at(-1).click()`) await run(`[...document.querySelectorAll('button')].filter(e => e.getAttribute('aria-label') === 'Add to note' || e.title === 'Add to note').at(-1).click()`)
+140
View File
@@ -0,0 +1,140 @@
import type { BrowserWindow } from 'electron'
import assert from 'node:assert/strict'
import { setTimeout } from 'node:timers/promises'
export async function verifySidebarSearch(win: BrowserWindow): Promise<number> {
const run = <T = unknown>(code: string): Promise<T> => win.webContents.executeJavaScript(code)
const wait = async (code: string): Promise<void> => {
for (let i = 0; i < 200; i++) {
if (await run(code)) return
await setTimeout(25)
}
throw new Error(`Sidebar search timeout: ${code}`)
}
const click = async (selector: string): Promise<void> => {
const point = await run<{ x: number; y: number }>(`(() => {
const e = document.querySelector(${JSON.stringify(selector)}), r = e.getBoundingClientRect();
if (!e.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2))) throw new Error('Control obscured: ' + ${JSON.stringify(selector)});
return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) };
})()`)
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
}
const measure = `(() => {
const row = document.querySelector('.sidebar-conversation-controls').getBoundingClientRect();
const slot = document.querySelector('.sidebar-search-slot').getBoundingClientRect();
const create = document.querySelector('.new-chat');
return { top: document.querySelector('.conversation-list').getBoundingClientRect().top,
right: slot.right, width: slot.width, height: row.height, overflow: create.scrollWidth - create.clientWidth,
createWidth: create.getBoundingClientRect().width };
})()`
type Bounds = { top: number; right: number; width: number; height: number; overflow: number; createWidth: number }
let observations = 0
const originalWidth = await run<string>('document.querySelector(".sidebar").style.getPropertyValue("--primary-sidebar-width")')
win.webContents.debugger.attach('1.3')
try {
await run('document.fonts.ready')
await wait('document.querySelector(".composer textarea")?.matches(":placeholder-shown")')
assert.equal(await run(`(async () => {
const input = document.querySelector('.composer textarea');
const info = await window.goodbuddy.app.getInfo();
return input.placeholder.split('\\n').length === 3 &&
input.placeholder.includes((info.platform === 'darwin' ? 'Command' : 'Ctrl') + '+N for a new conversation') &&
(!info.shortcut || input.placeholder.includes(info.shortcut + ' for quick access')) &&
!document.querySelector('.composer-meta__shortcut');
})()`), true)
win.show()
win.focus()
win.webContents.focus()
await wait('document.hasFocus()')
await click('.composer textarea')
await wait('document.activeElement?.matches(".composer textarea")')
await win.webContents.insertText('Placeholder visibility check')
await wait('document.querySelector(".composer textarea").value === "Placeholder visibility check"')
assert.equal(await run('document.querySelector(".composer textarea").matches(":placeholder-shown")'), false)
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'A', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'A', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] })
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Backspace' })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Backspace' })
await wait('document.querySelector(".composer textarea").matches(":placeholder-shown")')
for (const theme of ['light', 'dark']) {
for (const width of [220, 420, 280]) {
win.setSize(width === 280 ? 760 : 1280, width === 280 ? 560 : 900)
await wait(width === 280 ? 'window.innerWidth < 900' : 'window.innerWidth >= 900')
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
if (await run('document.querySelector(".sidebar").classList.contains("sidebar--closed")')) {
await click('.sidebar-toggle')
}
await run(`document.documentElement.dataset.theme = '${theme}'; document.querySelector('.sidebar').style.setProperty('--primary-sidebar-width', '${width}px')`)
await wait('!document.querySelector(".sidebar").classList.contains("sidebar--closed")')
await run('Promise.all(document.querySelector(".sidebar").getAnimations().map(animation => animation.finished))')
const before = await run<Bounds>(measure)
assert.equal(await run(`(() => {
const button = document.querySelector('.new-chat');
const label = getComputedStyle(button.querySelector('span'));
return !!button.querySelector('.lucide-message-square-plus') &&
getComputedStyle(button).justifyContent === 'flex-start' &&
label.fontSize === '13px' && label.fontWeight === '600';
})()`), true)
assert.ok(before.overflow <= 1, `New conversation text overflow at ${width}: ${JSON.stringify(before)}`)
await click('.sidebar-search-trigger')
await wait('document.activeElement?.matches(".sidebar-search input")')
await run(`new Promise((resolve, reject) => {
const sample = () => {
const search = document.querySelector('.sidebar-search').getBoundingClientRect();
const top = document.querySelector('.conversation-list').getBoundingClientRect().top;
if (Math.abs(search.right - ${before.right}) >= 0.01 || top !== ${before.top}) {
reject(new Error('Search edge or list moved during expansion')); return;
}
if (document.querySelector('.sidebar-search-slot').getAnimations().length) requestAnimationFrame(sample);
else resolve(true);
};
requestAnimationFrame(sample);
})`)
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
const after = await run<Bounds>(measure)
assert.equal(await run(`(() => {
const button = document.querySelector('.new-chat');
const icon = button.querySelector('.lucide-message-square-plus').getBoundingClientRect();
const bounds = button.getBoundingClientRect();
return Math.abs(icon.x + icon.width / 2 - bounds.x - bounds.width / 2) < 1;
})()`), true)
assert.ok(Math.abs(after.right - before.right) < 0.01, 'stable right edge within floating-point rounding')
assert.equal(after.top, before.top)
assert.equal(after.height, before.height)
assert.ok(after.width > before.width)
await win.webContents.insertText('Capture discussion')
await click('.conversation-item')
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
if (await run('document.querySelector(".sidebar").classList.contains("sidebar--closed")')) {
await click('.sidebar-toggle')
await wait('!document.querySelector(".sidebar").classList.contains("sidebar--closed")')
await run('Promise.all(document.querySelector(".sidebar").getAnimations().map(animation => animation.finished))')
}
assert.equal(await run('document.querySelector(".sidebar-search input").value'), 'Capture discussion')
await click('.sidebar-search input')
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
await wait('document.activeElement?.matches(".sidebar-search-trigger")')
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
assert.equal((await run<Bounds>(measure)).top, before.top)
await click('.sidebar-search-trigger')
await wait('document.activeElement?.matches(".sidebar-search input")')
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
assert.equal(await run('document.querySelector(".sidebar-search input").value'), '')
await click('.sidebar-search__clear')
await wait('document.activeElement?.matches(".sidebar-search-trigger")')
await run('Promise.all(document.querySelector(".sidebar-search-slot").getAnimations().map(animation => animation.finished))')
observations++
}
}
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] })
assert.equal(await run('getComputedStyle(document.querySelector(".sidebar-search-slot")).transitionProperty'), 'none')
return observations
} finally {
await win.webContents.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [] })
win.webContents.debugger.detach()
win.setSize(1280, 900)
await run(`document.querySelector('.sidebar').style.setProperty('--primary-sidebar-width', ${JSON.stringify(originalWidth)}); document.documentElement.dataset.theme = 'light'`)
}
}