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

@@ -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}