feat: improve assistant thinking states

This commit is contained in:
ajnart committed 2026-08-14 11:32:11 +02:00
1 parent ebd63bea74
commit 59cb3a4d99
9 files changed
+542 -23

No files matched your search

+3 -1
View File
@@ -151,7 +151,9 @@ from minimal through high; explicit levels are sent using the AI SDK's provider-
still depend on support from the chosen model and endpoint. Reasoning text is shown live when the provider
streams it through assistant-ui's compact chain-of-thought disclosure. The **Thinking** disclosure renders
Markdown, can be opened or closed without interrupting the response, and keeps the user's collapsed state when
another message starts.
another message starts. A compact dot matrix distinguishes an initial thinking pass from response streaming and
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
@@ -0,0 +1,46 @@
import { describe, expect, test } from "vitest";
import { getAssistantActivityState, getRunningAssistantPartType } from "./assistant-activity-state";
describe("getAssistantActivityState", () => {
test("does not treat output from the previous completed response as current streaming", () => {
expect(getRunningAssistantPartType("complete", "text")).toBeUndefined();
expect(getRunningAssistantPartType("running", "text")).toBe("text");
});
test("distinguishes thinking from a response that is already streaming", () => {
expect(
getAssistantActivityState({ isRunning: true, latestPartType: undefined, needsApproval: false, failed: false }),
).toBe("thinking");
expect(
getAssistantActivityState({ isRunning: true, latestPartType: "reasoning", needsApproval: false, failed: false }),
).toBe("thinking");
expect(
getAssistantActivityState({ isRunning: true, latestPartType: "text", needsApproval: false, failed: false }),
).toBe("streaming");
});
test("shows a waiting pattern while a tool is running", () => {
expect(
getAssistantActivityState({ isRunning: true, latestPartType: "tool-call", needsApproval: false, failed: false }),
).toBe("waiting");
});
test("maps settled states to waiting, failure, and success glyphs", () => {
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: true, failed: false }),
).toBe("waiting");
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: false, failed: true }),
).toBe("error");
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: false, failed: false }),
).toBe("success");
});
test("keeps a required action visible even if the last run also failed", () => {
expect(
getAssistantActivityState({ isRunning: false, latestPartType: "text", needsApproval: true, failed: true }),
).toBe("waiting");
});
});
@@ -0,0 +1,29 @@
import type { AssistantDotMatrixState } from "./assistant-dot-matrix";
interface AssistantActivityStateInput {
isRunning: boolean;
latestPartType: string | undefined;
needsApproval: boolean;
failed: boolean;
}
export const getRunningAssistantPartType = (
messageStatusType: string | undefined,
latestPartType: string | undefined,
) => (messageStatusType === "running" ? latestPartType : undefined);
export const getAssistantActivityState = ({
isRunning,
latestPartType,
needsApproval,
failed,
}: AssistantActivityStateInput): AssistantDotMatrixState => {
if (isRunning) {
if (latestPartType === "tool-call") return "waiting";
if (latestPartType === undefined || latestPartType === "reasoning") return "thinking";
return "streaming";
}
if (needsApproval) return "waiting";
if (failed) return "error";
return "success";
};
@@ -0,0 +1,87 @@
@property --assistant-dot-matrix-hi {
syntax: "<number>";
inherits: false;
initial-value: 1;
}
@property --assistant-dot-matrix-lo {
syntax: "<number>";
inherits: false;
initial-value: 0.15;
}
.root {
display: inline-block;
width: 1rem;
height: 1rem;
flex: 0 0 auto;
color: currentColor;
}
.root[data-state="idle"],
.root[data-state="paused"],
.root[data-state="stopped"],
.root[data-state="offline"] {
color: var(--mantine-color-dimmed);
}
.root[data-state="recording"],
.root[data-state="error"] {
color: var(--mantine-color-red-filled);
}
.root[data-state="success"] {
color: var(--mantine-color-green-filled);
}
.root[data-state="warning"] {
color: var(--mantine-color-yellow-filled);
}
.root[data-state="info"] {
color: var(--mantine-color-blue-filled);
}
.grid {
display: block;
width: 100%;
height: 100%;
}
.dot {
transition-duration: 300ms;
transition-property: --assistant-dot-matrix-hi, --assistant-dot-matrix-lo, opacity;
animation-name: assistant-dot-matrix-blink;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
padding: 0;
border: 0;
margin: -1px;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
@keyframes assistant-dot-matrix-blink {
0%,
100% {
opacity: var(--assistant-dot-matrix-hi, 1);
}
50% {
opacity: var(--assistant-dot-matrix-lo, 0.15);
}
}
@media (prefers-reduced-motion: reduce) {
.dot {
transition-duration: 0ms;
animation-name: none;
}
}
@@ -0,0 +1,83 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { AssistantDotMatrix, assistantDotMatrixStates } from "./assistant-dot-matrix";
describe("AssistantDotMatrix", () => {
let host: HTMLDivElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
});
afterEach(async () => {
await act(async () => root.unmount());
host.remove();
});
test("renders an accessible deterministic 5 by 5 loading matrix", async () => {
await act(async () => root.render(createElement(AssistantDotMatrix, { label: "Loading response" })));
const matrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
expect(matrix?.dataset.state).toBe("loading");
expect(matrix?.tagName).toBe("OUTPUT");
expect(matrix?.textContent).toBe("Loading response");
expect(matrix?.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");
expect(matrix?.querySelectorAll("[data-slot='assistant-dot-matrix-dot']")).toHaveLength(25);
});
test("switches from the thinking wave to the streaming wave without changing its structure", async () => {
await act(async () => root.render(createElement(AssistantDotMatrix, { state: "thinking", label: "Thinking" })));
const matrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
const firstDot = matrix?.querySelector<SVGCircleElement>("[data-slot='assistant-dot-matrix-dot']");
expect(matrix?.dataset.state).toBe("thinking");
expect(firstDot?.style.animationDuration).toBe("1.2s");
await act(async () =>
root.render(createElement(AssistantDotMatrix, { state: "streaming", label: "Writing response" })),
);
const updatedMatrix = host.querySelector<HTMLElement>("[data-slot='assistant-dot-matrix']");
const updatedFirstDot = updatedMatrix?.querySelector<SVGCircleElement>("[data-slot='assistant-dot-matrix-dot']");
expect(updatedMatrix).toBe(matrix);
expect(updatedMatrix?.dataset.state).toBe("streaming");
expect(updatedMatrix?.textContent).toBe("Writing response");
expect(updatedFirstDot?.style.animationDuration).toBe("0.9s");
expect(updatedMatrix?.querySelectorAll("[data-slot='assistant-dot-matrix-dot']")).toHaveLength(25);
});
test("supports every documented assistant-ui matrix state", async () => {
expect(assistantDotMatrixStates).toEqual([
"idle",
"loading",
"thinking",
"streaming",
"searching",
"syncing",
"connecting",
"waiting",
"uploading",
"downloading",
"listening",
"speaking",
"recording",
"success",
"error",
"warning",
"info",
"paused",
"stopped",
"offline",
]);
for (const state of assistantDotMatrixStates) {
await act(async () => root.render(createElement(AssistantDotMatrix, { state })));
expect(host.querySelector("[data-slot='assistant-dot-matrix']")?.getAttribute("data-state")).toBe(state);
}
});
});
@@ -0,0 +1,234 @@
import type { ComponentProps, CSSProperties } from "react";
import classes from "./assistant-dot-matrix.module.css";
const gridSize = 5;
const center = (gridSize - 1) / 2;
const dotIndexes = Array.from({ length: gridSize * gridSize }, (_, index) => index);
const hash = (value: number, salt: number, range: number) => {
let mixed = (Math.imul(value, 374761393) + Math.imul(salt, 668265263)) >>> 0;
mixed = Math.imul(mixed ^ (mixed >>> 13), 1274126177) >>> 0;
return ((mixed ^ (mixed >>> 16)) % range) / 1000;
};
const glyph = (dots: [number, number][]) => new Set(dots.map(([row, column]) => row * gridSize + column));
const check = glyph([
[1, 4],
[2, 3],
[3, 0],
[3, 2],
[4, 1],
]);
const cross = glyph([
[0, 0],
[0, 4],
[1, 1],
[1, 3],
[2, 2],
[3, 1],
[3, 3],
[4, 0],
[4, 4],
]);
const bang = glyph([
[0, 2],
[1, 2],
[2, 2],
[4, 2],
]);
const info = glyph([
[0, 2],
[2, 2],
[3, 2],
[4, 2],
]);
const pause = glyph([
[1, 1],
[2, 1],
[3, 1],
[1, 3],
[2, 3],
[3, 3],
]);
const stop = glyph([
[1, 1],
[1, 2],
[1, 3],
[2, 1],
[2, 2],
[2, 3],
[3, 1],
[3, 2],
[3, 3],
]);
const record = glyph([
[1, 2],
[2, 1],
[2, 2],
[2, 3],
[3, 2],
]);
const ellipsis = glyph([
[2, 0],
[2, 2],
[2, 4],
]);
interface Blink {
duration: number;
delay: number;
lo: number;
}
interface StateConfig {
glyph?: Set<number>;
base?: number;
dim?: number;
blink?: (index: number, row: number, column: number) => Blink;
}
const states = {
idle: { base: 0.3 },
loading: {
blink: (index) => ({
duration: 0.9 + hash(index, 2, 700),
delay: -hash(index, 1, 1200),
lo: 0.15,
}),
},
thinking: {
blink: (_index, row, column) => ({
duration: 1.2,
delay: -(row + column) * 0.09,
lo: 0.2,
}),
},
streaming: {
blink: (_index, row, column) => ({
duration: 0.9,
delay: -(row * 0.12 + hash(column, 3, 900)),
lo: 0.15,
}),
},
searching: {
blink: (_index, _row, column) => ({ duration: 1.1, delay: -column * 0.12, lo: 0.2 }),
},
syncing: {
blink: (_index, row, column) => {
const turn = (Math.atan2(row - center, column - center) + Math.PI) / (2 * Math.PI);
return { duration: 1.3, delay: -turn * 1.3, lo: 0.2 };
},
},
connecting: {
blink: (_index, row, column) => ({
duration: 1.4,
delay: -Math.max(Math.abs(row - center), Math.abs(column - center)) * 0.18,
lo: 0.15,
}),
},
waiting: {
glyph: ellipsis,
blink: (_index, _row, column) => ({
duration: 1.2,
delay: -column * 0.09,
lo: 0.2,
}),
},
uploading: {
blink: (_index, row) => ({
duration: 1,
delay: -(gridSize - 1 - row) * 0.12,
lo: 0.2,
}),
},
downloading: {
blink: (_index, row) => ({ duration: 1, delay: -row * 0.12, lo: 0.2 }),
},
listening: {
blink: (_index, _row, column) => ({
duration: 0.7 + hash(column, 4, 500),
delay: -hash(column, 5, 900),
lo: 0.25,
}),
},
speaking: {
blink: (_index, _row, column) => ({
duration: 0.4 + hash(column, 6, 350),
delay: -hash(column, 7, 700),
lo: 0.2,
}),
},
recording: {
glyph: record,
dim: 0.12,
blink: () => ({ duration: 1.4, delay: 0, lo: 0.3 }),
},
success: { glyph: check },
error: {
glyph: cross,
blink: () => ({ duration: 1.1, delay: 0, lo: 0.4 }),
},
warning: {
glyph: bang,
blink: () => ({ duration: 1.6, delay: 0, lo: 0.45 }),
},
info: { glyph: info },
paused: { glyph: pause },
stopped: { glyph: stop },
offline: { base: 0.15 },
} satisfies Record<string, StateConfig>;
export type AssistantDotMatrixState = keyof typeof states;
export const assistantDotMatrixStates = Object.keys(states) as readonly AssistantDotMatrixState[];
interface AssistantDotMatrixProps extends Omit<ComponentProps<"output">, "children"> {
state?: AssistantDotMatrixState;
label?: string;
}
export const AssistantDotMatrix = ({ className, state = "loading", label, ...props }: AssistantDotMatrixProps) => {
const config: StateConfig = states[state];
return (
<output
data-slot="assistant-dot-matrix"
data-state={state}
className={[classes.root, className].filter(Boolean).join(" ")}
{...props}
>
<span className={classes.visuallyHidden}>{label ?? state}</span>
<svg aria-hidden viewBox="0 0 20 20" fill="currentColor" className={classes.grid}>
{dotIndexes.map((index) => {
const row = Math.floor(index / gridSize);
const column = index % gridSize;
const on = !config.glyph || config.glyph.has(index);
const hi = on ? (config.base ?? 1) : (config.dim ?? 0.15);
const blink = on ? config.blink?.(index, row, column) : undefined;
return (
<circle
key={index}
data-slot="assistant-dot-matrix-dot"
cx={2 + column * 4}
cy={2 + row * 4}
r={1.3}
className={classes.dot}
style={
{
opacity: hi,
animationDuration: `${blink?.duration ?? 1}s`,
animationDelay: `${blink?.delay ?? 0}s`,
"--assistant-dot-matrix-hi": hi,
"--assistant-dot-matrix-lo": blink?.lo ?? hi,
} as CSSProperties
}
/>
);
})}
</svg>
</output>
);
};
@@ -352,6 +352,12 @@
max-width: 100%;
}
.messagePending {
width: max-content;
min-height: 1.75rem;
padding-inline: 0.1rem;
}
.messageText {
line-height: 1.6;
overflow-wrap: anywhere;
@@ -909,8 +915,10 @@
.reasoning {
margin-block: 0.45rem;
padding-inline-start: 0.6rem;
border-inline-start: 2px solid var(--mantine-color-default-border);
overflow: hidden;
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: color-mix(in srgb, var(--mantine-color-default) 72%, transparent);
}
.reasoningToggle {
@@ -918,11 +926,14 @@
width: 100%;
align-items: center;
justify-content: space-between;
padding-block: 0.25rem;
border-radius: var(--mantine-radius-sm);
padding: 0.4rem 0.55rem;
cursor: pointer;
}
.reasoningToggle:hover {
background: var(--mantine-color-default-hover);
}
.reasoningToggle:focus-visible {
outline: 2px solid var(--mantine-primary-color-filled);
outline-offset: 2px;
@@ -937,7 +948,8 @@
}
.reasoningContent {
padding: 0.3rem 0 0.2rem;
padding: 0.45rem 0.55rem 0.55rem;
border-top: 1px solid var(--mantine-color-default-border);
}
.reasoningText {
@@ -14,6 +14,7 @@ import {
useState,
} from "react";
import type {
EmptyMessagePartProps,
FileMessagePartProps,
ImageMessagePartProps,
ReasoningMessagePartProps,
@@ -119,7 +120,9 @@ import { showErrorNotification, showSuccessNotification } from "@homarr/notifica
import { useScopedI18n } from "@homarr/translation/client";
import classes from "./assistant-panel.module.css";
import { getAssistantActivityState } from "./assistant-activity-state";
import { useAssistantAutoApproval, useAssistantAutomaticAction } from "./assistant-auto-approval";
import { AssistantDotMatrix } from "./assistant-dot-matrix";
import { getAssistantDirectiveTranslationKey, parseAssistantDirectives } from "./assistant-directives";
import { remarkAssistantDirectives, resolveAssistantDirectiveEntity } from "./assistant-markdown-directives";
import type { AssistantDirectiveEntity } from "./assistant-markdown-directives";
@@ -160,6 +163,7 @@ interface AssistantPanelProps extends AssistantConversationControls {
isRunning: boolean;
unreadCount: number;
latestAssistantText: string;
latestAssistantPartType: string | undefined;
latestUserText: string;
latestStatus: MessageStatus | undefined;
pendingAction: AssistantPendingAction | undefined;
@@ -527,11 +531,24 @@ const ReasoningPart = (_props: ReasoningMessagePartProps) => (
remarkPlugins={markdownRemarkPlugins}
components={assistantMarkdownComponents}
componentsByLanguage={{ mermaid: { SyntaxHighlighter: MermaidDiagram } }}
defer
/>
</AssistantDirectiveEntitiesContext.Provider>
);
const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => {
const t = useScopedI18n("common.assistant");
if (status.type !== "running") return null;
return (
<Group component="output" className={classes.messagePending} gap="xs" wrap="nowrap" aria-live="polite">
<AssistantDotMatrix state="thinking" role="presentation" aria-hidden />
<Text size="xs" fw={600} c="dimmed">
{t("activity.thinking")}
</Text>
</Group>
);
};
const ReasoningVisibilityContext = createContext<{
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
@@ -970,7 +987,9 @@ const AssistantChainOfThought = () => {
onClick={() => setPreferredCollapsed(!collapsed)}
>
<Group gap="xs" wrap="nowrap">
{running && <Loader type="bars" size="xs" color="gray" />}
{(running || chainStatus.type === "requires-action") && (
<AssistantDotMatrix state={running ? "thinking" : "waiting"} role="presentation" aria-hidden />
)}
<Text size="xs" fw={650} c="dimmed">
{t("reasoning")}
</Text>
@@ -1736,7 +1755,9 @@ const WebSearchActivity = () => {
const AssistantMessage = () => (
<MessagePrimitive.Root className={`${classes.message} ${classes.assistantMessage}`}>
<MessagePrimitive.Parts
unstable_showEmptyOnNonTextEnd={false}
components={{
Empty: AssistantMessagePending,
Text: AssistantTextPart,
Source: SourcePart,
File: FilePart,
@@ -2490,6 +2511,7 @@ const AssistantActivityBar = ({
isRunning,
unreadCount,
latestAssistantText,
latestAssistantPartType,
latestUserText,
latestStatus,
pendingAction,
@@ -2501,6 +2523,7 @@ const AssistantActivityBar = ({
| "isRunning"
| "unreadCount"
| "latestAssistantText"
| "latestAssistantPartType"
| "latestUserText"
| "latestStatus"
| "pendingAction"
@@ -2530,26 +2553,21 @@ const AssistantActivityBar = ({
const detail = isRunning
? latestUserText || t("activity.working")
: pendingCopy?.detail || latestAssistantText || (failed ? t("responseError.description") : t("activity.completed"));
const activityState = getAssistantActivityState({
isRunning,
latestPartType: latestAssistantPartType,
needsApproval,
failed,
});
const activityColor = needsApproval ? "yellow" : failed ? "red" : isRunning ? "red" : "green";
return (
<Box component="output" className={classes.activityBar} aria-live="polite">
<UnstyledButton className={classes.activityTrigger} onClick={onOpen} aria-label={t("activity.expand")}>
<Group gap="sm" wrap="nowrap">
{isRunning ? (
<ThemeIcon variant="light" color="red" radius="xl">
<Loader type="bars" size="sm" color="red" />
</ThemeIcon>
) : (
<ThemeIcon variant="light" color={failed ? "red" : needsApproval ? "yellow" : "green"} radius="xl">
{failed ? (
<IconAlertTriangle size={17} />
) : needsApproval ? (
<IconRobot size={17} />
) : (
<IconCheck size={17} />
)}
</ThemeIcon>
)}
<ThemeIcon variant="light" color={activityColor} radius="xl">
<AssistantDotMatrix state={activityState} label={title} role="presentation" aria-hidden />
</ThemeIcon>
<Stack gap={0} className={classes.activityCopy}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={700}>
@@ -2740,6 +2758,7 @@ export const AssistantPanel = ({
isRunning,
unreadCount,
latestAssistantText,
latestAssistantPartType,
latestUserText,
latestStatus,
pendingAction,
@@ -2786,6 +2805,7 @@ export const AssistantPanel = ({
isRunning={isRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestAssistantPartType={latestAssistantPartType}
latestUserText={latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}
@@ -28,6 +28,7 @@ import { AssistantWidgetRendererProvider } from "@homarr/widgets";
import { AssistantContext, AssistantPreferencesContext, useAssistantPreferences } from "./assistant-context";
import { shouldAutomaticallyContinueAssistant } from "./assistant-auto-submit";
import { AssistantAutoApprovalProvider } from "./assistant-auto-approval";
import { getRunningAssistantPartType } from "./assistant-activity-state";
import { prepareAssistantRequestBody } from "./assistant-attachment-payload";
import { createAssistantBrowserToolExecutors } from "./assistant-browser-tool-executors";
import { AssistantAskUserTool, AssistantConfigureAppTool } from "./assistant-human-tools";
@@ -536,6 +537,10 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
const latestAssistantText = getMessageText(latestAssistantMessage);
const latestUserText = getMessageText(latestUserMessage);
const latestStatus = latestAssistantMessage?.role === "assistant" ? latestAssistantMessage.status : undefined;
const latestAssistantPartType = getRunningAssistantPartType(
latestStatus?.type,
latestAssistantMessage?.content.at(-1)?.type,
);
const pendingAction = getPendingAssistantAction(latestAssistantMessage);
const assistantIsRunning = isRunning || queuedPrompt !== null;
const notificationKey = getNotificationKey(latestAssistantMessage);
@@ -698,6 +703,7 @@ const EnabledAssistantProvider = ({ children }: PropsWithChildren) => {
isRunning={assistantIsRunning}
unreadCount={unreadCount}
latestAssistantText={latestAssistantText}
latestAssistantPartType={latestAssistantPartType}
latestUserText={queuedPrompt ?? latestUserText}
latestStatus={latestStatus}
pendingAction={pendingAction}