feat: pin conversations and float their action menus

Persist conversation pins across restarts and show pinned conversations first, including older history. Anchor action menus above the list without shifting neighboring rows, with keyboard dismissal and sidebar-close cleanup.

Add schema 36 migration, focused regression coverage, and feature documentation. File-level staging also includes existing workbar styles and browser contract adjustments in shared files.

Validation: 145 backend tests and 237 App tests passed; typecheck and lint passed. Full test run timed out with Runtime and Agent package failures; native Electron visual validation remains pending.

Release note: 会话菜单新增置顶和取消置顶,重启后仍保留;操作菜单现在悬浮显示,不再挤动下方会话。
This commit is contained in:
mesalogo
2026-09-15 23:39:06 +08:00
parent b16b0d10e7
commit 625182c3dc
19 changed files with 583 additions and 52 deletions
+161 -18
View File
@@ -496,6 +496,7 @@ const api: DesktopApi = {
search: vi.fn(async () => []),
replace: vi.fn(async () => {}),
saveLocal: vi.fn(async () => {}),
setPinned: vi.fn(async () => {}),
branchLocal: vi.fn(async (input) => ({
id: crypto.randomUUID(),
branch: {
@@ -1091,6 +1092,7 @@ describe("App", () => {
};
});
vi.mocked(api.conversations.list).mockReset().mockResolvedValue([]);
vi.mocked(api.conversations.setPinned).mockReset().mockResolvedValue();
const conversationFixtures = new Map<string, Awaited<ReturnType<DesktopApi["conversations"]["get"]>>>();
vi.mocked(api.conversations.listSummaries).mockReset()
.mockImplementation(async () => {
@@ -1349,7 +1351,7 @@ describe("App", () => {
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
await waitFor(() => expect(sidebar.querySelector(".conversation-more")).toBeInTheDocument());
fireEvent.click(sidebar.querySelector<HTMLButtonElement>(".conversation-more")!);
expect(sidebar.querySelector(".conversation-actions")).toBeInTheDocument();
expect(screen.getByRole("menu")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /全项目活动/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Background project/u }));
fireEvent.click(screen.getByRole("menuitem", { name: /Exact background discussion/u }));
@@ -1365,7 +1367,7 @@ describe("App", () => {
.toHaveClass("conversation-item--active");
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
selectProjectOption(project.name);
expect(sidebar.querySelector(".conversation-actions")).not.toBeInTheDocument();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
}
@@ -1626,11 +1628,11 @@ describe("App", () => {
vi.mocked(api.conversations.get).mockResolvedValue(second);
render(<App />);
fireEvent.click(await screen.findByLabelText("更多会话操作 Copy unopened"));
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "复制完整会话" }));
await waitFor(() => expect(api.clipboard.writeText).toHaveBeenCalledWith(expect.stringContaining("All retained text")));
expect(api.conversations.get).toHaveBeenCalledExactlyOnceWith(second.id);
fireEvent.click(screen.getByLabelText("更多会话操作 Copy unopened"));
fireEvent.click(screen.getByRole("button", { name: "复制完整会话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "复制完整会话" }));
await waitFor(() => expect(api.conversations.get).toHaveBeenCalledTimes(2));
expect(screen.queryByText("All retained text")).not.toBeInTheDocument();
});
@@ -3114,7 +3116,145 @@ describe("App", () => {
expect(await screen.findByDisplayValue("本地语音结果")).toBeInTheDocument();
});
it("keeps conversation actions in the conversation list", async () => {
it("clamps the conversation portal and supports keyboard and outside dismissal", async () => {
render(<App />);
const trigger = await screen.findByLabelText("更多会话操作 新对话");
vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ right: 5000, bottom: 5000 } as DOMRect);
fireEvent.click(trigger);
const menu = screen.getByRole("menu");
expect(menu.parentElement).toBe(document.body);
expect(parseFloat(menu.style.left)).toBeLessThanOrEqual(window.innerWidth - 8);
expect(parseFloat(menu.style.top)).toBeLessThanOrEqual(window.innerHeight - 8);
expect(within(menu).getByRole("menuitem", { name: "置顶会话" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "End" });
expect(within(menu).getByRole("menuitem", { name: "删除对话 新对话" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Home" });
fireEvent.keyDown(document.activeElement!, { key: "ArrowDown" });
expect(within(menu).getByRole("menuitem", { name: "在新会话中继续" })).toHaveFocus();
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await waitFor(() => expect(trigger).toHaveFocus());
fireEvent.click(trigger);
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
fireEvent.click(trigger);
act(() => screen.getByLabelText("向 GoodBuddy 提问").focus());
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
});
it("dismisses the conversation portal and delete confirmation when resizing closes the sidebar", async () => {
const originalWidth = window.innerWidth;
const { container } = render(<App />);
const trigger = await screen.findByLabelText("更多会话操作 新对话");
fireEvent.click(trigger);
const menu = screen.getByRole("menu");
expect(menu.parentElement).toBe(document.body);
fireEvent.click(within(menu).getByRole("menuitem", { name: "删除对话 新对话" }));
expect(screen.getByRole("alertdialog")).toBeInTheDocument();
try {
Object.defineProperty(window, "innerWidth", { configurable: true, value: 899 });
fireEvent(window, new Event("resize"));
expect(container.querySelector(".sidebar")).toHaveAttribute("inert");
expect(menu).not.toBeInTheDocument();
expect(screen.queryByRole("alertdialog", { hidden: true })).not.toBeInTheDocument();
expect(trigger).toHaveAttribute("aria-expanded", "false");
fireEvent.click(screen.getByRole("button", { name: "切换侧栏" }));
expect(screen.queryByRole("menu", { hidden: true })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("button", { name: "对话" })).toHaveFocus());
fireEvent.click(trigger);
expect(screen.getByRole("menuitem", { name: "删除对话 新对话" })).toBeVisible();
expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument();
expect(api.conversations.deleteLocal).not.toHaveBeenCalled();
} finally {
Object.defineProperty(window, "innerWidth", { configurable: true, value: originalWidth });
}
});
it("persists a draft before pinning and reports pin failures without changing its state", async () => {
vi.mocked(api.conversations.list).mockResolvedValue([{ id: "saved", projectId, title: "Saved discussion", updatedAt: 100, messages: [] }]);
const { container } = render(<App />);
await screen.findByLabelText("更多会话操作 Saved discussion");
fireEvent.click(container.querySelector(".new-chat")!);
await waitFor(() => expect(screen.getByLabelText("向 GoodBuddy 提问")).toHaveFocus());
const trigger = await screen.findByLabelText("更多会话操作 新对话");
fireEvent.click(trigger);
const pin = screen.getByRole("menuitem", { name: "置顶会话" });
await waitFor(() => expect(pin).toBeEnabled());
let persistedBeforePin = false;
vi.mocked(api.conversations.setPinned).mockImplementationOnce(async ({ conversationId }) => {
persistedBeforePin = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch)
.some(entry => entry.header.id === conversationId);
});
fireEvent.click(pin);
await waitFor(() => expect(api.conversations.setPinned).toHaveBeenCalledOnce());
expect(persistedBeforePin).toBe(true);
expect(await screen.findByRole("img", { name: "已置顶" })).toBeVisible();
expect(api.conversations.setPinned).toHaveBeenCalledWith({ conversationId: expect.any(String), pinned: true });
vi.mocked(api.conversations.setPinned).mockRejectedValueOnce(new Error("storage failed"));
fireEvent.click(trigger);
fireEvent.click(screen.getByRole("menuitem", { name: "取消置顶" }));
expect(await screen.findByRole("alert")).toHaveTextContent("无法更新会话置顶状态,请重试");
expect(screen.getByRole("img", { name: "已置顶" })).toBeVisible();
});
it("sorts pinned conversations first and ignores a refresh started before unpinning", async () => {
const snapshots: ConversationSnapshot[] = [
{ id: "pin-new", projectId, title: "Newer discussion", updatedAt: 300, messages: [] },
{ id: "pin-old", projectId, title: "Pinned discussion", updatedAt: 100, pinned: true, messages: [] },
];
vi.mocked(api.conversations.list).mockResolvedValue(snapshots);
const { container } = render(<App />);
await screen.findByLabelText("更多会话操作 Pinned discussion");
const titles = (): string[] => Array.from(container.querySelectorAll(".conversation-item__title")).map(item => item.textContent!);
expect(titles()).toEqual(["Pinned discussion", "Newer discussion"]);
let resolveRefresh!: (value: ConversationSnapshot[]) => void;
vi.mocked(api.conversations.listSummaries).mockImplementationOnce(() => new Promise(resolve => { resolveRefresh = resolve; }));
fireEvent(window, new Event("focus"));
await waitFor(() => expect(resolveRefresh).toBeDefined());
fireEvent.click(screen.getByLabelText("更多会话操作 Pinned discussion"));
fireEvent.click(screen.getByRole("menuitem", { name: "取消置顶" }));
await waitFor(() => expect(titles()).toEqual(["Newer discussion", "Pinned discussion"]));
vi.mocked(api.conversations.list).mockResolvedValue(snapshots.map(item => ({ ...item, pinned: false })));
await act(async () => resolveRefresh(snapshots));
await waitFor(() => expect(api.conversations.listSummaries).toHaveBeenCalledTimes(3));
expect(titles()).toEqual(["Newer discussion", "Pinned discussion"]);
expect(screen.queryByRole("img", { name: "已置顶" })).not.toBeInTheDocument();
});
it("uses the dedicated pin update for channel conversations", async () => {
vi.mocked(api.conversations.list).mockResolvedValue([{
id: "pin-channel", projectId, title: "Channel discussion", updatedAt: 100, messages: [],
remote: { channel: "weixin", accountDisplay: "Channel account", conversationType: "direct" },
}]);
render(<App />);
fireEvent.click(await screen.findByLabelText("更多会话操作 Channel discussion"));
expect(screen.queryByRole("menuitem", { name: "重命名会话" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "置顶会话" }));
await waitFor(() => expect(api.conversations.setPinned).toHaveBeenCalledWith({ conversationId: "pin-channel", pinned: true }));
expect(api.conversations.saveLocal).not.toHaveBeenCalled();
expect(await screen.findByRole("img", { name: "已置顶" })).toBeVisible();
});
it.each([false, true])("merges persisted pin metadata into a newer local conversation (summary: %s)", async (summary) => {
const snapshot: ConversationSnapshot = { id: "pin-merge", projectId, title: "Original discussion", updatedAt: 100, messages: [] };
vi.mocked(api.conversations.list).mockResolvedValue([snapshot]);
render(<App />);
fireEvent.click(await screen.findByLabelText("更多会话操作 Original discussion"));
fireEvent.click(screen.getByRole("menuitem", { name: "重命名会话" }));
const input = screen.getByLabelText("重命名会话 Original discussion");
fireEvent.change(input, { target: { value: "Locally renamed" } });
fireEvent.submit(input.closest("form")!);
vi.mocked(api.conversations.listSummaries).mockResolvedValue([{
...snapshot, pinned: true, ...(summary ? { messageSummary: { count: 0 } } : {}),
}]);
fireEvent(window, new Event("focus"));
expect(await screen.findByRole("img", { name: "已置顶" })).toBeVisible();
expect(screen.getByLabelText("更多会话操作 Locally renamed")).toBeInTheDocument();
});
it("anchors conversation actions in a body portal and preserves rename and copy", async () => {
const { container } = render(<App />);
const topbar = container.querySelector<HTMLElement>(".topbar");
const conversationList =
@@ -3152,17 +3292,20 @@ describe("App", () => {
const conversationMenuTrigger =
within(conversationList).getByLabelText("更多会话操作 新对话");
fireEvent.click(conversationMenuTrigger);
const renameButton = within(conversationList).getByRole("button", {
const menu = screen.getByRole("menu");
expect(menu.parentElement).toBe(document.body);
expect(conversationList).not.toContainElement(menu);
const renameButton = within(menu).getByRole("menuitem", {
name: "重命名会话",
});
expect(renameButton).toBeVisible();
expect(
within(conversationList).getByRole("button", {
within(menu).getByRole("menuitem", {
name: "复制完整会话",
}),
).toBeVisible();
expect(
within(conversationList).getByRole("button", {
within(menu).getByRole("menuitem", {
name: "导出 Markdown",
}),
).toBeVisible();
@@ -3184,7 +3327,7 @@ describe("App", () => {
within(conversationList).getByLabelText("更多会话操作 重命名后的会话"),
);
fireEvent.click(
within(conversationList).getByRole("button", {
screen.getByRole("menuitem", {
name: "复制完整会话",
}),
);
@@ -3271,7 +3414,7 @@ describe("App", () => {
expect(await screen.findByText("探索不同发布方案")).toBeInTheDocument();
fireEvent.click(await screen.findByLabelText("更多会话操作 方案讨论"));
fireEvent.click(screen.getByRole("button", { name: "重命名会话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "重命名会话" }));
const renameInput = screen.getByLabelText("重命名会话 方案讨论");
fireEvent.change(renameInput, {
target: { value: "方案讨论更新" },
@@ -3281,7 +3424,7 @@ describe("App", () => {
vi.mocked(api.conversations.saveLocal).mockClear();
fireEvent.click(screen.getByLabelText("更多会话操作 方案讨论更新"));
const branchButton = screen.getByRole("button", {
const branchButton = screen.getByRole("menuitem", {
name: "在新会话中继续",
});
await waitFor(() => expect(branchButton).toBeEnabled());
@@ -3357,7 +3500,7 @@ describe("App", () => {
const actionTrigger =
await screen.findByLabelText("更多会话操作 保留的来源会话");
fireEvent.click(actionTrigger);
const branchButton = screen.getByRole("button", {
const branchButton = screen.getByRole("menuitem", {
name: "在新会话中继续",
});
await waitFor(() => expect(branchButton).toBeEnabled());
@@ -3408,7 +3551,7 @@ describe("App", () => {
fireEvent.click(
await screen.findByLabelText("更多会话操作 等待队列的会话"),
);
const branchButton = screen.getByRole("button", {
const branchButton = screen.getByRole("menuitem", {
name: "在新会话中继续",
});
await waitFor(() =>
@@ -3850,7 +3993,7 @@ describe("App", () => {
render(<App />);
const menuTrigger = screen.getByLabelText("更多会话操作 新对话");
fireEvent.click(menuTrigger);
const deleteTrigger = screen.getByRole("button", {
const deleteTrigger = screen.getByRole("menuitem", {
name: "删除对话 新对话",
});
fireEvent.click(deleteTrigger);
@@ -3873,7 +4016,7 @@ describe("App", () => {
fireEvent.keyDown(dialog, { key: "Escape" });
await waitFor(() =>
expect(
screen.getByRole("button", { name: "删除对话 新对话" }),
screen.getByRole("menuitem", { name: "删除对话 新对话" }),
).toHaveFocus(),
);
expect(
@@ -3882,7 +4025,7 @@ describe("App", () => {
}),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "删除对话 新对话" }));
fireEvent.click(screen.getByRole("menuitem", { name: "删除对话 新对话" }));
fireEvent.click(
screen.getByRole("button", {
name: "确认永久删除对话 新对话",
@@ -3926,7 +4069,7 @@ describe("App", () => {
render(<App />);
fireEvent.click(await screen.findByLabelText(`更多会话操作 ${title}`));
fireEvent.click(screen.getByRole("button", { name: `删除对话 ${title}` }));
fireEvent.click(screen.getByRole("menuitem", { name: `删除对话 ${title}` }));
fireEvent.click(
screen.getByRole("button", {
name: `确认永久删除对话 ${title}`,
@@ -3966,7 +4109,7 @@ describe("App", () => {
await waitFor(() => expect(run).toHaveBeenCalledOnce());
fireEvent.click(await screen.findByLabelText(`更多会话操作 ${title}`));
fireEvent.click(screen.getByRole("button", { name: `删除对话 ${title}` }));
fireEvent.click(screen.getByRole("menuitem", { name: `删除对话 ${title}` }));
const confirm = screen.getByRole("button", {
name: `确认永久删除对话 ${title}`,
});
+127 -6
View File
@@ -28,6 +28,8 @@ import {
MoreHorizontal,
Paperclip,
PanelLeft,
Pin,
PinOff,
Search,
Send,
Settings,
@@ -1510,7 +1512,7 @@ function mergePersistedConversations(
local.messages.some(message => message.approval || message.pendingQuestions?.length || message.state === "streaming") ||
(!local.messageSummary && retainDetails.has(conversation.id)));
const next = local && local.updatedAt > conversation.updatedAt
? { ...local, messageSummary: conversation.messageSummary, messages: [] }
? { ...local, pinned: conversation.pinned, messageSummary: conversation.messageSummary, messages: [] }
: conversation;
if (keepMessages) {
// A list reply started before navigation must not unload the newly
@@ -1563,7 +1565,7 @@ function mergePersistedConversations(
...local.messages.filter((message) => !serverMessageIds.has(message.id)),
];
const next = localIsNewer
? { ...local, messages, messageSummary: undefined, activeRequest: conversation.activeRequest }
? { ...local, pinned: conversation.pinned, messages, messageSummary: undefined, activeRequest: conversation.activeRequest }
: { ...conversation, messages };
persistedLocal.set(conversation.id, conversation);
return withRecoveredQuestions(next);
@@ -1573,7 +1575,7 @@ function mergePersistedConversations(
merged.push(conversation);
}
}
return merged.sort((left, right) => right.updatedAt - left.updatedAt);
return merged.sort((left, right) => Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) || right.updatedAt - left.updatedAt);
}
function getProjectDefaultRuntimeSelection(
@@ -2559,7 +2561,14 @@ function App(): React.JSX.Element {
const [conversationLoadError, setConversationLoadError] = useState<string>();
const [conversationLoadRetry, setConversationLoadRetry] = useState(0);
const [conversationActionsId, setConversationActionsId] = useState("");
const [pinningConversationId, setPinningConversationId] = useState("");
const conversationPinRevisionRef = useRef(0);
const conversationPinPendingRef = useRef(false);
const [confirmingConversationId, setConfirmingConversationId] = useState("");
if (!sidebarOpen && (conversationActionsId || confirmingConversationId)) {
setConversationActionsId("");
setConfirmingConversationId("");
}
const [deletingConversationId, setDeletingConversationId] = useState("");
const [branchingConversationId, setBranchingConversationId] = useState("");
const [renamingConversationId, setRenamingConversationId] = useState("");
@@ -2723,6 +2732,39 @@ function App(): React.JSX.Element {
const conversationActionTriggerRefs = useRef(
new Map<string, HTMLButtonElement>(),
);
const conversationActionsRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const surface = conversationActionsRef.current;
const trigger = conversationActionTriggerRefs.current.get(conversationActionsId);
if (!surface || !trigger) return;
const position = (): void => {
const anchor = trigger.getBoundingClientRect();
const bounds = surface.getBoundingClientRect();
surface.style.left = `${Math.max(8, Math.min(anchor.right - bounds.width, window.innerWidth - bounds.width - 8))}px`;
surface.style.top = `${Math.max(8, Math.min(anchor.bottom + 4, window.innerHeight - bounds.height - 8))}px`;
};
position();
surface.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus({ preventScroll: true });
const dismiss = (event: Event): void => {
if (event.target instanceof Node && !surface.contains(event.target) && !trigger.contains(event.target)) {
setConversationActionsId("");
setConfirmingConversationId("");
}
};
const resizeObserver = new ResizeObserver(position);
resizeObserver.observe(surface);
document.addEventListener("pointerdown", dismiss);
document.addEventListener("focusin", dismiss);
window.addEventListener("resize", position);
window.addEventListener("scroll", position, true);
return () => {
resizeObserver.disconnect();
document.removeEventListener("pointerdown", dismiss);
document.removeEventListener("focusin", dismiss);
window.removeEventListener("resize", position);
window.removeEventListener("scroll", position, true);
};
}, [conversationActionsId]);
const handleChatScrollSnapshotChange = useCallback(
(conversationId: string, snapshot: ChatScrollSnapshot): void => {
setChatScrollSnapshots((current) => ({
@@ -3827,7 +3869,7 @@ function App(): React.JSX.Element {
(persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) ||
conversation.title.toLocaleLowerCase().includes(query) ||
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
);
).sort((left, right) => Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) || right.updatedAt - left.updatedAt);
}, [
activeProject,
activeProjectId,
@@ -5129,12 +5171,16 @@ function App(): React.JSX.Element {
if (!current.messageSummary) return Promise.resolve(current);
const pending = conversationHistoryRequests.current.get(conversationId);
if (pending) return pending;
const pinRevision = conversationPinRevisionRef.current;
const request = window.goodbuddy.conversations.get(conversationId).then(snapshot => {
const latest = conversationsRef.current.find(item => item.id === conversationId);
if (!latest || deletingLocalConversationIdsRef.current.has(conversationId)) {
throw new Error(tRef.current("notices.remoteConversationRefreshFailed"));
}
if (!latest.messageSummary) return latest;
if (pinRevision !== conversationPinRevisionRef.current || conversationPinPendingRef.current) {
snapshot = { ...snapshot, pinned: latest.pinned };
}
const next = mergePersistedConversations(
conversationsRef.current, [snapshot], persistedLocalConversationsRef.current,
retainedConversationDetailIds(),
@@ -5274,6 +5320,7 @@ function App(): React.JSX.Element {
refreshInFlight = true;
refreshQueued = false;
const sequence = refreshSequence;
const pinRevision = conversationPinRevisionRef.current;
const tasksRefresh = window.goodbuddy.tasks
.list()
.then((tasks) => {
@@ -5320,6 +5367,14 @@ function App(): React.JSX.Element {
if (!active || sequence !== refreshSequence) {
return;
}
if (pinRevision !== conversationPinRevisionRef.current) {
queueRefresh();
return;
}
if (conversationPinPendingRef.current) {
const currentPins = new Map(conversationsRef.current.map(item => [item.id, item.pinned]));
persisted = persisted.map(item => ({ ...item, pinned: currentPins.has(item.id) ? currentPins.get(item.id) : item.pinned }));
}
const remote = persisted.filter(
(conversation) => conversation.remote,
);
@@ -6675,6 +6730,38 @@ function App(): React.JSX.Element {
);
};
const setConversationPinned = async (conversation: Conversation): Promise<void> => {
if (conversationPinPendingRef.current || !conversationStoreReady) return;
conversationPinPendingRef.current = true;
conversationPinRevisionRef.current += 1;
setPinningConversationId(conversation.id);
try {
if (!conversation.remote) {
await conversationPersistenceQueueRef.current;
if (!persistedLocalConversationsRef.current.has(conversation.id)) {
const draft = conversationsRef.current.find(item => item.id === conversation.id);
if (!draft) return;
await window.goodbuddy.conversations.saveLocal([{
header: toLocalConversationHeader(draft),
messages: draft.messages.map(toConversationMessage),
}]);
persistedLocalConversationsRef.current.set(draft.id, draft);
}
}
const pinned = !conversation.pinned;
await window.goodbuddy.conversations.setPinned({ conversationId: conversation.id, pinned });
const next = conversationsRef.current.map(item => item.id === conversation.id ? { ...item, pinned } : item);
conversationsRef.current = next;
setConversations(next);
} catch {
notify({ tone: "error", message: t("notices.conversationPinFailed") });
} finally {
conversationPinPendingRef.current = false;
conversationPinRevisionRef.current += 1;
setPinningConversationId("");
}
};
const deleteConversation = async (conversationId: string): Promise<void> => {
if (deletingConversationId) {
return;
@@ -8855,6 +8942,7 @@ function App(): React.JSX.Element {
}}
>
<span className="conversation-item__primary">
{conversation.pinned && <Pin className="conversation-pin" size={13} role="img" aria-label={t("conversation.actions.pinned")}><title>{t("conversation.actions.pinned")}</title></Pin>}
{branchSourceTitle && (
<ConversationBranchBadge
sourceTitle={branchSourceTitle}
@@ -8908,6 +8996,7 @@ function App(): React.JSX.Element {
)}
<button
aria-controls={`conversation-actions-${conversation.id}`}
aria-haspopup="menu"
aria-expanded={conversationActionsId === conversation.id}
aria-label={t("conversation.actions.more", {
title: conversationTitle,
@@ -8937,17 +9026,45 @@ function App(): React.JSX.Element {
<MoreHorizontal size={14} />
</button>
</div>
{conversationActionsId === conversation.id && (
{sidebarOpen && conversationActionsId === conversation.id && createPortal(
<div
ref={conversationActionsRef}
role="menu"
onKeyDown={(event) => {
if (event.defaultPrevented) return;
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setConversationActionsId("");
setConfirmingConversationId("");
focusConversationActions(conversation.id);
return;
}
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const items = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled):not([aria-disabled="true"])'));
const index = items.indexOf(document.activeElement as HTMLButtonElement);
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
items[next]?.focus();
}}
aria-label={t("conversation.actions.region", {
title: conversationTitle,
})}
className="conversation-actions"
id={`conversation-actions-${conversation.id}`}
>
<button role="menuitem" type="button" disabled={!conversationStoreReady || Boolean(pinningConversationId)} onClick={() => {
setConversationActionsId("");
focusConversationActions(conversation.id);
void setConversationPinned(conversation);
}}>
{conversation.pinned ? <PinOff size={14} /> : <Pin size={14} />}
{t(conversation.pinned ? "conversation.actions.unpin" : "conversation.actions.pin")}
</button>
{!conversation.remote && (
<>
<button
role="menuitem"
aria-describedby={
branchDisabledReason
? branchDisabledReasonId
@@ -8991,6 +9108,7 @@ function App(): React.JSX.Element {
)}
{!conversation.remote && (
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
setRenamingConversationId(conversation.id);
@@ -9002,6 +9120,7 @@ function App(): React.JSX.Element {
</button>
)}
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
void copyConversation(conversation).finally(() =>
@@ -9014,6 +9133,7 @@ function App(): React.JSX.Element {
{t("conversation.actions.copy")}
</button>
<button
role="menuitem"
onClick={() => {
setConversationActionsId("");
exportConversation(conversation);
@@ -9026,6 +9146,7 @@ function App(): React.JSX.Element {
</button>
{!conversation.remote && (
<DestructiveConfirmActions
triggerRole="menuitem"
cancelAriaLabel={t("conversation.delete.cancelAria", {
title: conversationTitle,
})}
@@ -9058,7 +9179,7 @@ function App(): React.JSX.Element {
triggerLabel={t("conversation.delete.trigger")}
/>
)}
</div>
</div>, document.body
)}
{!conversation.remote &&
renamingConversationId === conversation.id && (
@@ -8,6 +8,15 @@ const stylesheet = readFileSync(
).replaceAll('\r\n', '\n')
describe('primary sidebar layout', () => {
it('floats conversation actions outside list layout with viewport limits', () => {
const rule = stylesheet.match(/\.conversation-actions\s*\{([^}]*)\}/u)?.[1]
expect(rule).toContain('position: fixed;')
expect(rule).toContain('max-width: calc(100vw - 16px);')
expect(rule).toContain('max-height: calc(100vh - 16px);')
expect(rule).toContain('overflow: auto;')
expect(rule).toContain('z-index: calc(var(--z-dialog) - 1);')
})
it('keeps the resize hit target from adding space beside the divider', () => {
const rule = stylesheet.match(
/\.primary-sidebar-resize-handle\s*\{([^}]*)\}/u
+3
View File
@@ -371,6 +371,7 @@ export function DestructiveConfirmActions({
onConfirm,
onRequestConfirm,
triggerAriaLabel,
triggerRole,
triggerLabel
}: {
cancelAriaLabel?: string
@@ -384,6 +385,7 @@ export function DestructiveConfirmActions({
onConfirm: () => void
onRequestConfirm: () => void
triggerAriaLabel?: string
triggerRole?: 'menuitem'
triggerLabel: string
}): React.JSX.Element {
const { t } = useTranslation('workspace')
@@ -473,6 +475,7 @@ export function DestructiveConfirmActions({
) : (
<button
aria-label={triggerAriaLabel}
role={triggerRole}
className="danger-button danger-button--quiet"
disabled={disabled}
onClick={onRequestConfirm}
@@ -112,6 +112,9 @@ export const app = {
region: 'Conversation actions for {{title}}',
branch: 'Continue in a new conversation',
rename: 'Rename conversation',
pin: 'Pin conversation',
unpin: 'Unpin conversation',
pinned: 'Pinned',
copy: 'Copy full conversation',
export: 'Export Markdown'
},
@@ -694,6 +697,7 @@ export const app = {
'Failed to resume this conversation’s send queue',
conversationPersistenceFailed:
'Failed to save conversations. Check local storage.',
conversationPinFailed: 'Could not update the conversation pin. Please try again.',
activityHistoryPersistenceFailed:
'Failed to save run history. Check local storage.',
activityHistoryReadFailed: 'Failed to read run history',
@@ -105,6 +105,9 @@ export const app = {
region: '{{title}} 的会话操作',
branch: '在新会话中继续',
rename: '重命名会话',
pin: '置顶会话',
unpin: '取消置顶',
pinned: '已置顶',
copy: '复制完整会话',
export: '导出 Markdown'
},
@@ -646,6 +649,7 @@ export const app = {
conversationQueueResumeFailed:
'无法继续执行当前对话的待发送队列',
conversationPersistenceFailed: '会话持久化失败,请检查本地存储',
conversationPinFailed: '无法更新会话置顶状态,请重试',
activityHistoryPersistenceFailed: '运行记录保存失败,请检查本地存储',
activityHistoryReadFailed: '运行记录读取失败',
remoteConversationRefreshFailed: '远程通道会话刷新失败',
+57 -11
View File
@@ -3940,7 +3940,7 @@ button > svg {
display: flex;
min-width: 0;
min-height: 46px;
padding: 6px 46px 0 8px;
padding: 6px 8px 0;
border-bottom: 1px solid var(--border-default);
background: var(--surface-subtle);
flex: 0 0 46px;
@@ -3960,10 +3960,16 @@ button > svg {
display: none;
}
.workbar-shell__tab-strip {
display: flex;
width: max-content;
align-items: flex-end;
gap: var(--space-1);
}
.workbar-shell__tablist {
display: flex;
width: max-content;
min-width: 100%;
align-items: flex-end;
gap: 2px;
}
@@ -3975,10 +3981,10 @@ button > svg {
max-width: 180px;
height: 39px;
align-items: center;
border: 1px solid transparent;
border: 1px solid var(--border-subtle);
border-bottom: 0;
border-radius: var(--radius-control) var(--radius-control) 0 0;
background: transparent;
background: color-mix(in srgb, var(--surface-muted) 60%, var(--surface-subtle));
transition:
border-color var(--motion-fast) ease-out,
background var(--motion-fast) ease-out,
@@ -3986,7 +3992,7 @@ button > svg {
}
.workbar-shell__tab-item:hover {
background: var(--surface-muted);
background: var(--surface-subtle);
}
.workbar-shell__tab-item--active {
@@ -4047,6 +4053,7 @@ button > svg {
white-space: nowrap;
}
.workbar-shell__scroll-button,
.workbar-shell__tab-close,
.workbar-shell__add {
display: grid;
@@ -4113,9 +4120,25 @@ button > svg {
}
.workbar-shell__add {
flex: 0 0 28px;
margin-bottom: 5px;
background: var(--accent-subtle);
color: var(--accent);
background: transparent;
color: var(--text-muted);
}
.workbar-shell__scroll-button {
flex: 0 0 28px;
margin-bottom: 5px;
}
.workbar-shell__scroll-button:hover:not(:disabled) {
background: var(--surface-muted);
color: var(--text-primary);
}
.workbar-shell__scroll-button:disabled {
opacity: 0.4;
cursor: default;
}
.workbar-shell__content,
@@ -12084,13 +12107,31 @@ details.settings-section > :not(summary) + :not(summary) {
}
.conversation-actions {
position: fixed;
z-index: calc(var(--z-dialog) - 1);
display: grid;
width: max-content;
min-width: 160px;
max-width: calc(100vw - 16px);
max-height: calc(100vh - 16px);
overflow: auto;
padding: var(--space-1);
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
margin: 2px 5px var(--space-2);
background: var(--surface-subtle);
gap: 2px;
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
gap: var(--space-1);
-webkit-app-region: no-drag;
}
.conversation-pin {
flex: 0 0 auto;
color: var(--text-secondary);
}
.conversation-actions button[type='button'] {
min-height: 32px;
font-size: var(--font-body);
}
.conversation-actions button,
@@ -12121,6 +12162,11 @@ details.settings-section > :not(summary) + :not(summary) {
opacity: 0.65;
}
.conversation-actions button:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.conversation-rename {
display: grid;
padding: var(--space-1);