feat(composer): move role picker into the input and keep it per conversation

Show the selected role as a compact chip in the input's top-left corner, persist the choice per conversation, and snapshot it on replies so the timeline shows the role as a caption under GoodBuddy.
This commit is contained in:
mesalogo
2026-10-09 23:26:23 +08:00
parent e07a17f79f
commit c6a8455f88
11 changed files with 247 additions and 34 deletions
@@ -134,6 +134,27 @@ it('persists the story graph switch per conversation without schema migration or
} finally { database.close() }
})
it('persists the composer role per conversation and the role snapshot on replies', async () => {
const database = await createDatabase()
const expertId = randomUUID()
const header = { id: randomUUID(), title: 'Role preference', updatedAt: 1000, selectedExpertId: expertId }
const team = { id: randomUUID(), title: 'Team preference', updatedAt: 1000, selectedExpertId: 'team' as const }
const plain = { id: randomUUID(), title: 'No preference', updatedAt: 1000 }
const reply = { id: randomUUID(), role: 'assistant' as const, content: 'Reviewed', state: 'complete' as const,
createdAt: 900, expert: { kind: 'expert' as const, id: expertId, name: '代码审查专家' } }
try {
database.saveLocalConversations([{ header, messages: [reply] }, { header: team, messages: [] }, { header: plain, messages: [] }])
database.close()
database.initialize('C:\\Workspace')
expect(database.getConversation(header.id)).toMatchObject({ selectedExpertId: expertId, messages: [{ expert: reply.expert }] })
expect(database.getConversation(team.id).selectedExpertId).toBe('team')
expect(database.getConversation(plain.id).selectedExpertId).toBeUndefined()
database.saveLocalConversations([{ header: { ...header, selectedExpertId: undefined, updatedAt: 1001 }, messages: [] }])
expect(database.getConversation(header.id).selectedExpertId).toBeUndefined()
expect(localConversationHeaderSchema.safeParse({ ...plain, selectedExpertId: 'not-an-id' }).success).toBe(false)
} finally { database.close() }
})
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 }
+10 -2
View File
@@ -364,6 +364,7 @@ type MessageMetadata = {
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
tools?: ConversationSnapshot['messages'][number]['tools']
subagents?: ConversationSnapshot['messages'][number]['subagents']
expert?: ConversationMessage['expert']
sources?: string[]
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
@@ -1238,6 +1239,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
knowledgeLibraryIds: true,
knowledgeRetrievalMode: true,
storyGraphEnabled: true,
selectedExpertId: true,
contextMetrics: true,
contextCompressionState: true
})
@@ -1246,7 +1248,7 @@ function parseConversationContextState(
value: string | null
): Pick<
ConversationSnapshot,
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
> {
if (!value) {
return {}
@@ -1264,18 +1266,20 @@ function parseConversationContextState(
function serializeConversationContextState(
conversation: Pick<
ConversationSnapshot,
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState'
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
>
): string | null {
return conversation.knowledgeLibraryIds !== undefined ||
conversation.knowledgeRetrievalMode !== undefined ||
conversation.storyGraphEnabled !== undefined ||
conversation.selectedExpertId !== undefined ||
conversation.contextMetrics ||
conversation.contextCompressionState
? JSON.stringify({
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled,
selectedExpertId: conversation.selectedExpertId,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState
})
@@ -1346,6 +1350,7 @@ function toConversationSnapshot(
contextCompressions: metadata.contextCompressions,
tools: metadata.tools,
subagents: metadata.subagents,
expert: metadata.expert,
sources: metadata.sources,
sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval,
@@ -1377,6 +1382,7 @@ function serializeConversationMessageMetadata(
contextCompressions: message.contextCompressions,
tools: message.tools,
subagents: message.subagents,
expert: message.expert,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
@@ -1784,6 +1790,7 @@ function storedRemoteAssistantMessage(
contextCompressions: metadata.contextCompressions,
tools: metadata.tools,
subagents: metadata.subagents,
expert: metadata.expert,
sources: metadata.sources,
sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval,
@@ -5638,6 +5645,7 @@ export class AssistantDatabase {
contextCompressions: metadata.contextCompressions,
tools: metadata.tools,
subagents: metadata.subagents,
expert: metadata.expert,
sources: metadata.sources,
sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval,