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() } } 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 () => { it('persists conversation pins without changing timestamps or allowing autosaves to overwrite them', async () => {
const database = await createDatabase() const database = await createDatabase()
const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 } const header = { id: randomUUID(), title: 'Pinned conversation', updatedAt: 1000 }
+10 -2
View File
@@ -364,6 +364,7 @@ type MessageMetadata = {
contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions'] contextCompressions?: ConversationSnapshot['messages'][number]['contextCompressions']
tools?: ConversationSnapshot['messages'][number]['tools'] tools?: ConversationSnapshot['messages'][number]['tools']
subagents?: ConversationSnapshot['messages'][number]['subagents'] subagents?: ConversationSnapshot['messages'][number]['subagents']
expert?: ConversationMessage['expert']
sources?: string[] sources?: string[]
sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences'] sourceReferences?: ConversationSnapshot['messages'][number]['sourceReferences']
knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval'] knowledgeRetrieval?: ConversationSnapshot['messages'][number]['knowledgeRetrieval']
@@ -1238,6 +1239,7 @@ const conversationContextStateSchema = conversationSnapshotSchema.pick({
knowledgeLibraryIds: true, knowledgeLibraryIds: true,
knowledgeRetrievalMode: true, knowledgeRetrievalMode: true,
storyGraphEnabled: true, storyGraphEnabled: true,
selectedExpertId: true,
contextMetrics: true, contextMetrics: true,
contextCompressionState: true contextCompressionState: true
}) })
@@ -1246,7 +1248,7 @@ function parseConversationContextState(
value: string | null value: string | null
): Pick< ): Pick<
ConversationSnapshot, ConversationSnapshot,
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState' 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
> { > {
if (!value) { if (!value) {
return {} return {}
@@ -1264,18 +1266,20 @@ function parseConversationContextState(
function serializeConversationContextState( function serializeConversationContextState(
conversation: Pick< conversation: Pick<
ConversationSnapshot, ConversationSnapshot,
'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'contextMetrics' | 'contextCompressionState' 'knowledgeLibraryIds' | 'knowledgeRetrievalMode' | 'storyGraphEnabled' | 'selectedExpertId' | 'contextMetrics' | 'contextCompressionState'
> >
): string | null { ): string | null {
return conversation.knowledgeLibraryIds !== undefined || return conversation.knowledgeLibraryIds !== undefined ||
conversation.knowledgeRetrievalMode !== undefined || conversation.knowledgeRetrievalMode !== undefined ||
conversation.storyGraphEnabled !== undefined || conversation.storyGraphEnabled !== undefined ||
conversation.selectedExpertId !== undefined ||
conversation.contextMetrics || conversation.contextMetrics ||
conversation.contextCompressionState conversation.contextCompressionState
? JSON.stringify({ ? JSON.stringify({
knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled, storyGraphEnabled: conversation.storyGraphEnabled,
selectedExpertId: conversation.selectedExpertId,
contextMetrics: conversation.contextMetrics, contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState contextCompressionState: conversation.contextCompressionState
}) })
@@ -1346,6 +1350,7 @@ function toConversationSnapshot(
contextCompressions: metadata.contextCompressions, contextCompressions: metadata.contextCompressions,
tools: metadata.tools, tools: metadata.tools,
subagents: metadata.subagents, subagents: metadata.subagents,
expert: metadata.expert,
sources: metadata.sources, sources: metadata.sources,
sourceReferences: metadata.sourceReferences, sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval, knowledgeRetrieval: metadata.knowledgeRetrieval,
@@ -1377,6 +1382,7 @@ function serializeConversationMessageMetadata(
contextCompressions: message.contextCompressions, contextCompressions: message.contextCompressions,
tools: message.tools, tools: message.tools,
subagents: message.subagents, subagents: message.subagents,
expert: message.expert,
sources: message.sources, sources: message.sources,
sourceReferences: message.sourceReferences, sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval, knowledgeRetrieval: message.knowledgeRetrieval,
@@ -1784,6 +1790,7 @@ function storedRemoteAssistantMessage(
contextCompressions: metadata.contextCompressions, contextCompressions: metadata.contextCompressions,
tools: metadata.tools, tools: metadata.tools,
subagents: metadata.subagents, subagents: metadata.subagents,
expert: metadata.expert,
sources: metadata.sources, sources: metadata.sources,
sourceReferences: metadata.sourceReferences, sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval, knowledgeRetrieval: metadata.knowledgeRetrieval,
@@ -5638,6 +5645,7 @@ export class AssistantDatabase {
contextCompressions: metadata.contextCompressions, contextCompressions: metadata.contextCompressions,
tools: metadata.tools, tools: metadata.tools,
subagents: metadata.subagents, subagents: metadata.subagents,
expert: metadata.expert,
sources: metadata.sources, sources: metadata.sources,
sourceReferences: metadata.sourceReferences, sourceReferences: metadata.sourceReferences,
knowledgeRetrieval: metadata.knowledgeRetrieval, knowledgeRetrieval: metadata.knowledgeRetrieval,
+46 -5
View File
@@ -8751,13 +8751,16 @@ describe("App", () => {
const conversationSettings = within(composer!).getByRole("group", { const conversationSettings = within(composer!).getByRole("group", {
name: "对话设置", 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 contextTrigger = within(conversationSettings).getByRole("button", { name: "故事图谱与知识库" });
const expertTrigger = within(conversationSettings).getByRole("button", { name: "专家角色:通用助手" });
const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u }); const runtimeTrigger = within(conversationSettings).getByRole("button", { name: /默认模型/u });
expect(contextTrigger).toHaveAttribute("aria-expanded", "false"); expect(contextTrigger).toHaveAttribute("aria-expanded", "false");
expect(contextTrigger.compareDocumentPosition(expertTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(contextTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(expertTrigger.compareDocumentPosition(runtimeTrigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect( expect(
within(conversationSettings).queryByRole("button", { within(conversationSettings).queryByRole("button", {
name: /工作模式/u, name: /工作模式/u,
@@ -12583,7 +12586,8 @@ describe("App", () => {
it("can dispatch a request to the parallel expert team", async () => { it("can dispatch a request to the parallel expert team", async () => {
render(<App />); render(<App />);
// The role belongs to the loaded conversation.
await screen.findByLabelText("发送");
selectComposerOption("专家角色", "专家团队(并行)"); selectComposerOption("专家角色", "专家团队(并行)");
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), {
target: { value: "制定发布计划" }, 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 () => { it("shows Subagent states and records child expert activity", async () => {
render(<App />); render(<App />);
fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), { fireEvent.change(await screen.findByLabelText("向 GoodBuddy 提问"), {
+41 -10
View File
@@ -842,6 +842,7 @@ function toConversationSnapshots(
knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled, storyGraphEnabled: conversation.storyGraphEnabled,
selectedExpertId: conversation.selectedExpertId,
contextMetrics: conversation.contextMetrics, contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState, contextCompressionState: conversation.contextCompressionState,
...(conversation.branch ? { branch: conversation.branch } : {}), ...(conversation.branch ? { branch: conversation.branch } : {}),
@@ -1153,7 +1154,6 @@ function App(): React.JSX.Element {
const [assistantExperts, setAssistantExperts] = useState<AssistantExpert[]>( const [assistantExperts, setAssistantExperts] = useState<AssistantExpert[]>(
[], [],
); );
const [selectedExpertId, setSelectedExpertId] = useState("");
const [activeProjectId, setActiveProjectId] = useState(""); const [activeProjectId, setActiveProjectId] = useState("");
const activeProjectIdRef = useRef(activeProjectId); const activeProjectIdRef = useRef(activeProjectId);
const workspaceChangesRequestRef = useRef(0); const workspaceChangesRequestRef = useRef(0);
@@ -2280,6 +2280,34 @@ function App(): React.JSX.Element {
// Header fields of the active conversation, value-compared: message updates // Header fields of the active conversation, value-compared: message updates
// (streaming flushes, tool events) do not re-render App. // (streaming flushes, tool events) do not re-render App.
const activeConversation = useActiveConversationView(conversationStore, activeId); 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 = const enabledKnowledgeLibraryIds =
activeConversation?.knowledgeLibraryIds ?? []; activeConversation?.knowledgeLibraryIds ?? [];
const setEnabledKnowledgeLibraryIds = useCallback( const setEnabledKnowledgeLibraryIds = useCallback(
@@ -5113,7 +5141,7 @@ function App(): React.JSX.Element {
: (queuedInput.expertId ?? "") : (queuedInput.expertId ?? "")
: runtime?.capability === "image-generation" : runtime?.capability === "image-generation"
? "" ? ""
: selectedExpertId; : resolveExpertSelection(conversationSnapshot.selectedExpertId);
const knowledgeLibraryIdsSnapshot = const knowledgeLibraryIdsSnapshot =
queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds; queuedInput?.knowledgeLibraryIds ?? enabledKnowledgeLibraryIds;
const smartRoutingSnapshot = const smartRoutingSnapshot =
@@ -5210,6 +5238,14 @@ function App(): React.JSX.Element {
const executionPrompt = memoryContext const executionPrompt = memoryContext
? `${prompt}\n\n${memoryContext}` ? `${prompt}\n\n${memoryContext}`
: prompt; : 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 = { const assistantMessage: Message = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
role: "assistant", role: "assistant",
@@ -5218,6 +5254,7 @@ function App(): React.JSX.Element {
createdAt: Date.now(), createdAt: Date.now(),
state: "streaming", state: "streaming",
status: t("chat.status.preparingRequest"), status: t("chat.status.preparingRequest"),
...(expertSnapshot ? { expert: expertSnapshot } : {}),
}; };
activeRuns.current.set(requestId, { activeRuns.current.set(requestId, {
@@ -6512,16 +6549,10 @@ function App(): React.JSX.Element {
onClose: () => { onClose: () => {
commitView(viewRef.current); commitView(viewRef.current);
}, },
// Each conversation keeps its role; one that no longer resolves reads as
// the general assistant (see resolveExpertSelection).
onExpertsChanged: (experts: AssistantExpert[]) => { onExpertsChanged: (experts: AssistantExpert[]) => {
setAssistantExperts(experts); setAssistantExperts(experts);
if (
(selectedExpertId === "team" && experts.length < 2) ||
(selectedExpertId &&
selectedExpertId !== "team" &&
!experts.some((expert) => expert.id === selectedExpertId))
) {
setSelectedExpertId("");
}
}, },
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange, onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
onProjectsDeleted: removeProjectsFromUi, 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', () => { it('captures only nonempty settled assistant replies, including incomplete replies', () => {
const onAddToNote = vi.fn() const onAddToNote = vi.fn()
const messages: Message[] = [ const messages: Message[] = [
+15 -1
View File
@@ -73,6 +73,7 @@ export type Message = {
contextCompressions?: ConversationMessage['contextCompressions'] contextCompressions?: ConversationMessage['contextCompressions']
tools?: ToolActivity[] tools?: ToolActivity[]
subagents?: SubagentActivity[] subagents?: SubagentActivity[]
expert?: ConversationMessage['expert']
approval?: { approval?: {
id: string id: string
taskId?: string taskId?: string
@@ -754,6 +755,14 @@ function ChatMessageRowView({
: t('chat.status.waitingForProgress')) : t('chat.status.waitingForProgress'))
: message.status : 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 ( return (
<> <>
<article <article
@@ -768,7 +777,7 @@ function ChatMessageRowView({
<UserRound size={18} /> <UserRound size={18} />
)} )}
</div> </div>
<div className="message__header"> <div className={`message__header${roleCaption ? ' message__header--with-role' : ''}`}>
<div className="message__meta"> <div className="message__meta">
<strong> <strong>
{message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')} {message.role === 'assistant' ? 'GoodBuddy' : t('chat.user')}
@@ -786,6 +795,11 @@ function ChatMessageRowView({
)} )}
<span>{formatTime(message.createdAt, locale)}</span> <span>{formatTime(message.createdAt, locale)}</span>
</div> </div>
{roleCaption && (
<small className="message__role" title={t('composer.expertLabel')}>
{roleCaption}
</small>
)}
</div> </div>
<div className="message__body"> <div className="message__body">
{message.attachments && message.attachments.length > 0 && ( {message.attachments && message.attachments.length > 0 && (
+16 -14
View File
@@ -653,6 +653,22 @@ export const Composer = memo(function Composer({
</div> </div>
)} )}
<div className="composer__input"> <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 <ComposerDraftText
conversationId={conversationId} conversationId={conversationId}
store={composerDrafts} store={composerDrafts}
@@ -1024,20 +1040,6 @@ export const Composer = memo(function Composer({
</div> </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"> <div className="runtime-picker">
<button <button
aria-expanded={runtimeMenuOpen} aria-expanded={runtimeMenuOpen}
@@ -41,6 +41,7 @@ export function toConversationMessage(message: Message): ConversationMessage {
contextCompressions: message.contextCompressions, contextCompressions: message.contextCompressions,
tools: message.tools, tools: message.tools,
subagents: message.subagents, subagents: message.subagents,
expert: message.expert,
sources: message.sources, sources: message.sources,
sourceReferences: message.sourceReferences, sourceReferences: message.sourceReferences,
knowledgeRetrieval: message.knowledgeRetrieval, knowledgeRetrieval: message.knowledgeRetrieval,
@@ -64,6 +65,7 @@ export function toLocalConversationHeader(
knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
storyGraphEnabled: conversation.storyGraphEnabled, storyGraphEnabled: conversation.storyGraphEnabled,
selectedExpertId: conversation.selectedExpertId,
contextMetrics: conversation.contextMetrics, contextMetrics: conversation.contextMetrics,
contextCompressionState: conversation.contextCompressionState, contextCompressionState: conversation.contextCompressionState,
...(conversation.branch ? { branch: conversation.branch } : {}), ...(conversation.branch ? { branch: conversation.branch } : {}),
+3 -1
View File
@@ -151,7 +151,7 @@ export function useConversationActivitySummary(
export type ActiveConversationView = Pick< export type ActiveConversationView = Pick<
Conversation, Conversation,
| "id" | "title" | "projectId" | "runtimeSelection" | "knowledgeLibraryIds" | "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. */ /** Untouched greeting-only conversation: shown with the default title. */
unused: boolean; unused: boolean;
@@ -188,6 +188,7 @@ export function selectActiveConversationView(
runtimeSelection: conversation.runtimeSelection, runtimeSelection: conversation.runtimeSelection,
knowledgeLibraryIds: conversation.knowledgeLibraryIds, knowledgeLibraryIds: conversation.knowledgeLibraryIds,
knowledgeRetrievalMode: conversation.knowledgeRetrievalMode, knowledgeRetrievalMode: conversation.knowledgeRetrievalMode,
selectedExpertId: conversation.selectedExpertId,
remote: conversation.remote, remote: conversation.remote,
branch: conversation.branch, branch: conversation.branch,
contextMetrics: conversation.contextMetrics, contextMetrics: conversation.contextMetrics,
@@ -214,6 +215,7 @@ export function sameActiveConversationView(
return left.id === right.id && left.title === right.title && return left.id === right.id && left.title === right.title &&
left.projectId === right.projectId && left.projectId === right.projectId &&
left.knowledgeRetrievalMode === right.knowledgeRetrievalMode && left.knowledgeRetrievalMode === right.knowledgeRetrievalMode &&
left.selectedExpertId === right.selectedExpertId &&
left.unused === right.unused && left.historyLoaded === right.historyLoaded && left.unused === right.unused && left.historyLoaded === right.historyLoaded &&
left.running === right.running && left.messageCount === right.messageCount && left.running === right.running && left.messageCount === right.messageCount &&
sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) && sameArrayItems(left.knowledgeLibraryIds ?? [], right.knowledgeLibraryIds ?? []) &&
+54 -1
View File
@@ -6695,6 +6695,23 @@ button > svg {
gap: 12px; 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 { .message--user .message__header {
width: min(78%, 720px); width: min(78%, 720px);
align-self: flex-end; align-self: flex-end;
@@ -8401,7 +8418,7 @@ button > svg {
.composer__input textarea { .composer__input textarea {
display: block; display: block;
width: 100%; width: 100%;
min-height: 72px; min-height: 56px;
max-height: 220px; max-height: 220px;
padding: 0; padding: 0;
border: 0; border: 0;
@@ -8697,6 +8714,42 @@ button > svg {
width: 148px; 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 > .model-button,
.composer-picker--runtime-action > .model-button { .composer-picker--runtime-action > .model-button {
+22
View File
@@ -519,6 +519,21 @@ export type ConversationAnsweredQuestion = z.infer<
typeof conversationAnsweredQuestionSchema 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 export const conversationMessageSchema = z
.object({ .object({
id: assistantIdSchema, id: assistantIdSchema,
@@ -541,6 +556,11 @@ export const conversationMessageSchema = z
.optional(), .optional(),
tools: z.array(conversationToolActivitySchema).optional(), tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).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(), sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z sourceReferences: z
.array( .array(
@@ -720,6 +740,8 @@ export const conversationSnapshotSchema = z
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(), knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(), knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
storyGraphEnabled: z.boolean().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(), contextMetrics: conversationContextMetricsSchema.optional(),
contextCompressionState: contextCompressionState:
conversationContextCompressionStateSchema.optional(), conversationContextCompressionStateSchema.optional(),