From 59cb3a4d99e40163c4bfdca817297101e9592fd3 Mon Sep 17 00:00:00 2001 From: ajnart Date: Sun, 9 Aug 2026 15:55:10 +0200 Subject: [PATCH] feat: improve assistant thinking states --- apps/docs/docs/management/assistant.mdx | 4 +- .../assistant-activity-state.spec.ts | 46 ++++ .../assistant/assistant-activity-state.ts | 29 +++ .../assistant/assistant-dot-matrix.module.css | 87 +++++++ .../assistant/assistant-dot-matrix.spec.tsx | 83 +++++++ .../assistant/assistant-dot-matrix.tsx | 234 ++++++++++++++++++ .../assistant/assistant-panel.module.css | 22 +- .../components/assistant/assistant-panel.tsx | 54 ++-- .../assistant/assistant-provider.tsx | 6 + 9 files changed, 542 insertions(+), 23 deletions(-) create mode 100644 apps/nextjs/src/components/assistant/assistant-activity-state.spec.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-activity-state.ts create mode 100644 apps/nextjs/src/components/assistant/assistant-dot-matrix.module.css create mode 100644 apps/nextjs/src/components/assistant/assistant-dot-matrix.spec.tsx create mode 100644 apps/nextjs/src/components/assistant/assistant-dot-matrix.tsx diff --git a/apps/docs/docs/management/assistant.mdx b/apps/docs/docs/management/assistant.mdx index 363d9e5f4..8d4f1e073 100644 --- a/apps/docs/docs/management/assistant.mdx +++ b/apps/docs/docs/management/assistant.mdx @@ -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 diff --git a/apps/nextjs/src/components/assistant/assistant-activity-state.spec.ts b/apps/nextjs/src/components/assistant/assistant-activity-state.spec.ts new file mode 100644 index 000000000..72136a0fe --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-activity-state.spec.ts @@ -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"); + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-activity-state.ts b/apps/nextjs/src/components/assistant/assistant-activity-state.ts new file mode 100644 index 000000000..e48ca5eb9 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-activity-state.ts @@ -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"; +}; diff --git a/apps/nextjs/src/components/assistant/assistant-dot-matrix.module.css b/apps/nextjs/src/components/assistant/assistant-dot-matrix.module.css new file mode 100644 index 000000000..9832ab5c1 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-dot-matrix.module.css @@ -0,0 +1,87 @@ +@property --assistant-dot-matrix-hi { + syntax: ""; + inherits: false; + initial-value: 1; +} + +@property --assistant-dot-matrix-lo { + syntax: ""; + 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; + } +} diff --git a/apps/nextjs/src/components/assistant/assistant-dot-matrix.spec.tsx b/apps/nextjs/src/components/assistant/assistant-dot-matrix.spec.tsx new file mode 100644 index 000000000..905fbda56 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-dot-matrix.spec.tsx @@ -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; + + 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("[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("[data-slot='assistant-dot-matrix']"); + const firstDot = matrix?.querySelector("[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("[data-slot='assistant-dot-matrix']"); + const updatedFirstDot = updatedMatrix?.querySelector("[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); + } + }); +}); diff --git a/apps/nextjs/src/components/assistant/assistant-dot-matrix.tsx b/apps/nextjs/src/components/assistant/assistant-dot-matrix.tsx new file mode 100644 index 000000000..66411a471 --- /dev/null +++ b/apps/nextjs/src/components/assistant/assistant-dot-matrix.tsx @@ -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; + 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; + +export type AssistantDotMatrixState = keyof typeof states; + +export const assistantDotMatrixStates = Object.keys(states) as readonly AssistantDotMatrixState[]; + +interface AssistantDotMatrixProps extends Omit, "children"> { + state?: AssistantDotMatrixState; + label?: string; +} + +export const AssistantDotMatrix = ({ className, state = "loading", label, ...props }: AssistantDotMatrixProps) => { + const config: StateConfig = states[state]; + + return ( + + {label ?? state} + + {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 ( + + ); + })} + + + ); +}; diff --git a/apps/nextjs/src/components/assistant/assistant-panel.module.css b/apps/nextjs/src/components/assistant/assistant-panel.module.css index 24bdd4880..c57a39996 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.module.css +++ b/apps/nextjs/src/components/assistant/assistant-panel.module.css @@ -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 { diff --git a/apps/nextjs/src/components/assistant/assistant-panel.tsx b/apps/nextjs/src/components/assistant/assistant-panel.tsx index 2f7a218a8..c16dbcccb 100644 --- a/apps/nextjs/src/components/assistant/assistant-panel.tsx +++ b/apps/nextjs/src/components/assistant/assistant-panel.tsx @@ -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 /> ); +const AssistantMessagePending = ({ status }: EmptyMessagePartProps) => { + const t = useScopedI18n("common.assistant"); + if (status.type !== "running") return null; + + return ( + + + + {t("activity.thinking")} + + + ); +}; + const ReasoningVisibilityContext = createContext<{ collapsed: boolean; setCollapsed: (collapsed: boolean) => void; @@ -970,7 +987,9 @@ const AssistantChainOfThought = () => { onClick={() => setPreferredCollapsed(!collapsed)} > - {running && } + {(running || chainStatus.type === "requires-action") && ( + + )} {t("reasoning")} @@ -1736,7 +1755,9 @@ const WebSearchActivity = () => { const AssistantMessage = () => ( - {isRunning ? ( - - - - ) : ( - - {failed ? ( - - ) : needsApproval ? ( - - ) : ( - - )} - - )} + + + @@ -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} diff --git a/apps/nextjs/src/components/assistant/assistant-provider.tsx b/apps/nextjs/src/components/assistant/assistant-provider.tsx index 9e062848e..8d4b29079 100644 --- a/apps/nextjs/src/components/assistant/assistant-provider.tsx +++ b/apps/nextjs/src/components/assistant/assistant-provider.tsx @@ -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}