fix: refine sidebar search and composer shortcut hints
This commit is contained in:
+14
-1
@@ -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',
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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;')
|
||||
|
||||
@@ -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}}',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user