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,
+46 -5
View File
@@ -8751,13 +8751,16 @@ describe("App", () => {
const conversationSettings = within(composer!).getByRole("group", {
name: "对话设置",
});
// Order: context sources, expert, Runtime, then Runtime-specific settings.
// The role chip sits in the input's top-left corner, before the textarea.
const inputArea = composer!.querySelector<HTMLElement>(".composer__input")!;
const expertTrigger = within(inputArea).getByRole("button", { name: "专家角色:通用助手" });
expect(expertTrigger.compareDocumentPosition(screen.getByLabelText("向 GoodBuddy 提问")) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(within(conversationSettings).queryByRole("button", { name: "专家角色:通用助手" })).not.toBeInTheDocument();
// Toolbar order: context sources, Runtime, then Runtime-specific settings.
const contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" });
const expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" });
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(contextTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(
within(conversationSettings).queryByRole("button", {
name: /工作模式/u,
@@ -12583,7 +12586,8 @@ describe("App", () => {
it("can dispatch a request to the parallel expert team", async () => {
render(<App />);
// The role belongs to the loaded conversation.
await screen.findByLabelText("发送");
selectComposerOption("专家角色", "专家团队(并行)");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "制定发布计划" },
@@ -12671,6 +12675,43 @@ describe("App", () => {
);
});
it("keeps the role per conversation and snapshots it on the reply", async () => {
const expertId = "00000000-0000-4000-8000-000000000511";
vi.mocked(api.experts.list).mockResolvedValueOnce([
{
id: expertId,
name: "审查专家",
description: "代码审查",
systemInstructions: "Review code.",
routingKeywords: [],
enabled: true,
createdAt: "2026-08-01T00:00:00.000Z",
updatedAt: "2026-08-01T00:00:00.000Z",
},
]);
const { container } = render(<App />);
await waitFor(() => expect(api.experts.list).toHaveBeenCalled());
const firstTitle = container.querySelector(".conversation-item--active .conversation-item__title")?.textContent;
const expertMenu = openComposerMenu("专家角色");
fireEvent.click(
(await within(expertMenu).findByText("审查专家", { selector: "span" })).closest<HTMLButtonElement>("button")!,
);
expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: "审查这个改动" } });
fireEvent.click(screen.getByLabelText("发送"));
await waitFor(() => expect(run).toHaveBeenCalledWith(expect.objectContaining({ expertId })));
expect(await screen.findByText("审查专家", { selector: ".message__role" })).toBeVisible();
const create = screen.getAllByRole("button", { name: "新建对话" })[0]!;
fireEvent.click(create);
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:通用助手"));
fireEvent.click(within(container.querySelector<HTMLElement>(".conversation-list")!)
.getByTitle(firstTitle && firstTitle !== "新对话" ? firstTitle : "审查这个改动"));
await waitFor(() => expect(composerMenuTrigger("专家角色")).toHaveAccessibleName("专家角色:审查专家"));
});
it("shows Subagent states and records child expert activity", async () => {
render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
+41 -10
View File
@@ -842,6 +842,7 @@ function toConversationSnapshots(
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled,
selectedExpertId: conversation.selectedExpertId,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
...(conversation.branch ? { branch: conversation.branch } : {}),
@@ -1153,7 +1154,6 @@ function App(): React.JSX.Element {
const [assistantExperts, setAssistantExperts] = useState<AssistantExpert[]>(
[],
);
const [selectedExpertId, setSelectedExpertId] = useState("");
const [activeProjectId, setActiveProjectId] = useState("");
const activeProjectIdRef = useRef(activeProjectId);
const workspaceChangesRequestRef = useRef(0);
@@ -2280,6 +2280,34 @@ function App(): React.JSX.Element {
// Header fields of the active conversation, value-compared: message updates
// (streaming flushes, tool events) do not re-render App.
const activeConversation = useActiveConversationView(conversationStore, activeId);
// The role follows the conversation; a deleted role reads as the general
// assistant.
const resolveExpertSelection = useCallback(
(value: string | undefined): string =>
!value || value === "team" ||
assistantExperts.some((expert) => expert.id === value)
? value ?? ""
: "",
[assistantExperts],
);
const selectedExpertId = resolveExpertSelection(activeConversation?.selectedExpertId);
const setSelectedExpertId = useCallback(
(value: string): void => {
setConversations((current) =>
current.map((conversation) =>
conversation.id === activeId &&
(conversation.selectedExpertId ?? "") !== value
? {
...conversation,
selectedExpertId: value ? value : undefined,
updatedAt: Date.now(),
}
: conversation,
),
);
},
[activeId, setConversations],
);
const enabledKnowledgeLibraryIds =
activeConversation?.knowledgeLibraryIds ?? [];
const setEnabledKnowledgeLibraryIds = useCallback(
@@ -5113,7 +5141,7 @@ function App(): React.JSX.Element {
: (queuedInput.expertId ?? "")
: runtime?.capability === "image-generation"
? ""
: selectedExpertId;
: resolveExpertSelection(conversationSnapshot.selectedExpertId);
const knowledgeLibraryIdsSnapshot =
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds;
const smartRoutingSnapshot =
@@ -5210,6 +5238,14 @@ function App(): React.JSX.Element {
const executionPrompt = memoryContext
? `${prompt}\n\n${memoryContext}`
: prompt;
const selectedExpert = selectedExpertSnapshot && selectedExpertSnapshot !== "team"
? assistantExperts.find((expert) => expert.id === selectedExpertSnapshot)
: undefined;
const expertSnapshot: Message["expert"] = selectedExpertSnapshot === "team"
? { kind: "team" }
: selectedExpert
? { kind: "expert", id: selectedExpert.id, name: selectedExpert.name.trim().slice(0, 80) }
: undefined;
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: "assistant",
@@ -5218,6 +5254,7 @@ function App(): React.JSX.Element {
createdAt: Date.now(),
state: "streaming",
status: t("chat.status.preparingRequest"),
...(expertSnapshot ? { expert: expertSnapshot } : {}),
};
activeRuns.current.set(requestId, {
@@ -6512,16 +6549,10 @@ function App(): React.JSX.Element {
onClose: () => {
commitView(viewRef.current);
},
// Each conversation keeps its role; one that no longer resolves reads as
// the general assistant (see resolveExpertSelection).
onExpertsChanged: (experts: AssistantExpert[]) => {
setAssistantExperts(experts);
if (
(selectedExpertId === "team" && experts.length < 2) ||
(selectedExpertId &&
selectedExpertId !== "team" &&
!experts.some((expert) => expert.id === selectedExpertId))
) {
setSelectedExpertId("");
}
},
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
onProjectsDeleted: removeProjectsFromUi,
+17
View File
@@ -180,6 +180,23 @@ describe('ChatTimeline', () => {
}
})
it('keeps the GoodBuddy name and shows the saved role as a caption below it', () => {
const props = { ...callbacks, artifactById: new Map(), conversationId: 'role', hiddenMessageCount: 0,
isUnusedConversation: false, locale: 'zh-CN' as const, messageStartIndex: 0, totalMessageCount: 3 }
const messages: Message[] = [
{ id: 'expert', role: 'assistant', content: 'a', createdAt: 1, state: 'complete',
expert: { kind: 'expert', id: '00000000-0000-4000-8000-000000000901', name: '代码审查专家' } },
{ id: 'team', role: 'assistant', content: 'b', createdAt: 2, state: 'complete', expert: { kind: 'team' } },
{ id: 'plain', role: 'assistant', content: 'c', createdAt: 3, state: 'complete' }
]
const view = render(<ChatTimeline {...props} messages={messages} />)
const articles = view.container.querySelectorAll('article.message')
expect([...articles].map(article => article.querySelector('.message__meta strong')?.textContent))
.toEqual(['GoodBuddy', 'GoodBuddy', 'GoodBuddy'])
expect([...articles].map(article => article.querySelector('.message__role')?.textContent ?? null))
.toEqual(['代码审查专家', '专家团队(并行)', null])
})
it('captures only nonempty settled assistant replies, including incomplete replies', () => {
const onAddToNote = vi.fn()
const messages: Message[] = [
+15 -1
View File
@@ -73,6 +73,7 @@ export type Message = {
contextCompressions?: ConversationMessage['contextCompressions']
tools?: ToolActivity[]
subagents?: SubagentActivity[]
expert?: ConversationMessage['expert']
approval?: {
id: string
taskId?: string
@@ -754,6 +755,14 @@ function ChatMessageRowView({
: t('chat.status.waitingForProgress'))
: message.status
const roleCaption = message.role !== 'assistant'
? undefined
: message.expert?.kind === 'expert'
? message.expert.name
: message.expert?.kind === 'team'
? t('composer.experts.team')
: undefined
return (
<>
<article
@@ -768,7 +777,7 @@ function ChatMessageRowView({
<UserRound size={18} />
)}
</div>
<div className="message__header">
<div className={`message__header${roleCaption ? ' message__header--with-role' : ''}`}>
<div className="message__meta">
<strong>
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
@@ -786,6 +795,11 @@ function ChatMessageRowView({
)}
<span>{formatTime(message.createdAt, locale)}</span>
</div>
{roleCaption && (
<small className="message__role" title={t('composer.expertLabel')}>
{roleCaption}
</small>
)}
</div>
<div className="message__body">
{message.attachments && message.attachments.length > 0 && (
+16 -14
View File
@@ -653,6 +653,22 @@ export const Composer = memo(function Composer({
</div>
)}
<div className="composer__input">
<div className="composer__input-header">
<ComposerMenuSelect
ariaLabel={t("composer.expertLabel")}
className="composer-picker--expert composer-picker--chip"
disabled={
isRunning ||
runtime?.capability === "image-generation"
}
icon={<Bot aria-hidden="true" size={13} />}
menuOpen={composerMenuOpen === "expert"}
onChange={actions.selectExpert}
onOpenChange={setExpertMenuOpen}
options={assistantExpertOptions}
value={selectedExpertId}
/>
</div>
<ComposerDraftText
conversationId={conversationId}
store={composerDrafts}
@@ -1024,20 +1040,6 @@ export const Composer = memo(function Composer({
</div>
</>
)}
<ComposerMenuSelect
ariaLabel={t("composer.expertLabel")}
className="composer-picker--expert"
disabled={
isRunning ||
runtime?.capability === "image-generation"
}
icon={<Bot aria-hidden="true" size={15} />}
menuOpen={composerMenuOpen === "expert"}
onChange={actions.selectExpert}
onOpenChange={setExpertMenuOpen}
options={assistantExpertOptions}
value={selectedExpertId}
/>
<div className="runtime-picker">
<button
aria-expanded={runtimeMenuOpen}
@@ -41,6 +41,7 @@ export function toConversationMessage(message: Message): ConversationMessage {
contextCompressions: message.contextCompressions,
tools: message.tools,
subagents: message.subagents,
expert: message.expert,
sources: message.sources,
sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval,
@@ -64,6 +65,7 @@ export function toLocalConversationHeader(
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled,
selectedExpertId: conversation.selectedExpertId,
contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState,
...(conversation.branch ? { branch: conversation.branch } : {}),
+3 -1
View File
@@ -151,7 +151,7 @@ export function useConversationActivitySummary(
export type ActiveConversationView = Pick<
Conversation,
| "id" | "title" | "projectId" | "runtimeSelection" | "knowledgeLibraryIds"
| "knowledgeRetrievalMode" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
| "knowledgeRetrievalMode" | "selectedExpertId" | "remote" | "branch" | "contextMetrics" | "contextCompressionState"
> & {
/** Untouched greeting-only conversation: shown with the default title. */
unused: boolean;
@@ -188,6 +188,7 @@ export function selectActiveConversationView(
runtimeSelection: conversation.runtimeSelection,
knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
selectedExpertId: conversation.selectedExpertId,
remote: conversation.remote,
branch: conversation.branch,
contextMetrics: conversation.contextMetrics,
@@ -214,6 +215,7 @@ export function sameActiveConversationView(
return left.id === right.id && left.title === right.title &&
left.projectId === right.projectId &&
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
left.selectedExpertId === right.selectedExpertId &&
left.unused === right.unused && left.historyLoaded === right.historyLoaded &&
left.running === right.running && left.messageCount === right.messageCount &&
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
+54 -1
View File
@@ -6695,6 +6695,23 @@ button > svg {
gap: 12px;
}
.message__header--with-role {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 0;
}
.message__role {
max-width: 100%;
overflow: hidden;
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.3;
text-overflow: ellipsis;
white-space: nowrap;
}
.message--user .message__header {
width: min(78%, 720px);
align-self: flex-end;
@@ -8401,7 +8418,7 @@ button > svg {
.composer__input textarea {
display: block;
width: 100%;
min-height: 72px;
min-height: 56px;
max-height: 220px;
padding: 0;
border: 0;
@@ -8697,6 +8714,42 @@ button > svg {
width: 148px;
}
/* Role chip in the top-left corner of the input area. */
.composer__input-header {
display: flex;
min-width: 0;
align-items: center;
margin: calc(var(--space-2) * -1) 0 var(--space-2) calc(var(--space-2) * -1);
}
.composer-picker--chip > .model-button {
width: auto;
max-width: 220px;
height: 24px;
padding: 0 var(--space-2);
border-color: transparent;
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text-secondary);
font-size: var(--font-caption);
font-weight: 500;
}
.composer-picker--chip > .model-button[aria-expanded="true"] {
border-color: var(--accent-selected);
background: var(--accent-subtle);
color: var(--accent);
}
.composer-picker--chip > .model-button:disabled {
border-color: transparent;
background: transparent;
}
.composer-picker--chip .model-button__label {
flex: 0 1 auto;
}
.composer-picker--runtime > .model-button,
.composer-picker--runtime-action > .model-button {
+22
View File
@@ -519,6 +519,21 @@ export type ConversationAnsweredQuestion = z.infer<
typeof conversationAnsweredQuestionSchema
>
export const conversationMessageExpertSchema = z.union([
z
.object({
kind: z.literal('expert'),
id: assistantIdSchema,
name: z.string().trim().min(1).max(80)
})
.strict(),
z.object({ kind: z.literal('team') }).strict()
])
export type ConversationMessageExpert = z.infer<
typeof conversationMessageExpertSchema
>
export const conversationMessageSchema = z
.object({
id: assistantIdSchema,
@@ -541,6 +556,11 @@ export const conversationMessageSchema = z
.optional(),
tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).optional(),
/**
* Snapshot of the role selected when the reply was requested, so later
* renames or deletions do not rewrite history.
*/
expert: conversationMessageExpertSchema.optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z
.array(
@@ -720,6 +740,8 @@ export const conversationSnapshotSchema = z
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
storyGraphEnabled: z.boolean().optional(),
/** Role picked in the composer for this conversation; absent means the general assistant. */
selectedExpertId: z.union([assistantIdSchema, z.literal('team')]).optional(),
contextMetrics: conversationContextMetricsSchema.optional(),
contextCompressionState:
conversationContextCompressionStateSchema.optional(),