fix assistant reasoning mount lifecycle
This commit is contained in:
1 parent
7a8b37c906
commit
f61e07ae58
5 files changed
+185
-34
No files matched your search
@@ -155,11 +155,12 @@ another message starts. A compact dot matrix distinguishes an initial thinking p
|
||||
settles into waiting, success, or error states in the minimized activity bar. The state always keeps its text label,
|
||||
and the animation is disabled when the operating system requests reduced motion.
|
||||
|
||||
A compact context control beside the model selector gives a quick glance at cumulative conversation tokens and
|
||||
cost. Its popover keeps the latest complete context reading while a new response starts and lists every request
|
||||
A circular context ring beside the model selector gives a quick glance at cumulative conversation usage without
|
||||
expanding the composer toolbar. Its accessible label includes cumulative tokens and cost when available, while
|
||||
its popover keeps the latest complete context reading as a new response starts and lists every request
|
||||
turn with its model, provider, input, output, cached-input and reasoning tokens, context use, duration, speed,
|
||||
agent steps, routing details, and cost when reported. Before usage is available, the control remains a compact
|
||||
circle instead of showing a redundant missing-data label. Closing and reopening the popover reads persisted
|
||||
ring instead of showing a redundant missing-data label. Closing and reopening the popover reads persisted
|
||||
conversation metadata instead of resetting the display to zero.
|
||||
|
||||
Each assistant response places a small provider logo in the same stable action toolbar as copy, export,
|
||||
|
||||
@@ -1153,14 +1153,13 @@
|
||||
|
||||
.composerContext {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
max-width: 14rem;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.25rem 0.45rem;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
border-radius: 50%;
|
||||
color: var(--mantine-color-text);
|
||||
background: var(--mantine-color-body);
|
||||
}
|
||||
@@ -1169,13 +1168,6 @@
|
||||
background: var(--mantine-color-default-hover);
|
||||
}
|
||||
|
||||
.composerContext[data-compact] {
|
||||
width: 1.75rem;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.composerContext:focus-visible {
|
||||
outline: 2px solid var(--mantine-primary-color-filled);
|
||||
outline-offset: 2px;
|
||||
|
||||
@@ -133,6 +133,7 @@ import type { AssistantConversationTurnUsage } from "./assistant-message-metadat
|
||||
import type { AssistantPendingAction } from "./assistant-pending-action";
|
||||
import { AssistantQuestionPortalProvider } from "./assistant-question-portal";
|
||||
import type { AssistantReasoningMode, AssistantRuntimeModelOption } from "./assistant-preferences";
|
||||
import { useAssistantReasoningState } from "./assistant-reasoning-state";
|
||||
import { getNearestTriggerScrollTop } from "./assistant-trigger-scroll";
|
||||
import { getToolResultPresentation } from "./assistant-tool-result";
|
||||
import { getSafeAssistantHttpUrl } from "./assistant-url";
|
||||
@@ -961,20 +962,9 @@ const ChainOfThoughtLayout = ({ children }: { children?: ReactNode }) => (
|
||||
const AssistantChainOfThought = () => {
|
||||
const t = useScopedI18n("common.assistant");
|
||||
const contentId = useId();
|
||||
const aui = useAui();
|
||||
const chainStatus = useAuiState((state) => state.chainOfThought.status);
|
||||
const collapsed = useAuiState((state) => state.chainOfThought.collapsed);
|
||||
const running = chainStatus.type === "running";
|
||||
const { collapsed: preferredCollapsed, setCollapsed: setPreferredCollapsed } = useContext(ReasoningVisibilityContext);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
aui.chainOfThought().setCollapsed(preferredCollapsed);
|
||||
}, [aui, preferredCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chainStatus.type !== "requires-action") return;
|
||||
aui.chainOfThought().setCollapsed(false);
|
||||
}, [aui, chainStatus.type]);
|
||||
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
|
||||
const running = chainStatus.type === "running";
|
||||
|
||||
return (
|
||||
<ChainOfThoughtPrimitive.Root className={classes.reasoning} data-opened={!collapsed || undefined}>
|
||||
@@ -1568,9 +1558,9 @@ const ConversationContext = () => {
|
||||
className={classes.composerContext}
|
||||
type="button"
|
||||
aria-label={quickLabel ? `${t("usage.contextWindow")}: ${quickLabel}` : t("usage.contextWindow")}
|
||||
title={quickLabel || t("usage.contextWindow")}
|
||||
aria-expanded={opened}
|
||||
aria-haspopup="dialog"
|
||||
data-compact={!quickLabel || undefined}
|
||||
onClick={() => setOpened((value) => !value)}
|
||||
>
|
||||
<RingProgress
|
||||
@@ -1579,11 +1569,6 @@ const ConversationContext = () => {
|
||||
roundCaps
|
||||
sections={hasContext ? [{ value: contextPercentage, color: getContextColor(contextPercentage) }] : []}
|
||||
/>
|
||||
{quickLabel && (
|
||||
<Text size="xs" fw={650} truncate>
|
||||
{quickLabel}
|
||||
</Text>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown className={classes.conversationContextPopover}>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, createElement, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import type { AssistantClient, ChatModelAdapter, ThreadMessageLike } from "@assistant-ui/react";
|
||||
import {
|
||||
AssistantRuntimeProvider,
|
||||
ChainOfThoughtPrimitive,
|
||||
MessagePrimitive,
|
||||
ThreadPrimitive,
|
||||
useAui,
|
||||
useLocalRuntime,
|
||||
} from "@assistant-ui/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { useAssistantReasoningState } from "./assistant-reasoning-state";
|
||||
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const flushEffects = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const containers: HTMLDivElement[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const container of containers.splice(0)) container.remove();
|
||||
});
|
||||
|
||||
const ReasoningPart = ({ text }: { text: string }) => <span data-testid="reasoning-part">{text}</span>;
|
||||
const ToolPart = ({ toolName }: { toolName: string }) => <span data-testid="tool-part">{toolName}</span>;
|
||||
|
||||
const TestChainOfThought = ({ preferredCollapsed = true }: { preferredCollapsed?: boolean }) => {
|
||||
const { chainStatus, collapsed } = useAssistantReasoningState(preferredCollapsed);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtPrimitive.Root
|
||||
data-testid="chain-of-thought"
|
||||
data-status={chainStatus.type}
|
||||
data-collapsed={String(collapsed)}
|
||||
>
|
||||
<ChainOfThoughtPrimitive.Parts components={{ Reasoning: ReasoningPart, tools: { Fallback: ToolPart } }} />
|
||||
</ChainOfThoughtPrimitive.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const TestMessage = () => (
|
||||
<MessagePrimitive.Root>
|
||||
<MessagePrimitive.Parts components={{ ChainOfThought: TestChainOfThought }} />
|
||||
</MessagePrimitive.Root>
|
||||
);
|
||||
|
||||
const RuntimeProbe = ({ onReady }: { onReady: (aui: AssistantClient) => void }) => {
|
||||
const aui = useAui();
|
||||
|
||||
useEffect(() => {
|
||||
onReady(aui);
|
||||
}, [aui, onReady]);
|
||||
return null;
|
||||
};
|
||||
|
||||
const renderAssistant = async (adapter: ChatModelAdapter, initialMessages: ThreadMessageLike[] = []) => {
|
||||
const container = document.createElement("div");
|
||||
containers.push(container);
|
||||
document.body.append(container);
|
||||
const root = createRoot(container);
|
||||
let client: AssistantClient | undefined;
|
||||
|
||||
const TestAssistant = () => {
|
||||
const runtime = useLocalRuntime(adapter, { initialMessages });
|
||||
return (
|
||||
<AssistantRuntimeProvider runtime={runtime}>
|
||||
<RuntimeProbe
|
||||
onReady={(aui) => {
|
||||
client = aui;
|
||||
}}
|
||||
/>
|
||||
<ThreadPrimitive.Messages components={{ Message: TestMessage }} />
|
||||
</AssistantRuntimeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(createElement(TestAssistant));
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
if (!client) throw new Error("The assistant runtime was not registered");
|
||||
return { client, container, root };
|
||||
};
|
||||
|
||||
describe("useAssistantReasoningState", () => {
|
||||
test("mounts and renders streamed reasoning without updating the chain resource before mount", async () => {
|
||||
const adapter: ChatModelAdapter = {
|
||||
async *run() {
|
||||
yield { content: [{ type: "reasoning", text: "Inspecting the dashboard" }] };
|
||||
await flushEffects();
|
||||
yield {
|
||||
content: [
|
||||
{ type: "reasoning", text: "Inspecting the dashboard" },
|
||||
{ type: "text", text: "Done" },
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const { client, container, root } = await renderAssistant(adapter);
|
||||
|
||||
await act(async () => {
|
||||
client.composer().setText("Inspect my dashboard");
|
||||
client.composer().send();
|
||||
await flushEffects();
|
||||
await flushEffects();
|
||||
});
|
||||
|
||||
expect(container.querySelector('[data-testid="reasoning-part"]')?.textContent).toBe("Inspecting the dashboard");
|
||||
expect(container.querySelector('[data-testid="chain-of-thought"]')?.getAttribute("data-status")).toBe("complete");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
|
||||
test("opens a pending tool call even when completed reasoning is normally collapsed", async () => {
|
||||
const pendingToolMessage: ThreadMessageLike = {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "I need approval" },
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "approval-1",
|
||||
toolName: "app_create",
|
||||
args: { name: "Wikipedia" },
|
||||
argsText: JSON.stringify({ name: "Wikipedia" }),
|
||||
},
|
||||
],
|
||||
status: { type: "requires-action", reason: "tool-calls" },
|
||||
};
|
||||
const { container, root } = await renderAssistant({ async *run() {} }, [pendingToolMessage]);
|
||||
await act(async () => flushEffects());
|
||||
|
||||
expect(container.querySelector('[data-testid="tool-part"]')?.textContent).toBe("app_create");
|
||||
expect(container.querySelector('[data-testid="chain-of-thought"]')?.getAttribute("data-status")).toBe(
|
||||
"requires-action",
|
||||
);
|
||||
expect(container.querySelector('[data-testid="chain-of-thought"]')?.getAttribute("data-collapsed")).toBe("false");
|
||||
|
||||
await act(async () => root.unmount());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAui, useAuiState } from "@assistant-ui/react";
|
||||
|
||||
/**
|
||||
* Keeps the per-message chain of thought aligned with the conversation preference.
|
||||
*
|
||||
* assistant-ui commits its chain resource in a passive effect. Updating it from a
|
||||
* layout effect races that commit and throws `Resource updated before mount` when
|
||||
* reasoning or tool parts first appear. Keep all programmatic chain updates in
|
||||
* passive effects so the resource is mounted before it receives an update.
|
||||
*/
|
||||
export const useAssistantReasoningState = (preferredCollapsed: boolean) => {
|
||||
const aui = useAui();
|
||||
const chainStatus = useAuiState((state) => state.chainOfThought.status);
|
||||
const collapsed = useAuiState((state) => state.chainOfThought.collapsed);
|
||||
|
||||
useEffect(() => {
|
||||
aui.chainOfThought().setCollapsed(preferredCollapsed);
|
||||
}, [aui, preferredCollapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chainStatus.type !== "requires-action") return;
|
||||
aui.chainOfThought().setCollapsed(false);
|
||||
}, [aui, chainStatus.type]);
|
||||
|
||||
return { chainStatus, collapsed };
|
||||
};
|
||||
Reference in new issue
Block a user