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:
@@ -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()
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user