feat(web): migrate to @assistant-ui/react 0.14 (tap 0.9.8 with update-depth fix)

- @assistant-ui/react ^0.11.53 -> ^0.14.29, react-markdown ^0.11.9 -> ^0.14.7
- resolves @assistant-ui/tap 0.9.8, which ships the upstream fix for
  bulk message prepends (per-scheduler MAX_UPDATE_DEPTH guard, PR
  assistant-ui/assistant-ui#5370) that the local patch covered for 0.3.5
- API migration: useAssistantApi -> useAui, useAssistantState -> useAuiState
  with s.* selector access; TextMessagePart type-guard for content.find;
  portable DefaultComponentsMap annotation for memoizeMarkdownComponents

Verified: tsc clean, 1762 unit tests, history-load e2e 12/12 against the
unpatched upstream scheduler.
This commit is contained in:
weishu
2026-08-02 00:12:29 +08:00
parent 58ff91e5c4
commit 9b299eaab7
20 changed files with 574 additions and 123 deletions
+2 -2
View File
@@ -13,8 +13,8 @@
"test:mermaid-lightbox:e2e": "playwright test e2e/mermaid-lightbox.spec.ts"
},
"dependencies": {
"@assistant-ui/react": "^0.11.53",
"@assistant-ui/react-markdown": "^0.11.9",
"@assistant-ui/react": "^0.14.29",
"@assistant-ui/react-markdown": "^0.14.7",
"@elevenlabs/react": "^0.13.0",
"@hapi/protocol": "workspace:*",
"@lobehub/icons": "^5.4.0",
@@ -4,7 +4,7 @@ import { render, fireEvent } from '@testing-library/react'
const addAttachment = vi.fn()
vi.mock('@assistant-ui/react', () => ({
useAssistantApi: () => ({
useAui: () => ({
composer: () => ({ addAttachment }),
}),
}))
@@ -1,5 +1,5 @@
import { useCallback } from 'react'
import { useAssistantApi } from '@assistant-ui/react'
import { useAui } from '@assistant-ui/react'
import { useDragOver } from '@/hooks/useDragOver'
import { useTranslation } from '@/lib/use-translation'
@@ -10,7 +10,7 @@ export function DragDropZone({
children: React.ReactNode
disabled?: boolean
}) {
const api = useAssistantApi()
const api = useAui()
const isDragging = useDragOver()
const { t } = useTranslation()
@@ -1,5 +1,5 @@
import { getCodexCollaborationModeOptions, getPermissionModeOptionsForFlavor } from '@hapi/protocol'
import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react'
import { ComposerPrimitive, useAui, useAuiState } from '@assistant-ui/react'
import {
type ChangeEvent as ReactChangeEvent,
type ClipboardEvent as ReactClipboardEvent,
@@ -276,12 +276,12 @@ export function HappyComposer(props: {
const serviceTier = rawServiceTier ?? null
const displayedServiceTier = getDisplayedCodexServiceTier(serviceTier)
const api = useAssistantApi()
const api = useAui()
const { composerEnterBehavior } = useComposerEnterBehavior()
const composerText = useAssistantState(({ composer }) => composer.text)
const attachments = useAssistantState(({ composer }) => composer.attachments)
const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning)
const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled)
const composerText = useAuiState((s) => s.composer.text)
const attachments = useAuiState((s) => s.composer.attachments)
const threadIsRunning = useAuiState((s) => s.thread.isRunning)
const threadIsDisabled = useAuiState((s) => s.thread.isDisabled)
const controlsDisabled = disabled || (!active && !allowSendWhenInactive) || threadIsDisabled
const trimmed = composerText.trim()
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ThreadPrimitive, useAssistantState } from '@assistant-ui/react'
import { ThreadPrimitive, useAuiState } from '@assistant-ui/react'
import type { ApiClient } from '@/api/client'
import type { HappyRuntimeExtras } from '@/lib/assistant-runtime'
import type { Session, SessionMetadataSummary } from '@/types/api'
@@ -435,7 +435,7 @@ export function HappyThread(props: {
}) {
const { t } = useTranslation()
const { terminalToolDisplayMode } = useTerminalToolDisplayMode()
const runtimeExtras = useAssistantState(({ thread }) => thread.extras) as HappyRuntimeExtras | undefined
const runtimeExtras = useAuiState((s) => s.thread.extras) as HappyRuntimeExtras | undefined
const appliedMessagesVersion = runtimeExtras?.messagesVersion ?? props.messagesVersion
const appliedHistoryVersion = runtimeExtras?.historyVersion ?? props.historyVersion
const viewportRef = useRef<HTMLDivElement | null>(null)
@@ -1,4 +1,4 @@
import { useAssistantApi } from '@assistant-ui/react'
import { useAui } from '@assistant-ui/react'
import { useCallback, useMemo, useSyncExternalStore } from 'react'
import type { ApiClient } from '@/api/client'
import { getMessageWindowState, subscribeMessageWindow } from '@/lib/message-window-store'
@@ -162,7 +162,7 @@ export function QueuedMessagesBar({
onEdit?: (params: { text: string; pendingSchedule: PendingSchedule | null }) => void
}) {
const queued = useQueuedMessages(sessionId)
const assistantApi = useAssistantApi()
const assistantApi = useAui()
const cancelMutation = useCancelQueuedMessage(api)
const { t } = useTranslation()
const { addToast } = useToast()
@@ -1,4 +1,4 @@
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { MessagePrimitive, useAuiState, type TextMessagePart } from '@assistant-ui/react'
import { MarkdownText } from '@/components/assistant-ui/markdown-text'
import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning'
import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage'
@@ -21,35 +21,35 @@ const MESSAGE_PART_COMPONENTS = {
} as const
export function HappyAssistantMessage() {
const messageId = useAssistantState(({ message }) => message.id)
const messageId = useAuiState((s) => s.message.id)
const elementId = getConversationMessageAnchorId(messageId)
const isCliOutput = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const isCliOutput = useAuiState((s) => {
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.kind === 'cli-output'
})
const codexReview = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const codexReview = useAuiState((s) => {
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.kind === 'codex-review' ? custom.review : undefined
})
const cliText = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const cliText = useAuiState((s) => {
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
if (custom?.kind !== 'cli-output') return ''
return message.content.find((part) => part.type === 'text')?.text ?? ''
return s.message.content.find((part): part is TextMessagePart => part.type === 'text')?.text ?? ''
})
const toolOnly = useAssistantState(({ message }) => {
if (message.role !== 'assistant') return false
const parts = message.content
const toolOnly = useAuiState((s) => {
if (s.message.role !== 'assistant') return false
const parts = s.message.content
return parts.length > 0 && parts.every((part) => part.type === 'tool-call')
})
const copyText = useAssistantState(({ message }) => {
if (message.role !== 'assistant') return ''
return getAssistantCopyText(message.content)
const copyText = useAuiState((s) => {
if (s.message.role !== 'assistant') return ''
return getAssistantCopyText(s.message.content)
})
const durationMs = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.durationMs)
const usage = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.usage)
const messageModel = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.model)
const turnCount = useAssistantState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.turnCount)
const durationMs = useAuiState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.durationMs)
const usage = useAuiState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.usage)
const messageModel = useAuiState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.model)
const turnCount = useAuiState(({ message }) => (message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined)?.turnCount)
const metadata = { durationMs, usage, model: messageModel ?? null, turnCount }
@@ -7,7 +7,7 @@ import { MessageActions } from './MessageActions'
const copy = vi.fn()
vi.mock('@assistant-ui/react', () => ({
useAssistantState: (selector: (state: { message: { createdAt: Date } }) => unknown) => selector({
useAuiState: (selector: (state: { message: { createdAt: Date } }) => unknown) => selector({
message: { createdAt: new Date(2026, 6, 12, 10, 30) }
})
}))
@@ -1,5 +1,5 @@
import * as Popover from '@radix-ui/react-popover'
import { useAssistantState } from '@assistant-ui/react'
import { useAuiState } from '@assistant-ui/react'
import { CheckIcon, CopyIcon, InfoIcon } from '@/components/icons'
import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'
import { useTranslation } from '@/lib/use-translation'
@@ -18,7 +18,7 @@ type MessageActionsProps = {
export function MessageActions({ align, copyText, metadata, messageElementId }: MessageActionsProps) {
const { copied, copy } = useCopyToClipboard()
const { t } = useTranslation()
const threadIsRunning = useAssistantState(({ thread }) => thread?.isRunning ?? false)
const threadIsRunning = useAuiState(({ thread }) => thread?.isRunning ?? false)
const canCopy = Boolean(copyText)
const hasMetadata = metadata ? buildMessageMetadataLabels(metadata).length > 0 : false
const shareButton = messageElementId && !threadIsRunning ? (
@@ -1,4 +1,4 @@
import { useAssistantState } from '@assistant-ui/react'
import { useAuiState } from '@assistant-ui/react'
import { formatMessageTimestamp, formatMessageTimestampTitle } from '@/chat/presentation'
import { cn } from '@/lib/utils'
@@ -7,7 +7,7 @@ type MessageTimestampProps = {
}
export function MessageTimestamp(props: MessageTimestampProps) {
const createdAt = useAssistantState(({ message }) => message.createdAt)
const createdAt = useAuiState((s) => s.message.createdAt)
return (
<time
@@ -1,4 +1,4 @@
import { useAssistantState } from '@assistant-ui/react'
import { useAuiState } from '@assistant-ui/react'
import { useOptionalHappyChatContext } from '@/components/AssistantChat/context'
import { useTranslation } from '@/lib/use-translation'
@@ -30,7 +30,7 @@ export function ShareTurnButton(props: {
}) {
const ctx = useOptionalHappyChatContext()
const { t } = useTranslation()
const fallbackUserText = useAssistantState(({ message, thread }) => {
const fallbackUserText = useAuiState(({ message, thread }) => {
const currentIndex = thread.messages.findIndex((candidate) => candidate.id === message.id)
if (currentIndex < 0) return ''
for (let index = currentIndex; index >= 0; index -= 1) {
@@ -1,19 +1,19 @@
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { MessagePrimitive, useAuiState } from '@assistant-ui/react'
import { getEventPresentation } from '@/chat/presentation'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
import { getConversationMessageAnchorId } from '@/chat/outline'
import { MessageTimestamp } from '@/components/AssistantChat/messages/MessageTimestamp'
export function HappySystemMessage() {
const role = useAssistantState(({ message }) => message.role)
const messageId = useAssistantState(({ message }) => message.id)
const text = useAssistantState(({ message }) => {
if (message.role !== 'system') return ''
return message.content[0]?.type === 'text' ? message.content[0].text : ''
const role = useAuiState((s) => s.message.role)
const messageId = useAuiState((s) => s.message.id)
const text = useAuiState((s) => {
if (s.message.role !== 'system') return ''
return s.message.content[0]?.type === 'text' ? s.message.content[0].text : ''
})
const icon = useAssistantState(({ message }) => {
if (message.role !== 'system') return null
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const icon = useAuiState((s) => {
if (s.message.role !== 'system') return null
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const event = custom?.kind === 'event' ? custom.event : undefined
return event ? getEventPresentation(event).icon : null
})
@@ -1,4 +1,4 @@
import { MessagePrimitive, useAssistantState } from '@assistant-ui/react'
import { MessagePrimitive, useAuiState, type TextMessagePart } from '@assistant-ui/react'
import { useHappyChatContext } from '@/components/AssistantChat/context'
import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime'
import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator'
@@ -10,36 +10,36 @@ import { MessageActions } from '@/components/AssistantChat/messages/MessageActio
export function HappyUserMessage() {
const ctx = useHappyChatContext()
const role = useAssistantState(({ message }) => message.role)
const messageId = useAssistantState(({ message }) => message.id)
const role = useAuiState((s) => s.message.role)
const messageId = useAuiState((s) => s.message.id)
const elementId = getConversationMessageAnchorId(messageId)
const text = useAssistantState(({ message }) => {
if (message.role !== 'user') return ''
return message.content.find((part) => part.type === 'text')?.text ?? ''
const text = useAuiState((s) => {
if (s.message.role !== 'user') return ''
return s.message.content.find((part): part is TextMessagePart => part.type === 'text')?.text ?? ''
})
const status = useAssistantState(({ message }) => {
if (message.role !== 'user') return undefined
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const status = useAuiState((s) => {
if (s.message.role !== 'user') return undefined
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.status
})
const localId = useAssistantState(({ message }) => {
if (message.role !== 'user') return null
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const localId = useAuiState((s) => {
if (s.message.role !== 'user') return null
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.localId ?? null
})
const attachments = useAssistantState(({ message }) => {
if (message.role !== 'user') return undefined
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const attachments = useAuiState((s) => {
if (s.message.role !== 'user') return undefined
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.attachments
})
const isCliOutput = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const isCliOutput = useAuiState((s) => {
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
return custom?.kind === 'cli-output'
})
const cliText = useAssistantState(({ message }) => {
const custom = message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
const cliText = useAuiState((s) => {
const custom = s.message.metadata.custom as Partial<HappyChatMessageMetadata> | undefined
if (custom?.kind !== 'cli-output') return ''
return message.content.find((part) => part.type === 'text')?.text ?? ''
return s.message.content.find((part): part is TextMessagePart => part.type === 'text')?.text ?? ''
})
if (role !== 'user') return null
const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage)
@@ -31,7 +31,7 @@ const mockSessionId = 'sess-test'
const setText = vi.fn()
vi.mock('@assistant-ui/react', () => ({
useAssistantApi: () => ({
useAui: () => ({
composer: () => ({ setText }),
}),
}))
+4 -4
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useNavigate } from '@tanstack/react-router'
import { AssistantRuntimeProvider, useAssistantApi, useAssistantState } from '@assistant-ui/react'
import { AssistantRuntimeProvider, useAui, useAuiState } from '@assistant-ui/react'
import { DragDropZone } from '@/components/AssistantChat/DragDropZone'
import type { ApiClient } from '@/api/client'
import type {
@@ -243,8 +243,8 @@ function isUninvokedScheduledMessage(message: DecryptedMessage): boolean {
* refresh of /sessions/:id doesn't re-attach the same payload.
*/
function ShareSeedConsumer(props: { sessionId: string; sessionActive: boolean }) {
const assistantApi = useAssistantApi()
const composerText = useAssistantState(({ composer }) => composer.text)
const assistantApi = useAui()
const composerText = useAuiState((s) => s.composer.text)
const composerTextRef = useRef(composerText)
const initRef = useRef(false)
const transferIdRef = useRef<string | null>(null)
@@ -332,7 +332,7 @@ export function ScratchlistDrawerHost(props: {
onSend: (text: string, attachments?: AttachmentMetadata[], scheduledAt?: number | null) => Promise<boolean>
onExitScratchlistMode: () => void
}) {
const assistantApi = useAssistantApi()
const assistantApi = useAui()
const handlePromoteToComposer = useCallback(async (entry: ScratchlistEntry) => {
assistantApi.composer().setText(entry.text)
// Exit scratchlist mode before rehydrating attachments so addAttachment
@@ -1,6 +1,6 @@
import '@assistant-ui/react-markdown/styles/dot.css'
import type { ComponentPropsWithoutRef, MouseEvent, ReactNode } from 'react'
import type { ComponentPropsWithoutRef, ComponentType, MouseEvent, ReactNode } from 'react'
import { useState, useCallback, useEffect, useMemo, createContext, useContext } from 'react'
import {
MarkdownTextPrimitive,
@@ -763,7 +763,15 @@ function Image(props: ComponentPropsWithoutRef<'img'>) {
return <img {...props} className={cn('aui-md-img my-3 max-w-full rounded-xl', props.className)} />
}
export const defaultComponents = memoizeMarkdownComponents({
type DefaultComponentsMap = {
[key: string]: ComponentType<any>
code: ComponentType<any>
pre: ComponentType<any>
CodeHeader: ComponentType<any>
SyntaxHighlighter: ComponentType<any>
}
export const defaultComponents: DefaultComponentsMap = memoizeMarkdownComponents({
SyntaxHighlighter,
CodeHeader,
pre: Pre,
@@ -790,7 +798,7 @@ export const defaultComponents = memoizeMarkdownComponents({
th: Th,
td: Td,
img: Image,
} as const)
} as const) as unknown as DefaultComponentsMap
export function MarkdownText() {
return (