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
+14 -1
View File
@@ -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
@@ -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',
+86 -33
View File
@@ -1416,6 +1416,7 @@ describe("App", () => {
const initialTasks = deferred<AssistantTask[]>();
vi.mocked(api.tasks.list).mockReturnValueOnce(initialTasks.promise);
render(<App />);
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(<App />);
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<HTMLElement>(".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(<App />);
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<HTMLElement>(".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(<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 = {
checkUpdatesOnStartup: false,
desktopNotificationsEnabled: true,
@@ -5774,7 +5814,12 @@ describe("App", () => {
};
try {
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: '设置' }));
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());
+80 -52
View File
@@ -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<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(
() => 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<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 [localSearchMatches, setLocalSearchMatches] =
useState<{ query: string; ids: Set<string> }>({ query: "", ids: new Set() });
@@ -8723,13 +8733,63 @@ function App(): React.JSX.Element {
onOpenConversation={openActivityConversation}
/>
{activeProject?.kind !== "channel" && (
<button className="new-chat" onClick={newConversation} type="button">
<MessageSquarePlus size={17} />
<span>{t("sidebar.newConversation")}</span>
<kbd>Ctrl N</kbd>
</button>
)}
<div
className={`sidebar-conversation-controls${searchOpen ? " sidebar-conversation-controls--searching" : ""}`}
onKeyDown={(event) => {
if (searchOpen && event.key === "Escape") {
event.preventDefault();
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")}>
<button
@@ -8791,32 +8851,6 @@ function App(): React.JSX.Element {
</nav>
<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">
{!conversationLoadError &&
filteredConversations.map((conversation) => {
@@ -9865,10 +9899,10 @@ function App(): React.JSX.Element {
runtime?.capability === "image-generation"
? t("composer.imagePlaceholder")
: t("composer.placeholder")
}\n${t("composer.keyboardHint")}`}
}\n${composerKeyboardHint}\n${composerConversationHint}`}
ref={inputRef}
rows={3}
title={t("composer.keyboardHint")}
title={`${composerKeyboardHint}\n${composerConversationHint}`}
value={input}
onChange={(event) => setInput(event.target.value)}
onPaste={(event) => {
@@ -10801,12 +10835,6 @@ function App(): React.JSX.Element {
{contextError}
</span>
)}
{appInfo?.shortcut && (
<span className="composer-meta__shortcut">
{t("composer.shortcut")}
<kbd>{appInfo.shortcut}</kbd>
</span>
)}
</div>
</>
)}
+26 -3
View File
@@ -8,14 +8,28 @@ const stylesheet = readFileSync(
).replaceAll('\r\n', '\n')
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 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]
expect(section).toContain('min-height: 0;')
expect(section).toContain('flex-direction: column;')
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-height: 0;')
expect(list).toContain('overflow: auto;')
@@ -25,6 +39,15 @@ describe('primary sidebar layout', () => {
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', () => {
const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1]
expect(rule).toContain('position: fixed;')
+4 -2
View File
@@ -82,6 +82,7 @@ export const app = {
label: 'Main sidebar',
newConversation: 'New conversation',
searchLabel: 'Search conversations',
closeSearch: 'Close and clear search',
searchPlaceholder: 'Search titles or messages',
recent: 'Recent conversations',
localWorkspace: 'Local workspace',
@@ -490,7 +491,8 @@ export const app = {
placeholder: 'Message GoodBuddy…',
imagePlaceholder: 'Describe the image you want to generate…',
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',
addAttachment: 'Add attachment',
attachmentProgress: {
@@ -547,7 +549,7 @@ export const app = {
removeAria: 'Remove “{{title}}” from the send queue',
actionFailed: 'Failed to update the send queue'
},
shortcut: 'Quick access: ',
shortcut: '{{shortcut}} for quick access',
context: {
confirmedTokenCount: 'Latest call {{used}}',
tokenCount: 'Estimated latest call ≈{{used}}',
+4 -2
View File
@@ -79,6 +79,7 @@ export const app = {
label: '主侧栏',
newConversation: '新建对话',
searchLabel: '搜索对话',
closeSearch: '关闭并清除搜索',
searchPlaceholder: '搜索标题或消息',
recent: '最近会话',
localWorkspace: '本地工作区',
@@ -471,7 +472,8 @@ export const app = {
placeholder: '给 GoodBuddy 发消息…',
imagePlaceholder: '描述你想生成的图片…',
keyboardHint:
'Enter 发送,Shift+Enter 换行,Ctrl+V 粘贴文件、图片或文本',
'Enter 发送 · Shift+Enter 换行 · {{pasteShortcut}} 粘贴',
newConversationHint: '{{shortcut}} 新建对话',
addContent: '添加内容',
addAttachment: '添加附件',
attachmentProgress: {
@@ -524,7 +526,7 @@ export const app = {
removeAria: '从待发送队列删除“{{title}}”',
actionFailed: '待发送队列操作失败'
},
shortcut: '快捷唤起:',
shortcut: '{{shortcut}} 快捷唤起',
context: {
confirmedTokenCount: '本次调用 {{used}}',
tokenCount: '本次调用估算 ≈{{used}}',
+63 -36
View File
@@ -2581,7 +2581,7 @@ textarea:focus-visible {
.sidebar > .brand,
.sidebar > .project-switcher,
.sidebar > .new-chat,
.sidebar > .sidebar-conversation-controls,
.sidebar > .primary-nav,
.sidebar > .sidebar-conversations,
.sidebar > .sidebar-footer {
@@ -2695,8 +2695,12 @@ textarea:focus-visible {
.project-switcher__row {
display: grid;
align-items: center;
gap: 5px;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-2);
grid-template-columns: minmax(0, 1fr) 36px;
}
.project-switcher__row > .icon-button {
width: 100%;
}
.project-switcher__picker {
@@ -3483,14 +3487,52 @@ textarea:focus-visible {
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 {
display: flex;
min-width: 248px;
overflow: hidden;
min-width: 36px;
flex: 1;
align-items: center;
padding: var(--space-3);
justify-content: flex-start;
padding: 0 var(--space-3);
border: 1px solid var(--accent-solid);
border-radius: var(--radius-control);
margin-bottom: var(--space-3);
background: var(--accent-solid);
color: var(--text-on-accent);
cursor: pointer;
@@ -3504,27 +3546,27 @@ textarea:focus-visible {
}
.new-chat span {
flex: 1;
font-size: 13px;
font-weight: 600;
}
.new-chat kbd {
padding: 2px 5px;
border: 1px solid transparent;
border-radius: 5px;
background: transparent;
color: var(--text-on-accent);
font-size: var(--font-caption);
.new-chat > svg {
flex-shrink: 0;
}
.sidebar-conversation-controls--searching .new-chat {
justify-content: center;
padding: 0;
}
.sidebar-search {
position: relative;
display: flex;
min-width: 0;
height: 36px;
margin-block: var(--space-1);
align-items: center;
padding: 9px 11px;
padding-right: 40px;
padding: 0;
border: 1px solid var(--border-control);
border-radius: var(--radius-control);
background: transparent;
@@ -3539,7 +3581,8 @@ textarea:focus-visible {
.sidebar-search input {
min-width: 0;
width: 100%;
width: calc(100% - 40px - var(--space-2));
margin-left: var(--space-2);
border: 0;
outline: 0;
background: transparent;
@@ -3551,16 +3594,12 @@ textarea:focus-visible {
color: var(--text-muted);
}
.sidebar-search > svg {
flex-shrink: 0;
}
.sidebar-search .sidebar-search__clear {
position: absolute;
top: 50%;
right: var(--space-2);
width: 24px;
height: 24px;
right: var(--space-1);
width: 32px;
height: 32px;
transform: translateY(-50%);
}
@@ -3652,11 +3691,6 @@ textarea:focus-visible {
background: var(--surface-raised);
}
.sidebar-conversations__header {
flex: 0 0 auto;
padding: var(--space-2) var(--space-1) var(--space-3);
}
.conversation-list {
min-width: 0;
min-height: 0;
@@ -7950,11 +7984,6 @@ button > svg {
color: var(--danger);
}
.composer-meta__shortcut {
color: var(--text-muted);
white-space: nowrap;
}
.composer-context-compact {
display: inline-flex;
min-width: var(--composer-context-compact-width);
@@ -16833,8 +16862,6 @@ textarea.field-control {
.brand,
.project-switcher,
.new-chat,
.sidebar-search,
.primary-nav,
.conversation-list,
.sidebar-footer {