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
+107 -2
View File
@@ -8,6 +8,8 @@ import {
builtInDefaultProjectSeedDescription,
builtInDefaultProjectSeedName,
conversationSnapshotSchema,
conversationSetPinnedSchema,
localConversationHeaderSchema,
isUntouchedBuiltInDefaultProject
} from '../../shared/assistant-contracts'
import {
@@ -19,6 +21,95 @@ import type { ImageOperation } from '../../shared/image-generation-contracts'
const temporaryDirectories: string[] = []
it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
const database = await createDatabase()
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
const message = { id: randomUUID(), role: 'user' as const, content: 'Keep me', state: 'complete' as const, createdAt: 900 }
try {
database.saveLocalConversations([{ header, messages: [message] }])
const original = database.getConversation(header.id)
expect(original.pinned).toBe(false)
expect(conversationSnapshotSchema.parse({ ...header, messages: [] }).pinned).toBeUndefined()
expect(localConversationHeaderSchema.safeParse({ ...header, pinned: false }).success).toBe(false)
expect(conversationSetPinnedSchema.safeParse({ conversationId: header.id, pinned: 'true' }).success).toBe(false)
expect(conversationSetPinnedSchema.safeParse({ conversationId: header.id, pinned: true, updatedAt: 2000 }).success).toBe(false)
database.setConversationPinned({ conversationId: header.id, pinned: true })
database.setConversationPinned({ conversationId: header.id, pinned: true })
// Even a stale snapshot passed directly to storage cannot overwrite the pin.
database.saveLocalConversations([{ header: { ...header, ...{ pinned: false } }, messages: [message] }])
expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true })
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true })
database.setConversationPinned({ conversationId: header.id, pinned: false })
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual(original)
expect(() => database.setConversationPinned({ conversationId: randomUUID(), pinned: true })).toThrow('对话不存在')
} finally {
database.close()
}
})
it('includes older pinned conversations beyond the recent 100 and sorts pins by update time', async () => {
const database = await createDatabase()
const headers = Array.from({ length: 103 }, (_, index) => ({
id: randomUUID(), title: `Conversation ${index}`, updatedAt: 1000 + index
}))
try {
database.saveLocalConversations(headers.map(header => ({ header, messages: [] })))
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: true })
database.setConversationPinned({ conversationId: headers[1]!.id, pinned: true })
const expectedIds = [headers[1]!.id, headers[0]!.id, ...headers.slice(3).reverse().map(header => header.id)]
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds)
const summaries = database.listConversationSummaries()
expect(summaries.map(item => item.id)).toEqual(expectedIds)
expect(summaries.slice(0, 2).every(item => item.pinned)).toBe(true)
expect(database.searchConversations('Conversation 0')).toEqual([headers[0]!.id])
database.setConversationPinned({ conversationId: headers[0]!.id, pinned: false })
expect(database.listConversations().map(item => item.id)).toEqual(expectedIds.filter(id => id !== headers[0]!.id))
expect(database.getConversation(headers[0]!.id)).toMatchObject({ pinned: false, updatedAt: 1000 })
} finally {
database.close()
}
})
it('migrates schema 35 conversations to unpinned while retaining existing data across reopening', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-conversation-pins-'))
temporaryDirectories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
database.initialize('C:\\Workspace')
const header = { id: randomUUID(), title: 'Before migration', updatedAt: 1000 }
database.saveLocalConversations([{ header, messages: [] }])
const original = database.getConversation(header.id)
database.close()
const legacy = new DatabaseSync(path)
legacy.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 35;')
legacy.close()
try {
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual(original)
database.setConversationPinned({ conversationId: header.id, pinned: true })
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toEqual({ ...original, pinned: true })
} finally {
database.close()
}
const inspected = new DatabaseSync(path)
try {
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({ user_version: 36 })
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
expect(inspected.prepare('SELECT created_at, updated_at FROM conversations WHERE id = ?').get(header.id)).toEqual({
created_at: new Date(1000).toISOString(), updated_at: new Date(1000).toISOString()
})
} finally {
inspected.close()
}
})
it('atomically completes conversation images and retains operations and upload references across saves and reopening', async () => {
const database = await createDatabase()
const conversationId = randomUUID()
@@ -732,6 +823,7 @@ describe('AssistantDatabase', () => {
oldDatabase.exec(`
DROP TABLE model_usage_calls;
ALTER TABLE projects DROP COLUMN runtime_selection_json;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 3;
`)
oldDatabase.close()
@@ -888,6 +980,7 @@ describe('AssistantDatabase', () => {
.all()
legacy.exec(`
DROP TABLE project_execution_spaces;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 26;
`)
legacy.close()
@@ -997,6 +1090,7 @@ describe('AssistantDatabase', () => {
project_id TEXT PRIMARY KEY,
runtime_bundle_digest TEXT NOT NULL
);
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 30;
`)
legacy.close()
@@ -1068,6 +1162,7 @@ describe('AssistantDatabase', () => {
INSERT INTO project_runtime_validations
(project_id, runtime_bundle_digest)
VALUES ('${project.id}', 'sha256:${'d'.repeat(64)}');
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 30;
`)
legacy.close()
@@ -1158,6 +1253,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1198,6 +1294,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1239,6 +1336,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1276,6 +1374,7 @@ describe('AssistantDatabase', () => {
legacy.exec(`
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1302,6 +1401,7 @@ describe('AssistantDatabase', () => {
SET updated_at = created_at || '-edited';
DROP INDEX projects_built_in_default_unique;
ALTER TABLE projects DROP COLUMN built_in_default;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 24;
`)
legacy.close()
@@ -1325,6 +1425,7 @@ describe('AssistantDatabase', () => {
const versionFive = new DatabaseSync(databasePath)
versionFive.exec(`
DROP TABLE computer_control_actions;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 5;
`)
versionFive.close()
@@ -1445,6 +1546,7 @@ describe('AssistantDatabase', () => {
)
raw.exec(`
DROP INDEX IF EXISTS idx_tasks_schedule;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 21;
COMMIT;
`)
@@ -1532,6 +1634,7 @@ describe('AssistantDatabase', () => {
const legacy = new DatabaseSync(databasePath)
legacy.exec(`
DELETE FROM magic_todos;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 9;
`)
legacy.close()
@@ -1583,7 +1686,7 @@ describe('AssistantDatabase', () => {
now,
now
)
legacy.exec('PRAGMA user_version = 16')
legacy.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 16')
legacy.close()
const migrated = new AssistantDatabase(databasePath)
@@ -2528,7 +2631,7 @@ describe('AssistantDatabase', () => {
WHERE id = ?`
)
.run(entry.id)
legacy.exec('PRAGMA user_version = 23')
legacy.exec('ALTER TABLE conversations DROP COLUMN pinned; PRAGMA user_version = 23')
legacy.close()
const migrated = new AssistantDatabase(databasePath)
@@ -2663,6 +2766,7 @@ describe('AssistantDatabase', () => {
ON channel_events(claimed_at);
INSERT INTO channel_events(channel, event_id, claimed_at)
VALUES ('weixin', 'legacy-event', 1);
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 18;
`)
legacy.close()
@@ -3169,6 +3273,7 @@ describe('AssistantDatabase', () => {
ALTER TABLE task_events DROP COLUMN remote_semantic_sequence;
ALTER TABLE task_events DROP COLUMN remote_operation_id;
ALTER TABLE task_events DROP COLUMN remote_binding_id;
ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = 31;
`)
legacy.close()
+33 -4
View File
@@ -9,6 +9,7 @@ import {
conversationAnsweredQuestionSchema,
conversationMessageSchema,
conversationSnapshotSchema,
conversationSetPinnedSchema,
expertCreateSchema,
normalizeInteractiveWorkMode,
persistedProjectExecutionSpaceSchema,
@@ -34,6 +35,7 @@ import type {
ConversationToolActivity,
ConversationMessage,
ConversationSnapshot,
ConversationSetPinnedInput,
ExpertCreateInput,
ExpertUpdateInput,
HeartbeatCreateInput,
@@ -102,7 +104,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 36
export type RemoteTaskEventInput = {
taskId: string
@@ -209,6 +211,7 @@ type ConversationRow = {
account_display: string | null
branch_source_conversation_id: string | null
branch_source_title: string | null
pinned: number
updated_at: string
}
@@ -1149,6 +1152,7 @@ function toConversationSnapshot(
}
: {}),
title: conversation.title,
pinned: conversation.pinned === 1,
updatedAt: Date.parse(conversation.updated_at),
// Startup recovery is the only authority for interruption: it persists
// the terminal state once. Deriving it while reading would also hit
@@ -2634,7 +2638,7 @@ export class AssistantDatabase {
external_account_id, external_conversation_id,
conversation_type, account_display,
branch_source_conversation_id, branch_source_title,
updated_at
pinned, updated_at
FROM conversations
WHERE status = 'active'
AND (
@@ -2642,11 +2646,12 @@ export class AssistantDatabase {
ORDER BY updated_at DESC LIMIT 100)
OR id IN (SELECT conversation_id FROM (${activeVisibleTaskSelect}))
OR id IN (SELECT value FROM json_each(?))
OR pinned = 1
OR EXISTS (SELECT 1 FROM messages
WHERE conversation_id = conversations.id
AND state = 'streaming')
)
ORDER BY updated_at DESC`
ORDER BY pinned DESC, updated_at DESC`
)
.all(JSON.stringify([...extraIds])) as ConversationRow[]
}
@@ -2715,7 +2720,7 @@ export class AssistantDatabase {
external_account_id, external_conversation_id,
conversation_type, account_display,
branch_source_conversation_id, branch_source_title,
updated_at
pinned, updated_at
FROM conversations
WHERE id = ? AND status = 'active'`
)
@@ -2740,6 +2745,16 @@ export class AssistantDatabase {
return toConversationSnapshot(conversation, messages)
}
setConversationPinned(input: ConversationSetPinnedInput): void {
const { conversationId, pinned } = conversationSetPinnedSchema.parse(input)
const result = this.requireDatabase().prepare(
`UPDATE conversations SET pinned = ? WHERE id = ? AND status = 'active'`
).run(pinned ? 1 : 0, conversationId)
if (result.changes !== 1) {
throw new Error('对话不存在')
}
}
repairConversationRuntimeSelections(
settings: RuntimeSelectionRepairSettings
): number {
@@ -9916,6 +9931,20 @@ export class AssistantDatabase {
// Startup's worker has already compacted full and schema-34 delta events.
database.exec(`PRAGMA user_version = ${SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION}`)
}
if (version.user_version < 36) {
database.exec('BEGIN IMMEDIATE')
try {
database.exec(`
ALTER TABLE conversations ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0
CHECK(pinned IN (0, 1));
PRAGMA user_version = 36;
COMMIT;
`)
} catch (error) {
database.exec('ROLLBACK')
throw error
}
}
}
private requireDatabase(): DatabaseSync {
@@ -317,7 +317,8 @@ describe('subagent progress storage', () => {
const legacy = new DatabaseSync(path)
const expected: SubagentEvent[] = []
try {
legacy.exec(`PRAGMA user_version = ${sourceVersion}; BEGIN`)
legacy.exec(`ALTER TABLE conversations DROP COLUMN pinned;
PRAGMA user_version = ${sourceVersion}; BEGIN`)
const insert = legacy.prepare(
`INSERT INTO task_events(
task_id, kind, payload_json, created_at, remote_binding_id,
+13 -1
View File
@@ -205,6 +205,7 @@ import {
conversationSnapshotsSchema,
conversationListRequestSchema,
conversationSearchRequestSchema,
conversationSetPinnedSchema,
type ConversationSnapshot,
localConversationSaveBatchSchema,
memoryCreateSchema,
@@ -1259,7 +1260,7 @@ export function registerIpcHandlers(
signal: AbortSignal,
tabId?: BrowserTabId,
ownerWindowId?: number
): Promise<void>
): Promise<unknown>
type(
conversationId: string,
ref: string,
@@ -6828,6 +6829,17 @@ export function registerIpcHandlers(
}
)
registerHandler(
ipcChannels.conversationsSetPinned,
(event, input: unknown) => {
assertTrustedSender(event, window)
assistantDatabase.setConversationPinned(
conversationSetPinnedSchema.parse(input)
)
publishConversationChange()
}
)
registerHandler(
ipcChannels.conversationsBranchLocal,
(event, input: unknown) => {
+3
View File
@@ -1088,6 +1088,9 @@ const desktopApi: DesktopApi = {
batch
)
},
setPinned: async (input) => {
await ipcRenderer.invoke(ipcChannels.conversationsSetPinned, input)
},
branchLocal: (input: ConversationBranchInput) =>
ipcRenderer.invoke(
ipcChannels.conversationsBranchLocal,
+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);
+12
View File
@@ -553,6 +553,8 @@ export const conversationSnapshotSchema = z
.optional(),
branch: conversationBranchSchema.optional(),
title: z.string().trim().min(1).max(200),
// Absent in older snapshots means unpinned; written only via setPinned.
pinned: z.boolean().optional(),
updatedAt: z.number().int().nonnegative(),
// Main-only live projection. Never part of a saved conversation header.
activeRequest: z.object({
@@ -583,6 +585,15 @@ export const conversationSearchRequestSchema = z.object({
conversationIds: z.array(assistantIdSchema).default([])
}).strict()
export const conversationSetPinnedSchema = z.object({
conversationId: assistantIdSchema,
pinned: z.boolean()
}).strict()
export type ConversationSetPinnedInput = z.infer<
typeof conversationSetPinnedSchema
>
export const conversationSnapshotsSchema = z
.array(conversationSnapshotSchema)
.max(100)
@@ -590,6 +601,7 @@ export const conversationSnapshotsSchema = z
export const localConversationHeaderSchema = conversationSnapshotSchema
.omit({
messages: true,
pinned: true,
remote: true,
activeRequest: true
})
+3
View File
@@ -46,6 +46,7 @@ import {
type LiveAgentQuestion,
type ConversationAttachment,
type ConversationBranchInput,
type ConversationSetPinnedInput,
type ConversationQueueItem,
type ConversationContextCompressionState,
type LocalConversationSaveBatch,
@@ -1393,6 +1394,7 @@ export const browserLiveStateSchema = z
conversationId: conversationIdSchema,
tabId: browserTabIdSchema,
workbarInstanceId: browserWorkbarInstanceIdSchema.optional(),
openerTabId: browserTabIdSchema.optional(),
ownerWindowId: z.number().int().nonnegative().optional(),
status: browserStatusSchema,
sessionActive: z.boolean(),
@@ -2059,6 +2061,7 @@ export type DesktopApi = {
search: (query: string, conversationIds?: string[]) => Promise<string[]>
replace: (conversations: ConversationSnapshot[]) => Promise<void>
saveLocal: (batch: LocalConversationSaveBatch) => Promise<void>
setPinned: (input: ConversationSetPinnedInput) => Promise<void>
branchLocal: (
input: ConversationBranchInput
) => Promise<ConversationSnapshot>
+1
View File
@@ -190,6 +190,7 @@ export const ipcChannels = {
conversationsSearch: 'conversations:search',
conversationsReplace: 'conversations:replace',
conversationsSaveLocal: 'conversations:save-local',
conversationsSetPinned: 'conversations:set-pinned',
conversationsBranchLocal: 'conversations:branch-local',
conversationsDeleteLocal: 'conversations:delete-local',
conversationsChanged: 'conversations:changed',