diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts new file mode 100644 index 00000000..2acebf26 --- /dev/null +++ b/web/src/chat/presentation.ts @@ -0,0 +1,37 @@ +import type { AgentEvent } from '@/chat/types' + +export function formatUnixTimestamp(value: number): string { + const ms = value < 1_000_000_000_000 ? value * 1000 : value + const date = new Date(ms) + if (Number.isNaN(date.getTime())) return String(value) + return date.toLocaleString() +} + +export function renderEventLabel(event: AgentEvent): string { + if (event.type === 'switch') { + const mode = event.mode === 'local' ? 'local' : 'remote' + return `🔄 Switched to ${mode}` + } + if (event.type === 'title-changed') { + const title = typeof event.title === 'string' ? event.title : '' + return title ? `Title changed to "${title}"` : 'Title changed' + } + if (event.type === 'permission-mode-changed') { + const modeValue = (event as Record).mode + const mode = typeof modeValue === 'string' ? modeValue : 'default' + return `🔐 Permission mode: ${mode}` + } + if (event.type === 'limit-reached') { + const endsAt = typeof event.endsAt === 'number' ? event.endsAt : null + return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached' + } + if (event.type === 'message') { + return typeof event.message === 'string' ? event.message : 'Message' + } + try { + return JSON.stringify(event) + } catch { + return String(event.type) + } +} + diff --git a/web/src/components/AssistantChat/messages/MessageStatusIndicator.tsx b/web/src/components/AssistantChat/messages/MessageStatusIndicator.tsx new file mode 100644 index 00000000..d20f2529 --- /dev/null +++ b/web/src/components/AssistantChat/messages/MessageStatusIndicator.tsx @@ -0,0 +1,38 @@ +import type { MessageStatus } from '@/types/api' + +function ErrorIcon() { + return ( + + + + + + ) +} + +export function MessageStatusIndicator(props: { + status?: MessageStatus + onRetry?: () => void +}) { + if (props.status !== 'failed') { + return null + } + + return ( + + + + + {props.onRetry ? ( + + ) : null} + + ) +} + diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index 24b3b11d..19aec2e3 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,10 +1,11 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' -import type { AgentEvent, ToolCallBlock } from '@/chat/types' -import type { MessageStatus } from '@/types/api' +import type { ToolCallBlock } from '@/chat/types' +import { renderEventLabel } from '@/chat/presentation' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { LazyRainbowText } from '@/components/LazyRainbowText' +import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' @@ -37,77 +38,6 @@ function isToolCallBlock(value: unknown): value is ToolCallBlock { return true } -function ErrorIcon() { - return ( - - - - - - ) -} - -function MessageStatusIndicator(props: { - status?: MessageStatus - onRetry?: () => void -}) { - if (props.status !== 'failed') { - return null - } - - return ( - - - - - {props.onRetry ? ( - - ) : null} - - ) -} - -function formatUnixTimestamp(value: number): string { - const ms = value < 1_000_000_000_000 ? value * 1000 : value - const date = new Date(ms) - if (Number.isNaN(date.getTime())) return String(value) - return date.toLocaleString() -} - -function renderEventLabel(event: AgentEvent): string { - if (event.type === 'switch') { - const mode = event.mode === 'local' ? 'local' : 'remote' - return `🔄 Switched to ${mode}` - } - if (event.type === 'title-changed') { - const title = typeof event.title === 'string' ? event.title : '' - return title ? `Title changed to "${title}"` : 'Title changed' - } - if (event.type === 'permission-mode-changed') { - const modeValue = (event as Record).mode - const mode = typeof modeValue === 'string' ? modeValue : 'default' - return `🔐 Permission mode: ${mode}` - } - if (event.type === 'limit-reached') { - const endsAt = typeof event.endsAt === 'number' ? event.endsAt : null - return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached' - } - if (event.type === 'message') { - return typeof event.message === 'string' ? event.message : 'Message' - } - try { - return JSON.stringify(event) - } catch { - return String(event.type) - } -} - function HappyNestedBlockList(props: { blocks: ChatBlock[] }) { diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index d3e8d284..046a7e92 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -1,44 +1,8 @@ import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' -import type { MessageStatus } from '@/types/api' import { LazyRainbowText } from '@/components/LazyRainbowText' import { useHappyChatContext } from '@/components/AssistantChat/context' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' - -function ErrorIcon() { - return ( - - - - - - ) -} - -function MessageStatusIndicator(props: { - status?: MessageStatus - onRetry?: () => void -}) { - if (props.status !== 'failed') { - return null - } - - return ( - - - - - {props.onRetry ? ( - - ) : null} - - ) -} +import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' export function HappyUserMessage() { const ctx = useHappyChatContext() @@ -70,4 +34,3 @@ export function HappyUserMessage() { ) } - diff --git a/web/src/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index 02617a2b..8f164e0e 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import type { AppendMessage, ExternalStoreAdapter, ThreadMessageLike } from '@assistant-ui/react' import { useExternalStoreRuntime } from '@assistant-ui/react' +import { renderEventLabel } from '@/chat/presentation' import type { ChatBlock } from '@/chat/types' import type { AgentEvent, ToolCallBlock } from '@/chat/types' import type { MessageStatus as HappyMessageStatus, Session } from '@/types/api' @@ -15,41 +16,6 @@ function safeStringify(value: unknown): string { } } -function formatUnixTimestamp(value: number): string { - const ms = value < 1_000_000_000_000 ? value * 1000 : value - const date = new Date(ms) - if (Number.isNaN(date.getTime())) return String(value) - return date.toLocaleString() -} - -function renderEventLabel(event: AgentEvent): string { - if (event.type === 'switch') { - const mode = event.mode === 'local' ? 'local' : 'remote' - return `🔄 Switched to ${mode}` - } - if (event.type === 'title-changed') { - const title = typeof event.title === 'string' ? event.title : '' - return title ? `Title changed to "${title}"` : 'Title changed' - } - if (event.type === 'permission-mode-changed') { - const modeValue = (event as Record).mode - const mode = typeof modeValue === 'string' ? modeValue : 'default' - return `🔐 Permission mode: ${mode}` - } - if (event.type === 'limit-reached') { - const endsAt = typeof event.endsAt === 'number' ? event.endsAt : null - return endsAt ? `⏳ Usage limit reached until ${formatUnixTimestamp(endsAt)}` : '⏳ Usage limit reached' - } - if (event.type === 'message') { - return typeof event.message === 'string' ? event.message : 'Message' - } - try { - return JSON.stringify(event) - } catch { - return String(event.type) - } -} - export type HappyChatMessageMetadata = { kind: 'user' | 'assistant' | 'tool' | 'event' status?: HappyMessageStatus