diff --git a/packages/agent/package.json b/packages/agent/package.json index ee0d155..4752e9b 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -113,6 +113,7 @@ "class-variance-authority": "catalog:", "react-markdown": "^10.1.0", "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", "react-aria": "catalog:", "tailwind-merge": "catalog:" }, diff --git a/packages/agent/src/hooks/useAgentChat.ts b/packages/agent/src/hooks/useAgentChat.ts index e5cd352..9015582 100644 --- a/packages/agent/src/hooks/useAgentChat.ts +++ b/packages/agent/src/hooks/useAgentChat.ts @@ -37,6 +37,7 @@ type Action = | { type: 'SEND_START'; message: ChatMessage } | { type: 'STREAM_PHASE'; phase: StreamState['phase'] } | { type: 'STREAM_CONTENT'; content: string } + | { type: 'STREAM_CONTENT_RESET' } | { type: 'STREAM_AGENT'; agent: string } | { type: 'SEND_SUCCESS'; message: ChatMessage; streamingContent: string; conversationId: string | null } | { type: 'SEND_ERROR'; error: ChatError } @@ -67,6 +68,9 @@ function reducer(state: AgentChatState, action: Action): AgentChatState { case 'STREAM_CONTENT': return { ...state, streamingContent: state.streamingContent + action.content } + case 'STREAM_CONTENT_RESET': + return { ...state, streamingContent: '' } + case 'STREAM_AGENT': return { ...state, streamingAgent: action.agent } @@ -200,6 +204,10 @@ export function useAgentChat(config: AgentChatConfig) { ctx.accumulatedContent += event.content dispatch({ type: 'STREAM_CONTENT', content: event.content as string }) break + case 'delta_reset': + ctx.accumulatedContent = '' + dispatch({ type: 'STREAM_CONTENT_RESET' }) + break case 'done': ctx.agentResponse = event.response as AgentResponse ctx.capturedConversationId = (event.conversation_id as string) ?? null diff --git a/packages/agent/src/response/ResponseMessage/ResponseMessage.native.tsx b/packages/agent/src/response/ResponseMessage/ResponseMessage.native.tsx index 8d66fdd..d2007dd 100644 --- a/packages/agent/src/response/ResponseMessage/ResponseMessage.native.tsx +++ b/packages/agent/src/response/ResponseMessage/ResponseMessage.native.tsx @@ -32,34 +32,68 @@ const markdownStyles = { function ResponseMessage({ content, className }: ResponseMessageProps) { const systemScheme = useColorScheme() const themeCtx = useContext(ThemeContext) - // brand and dark modes both have dark backgrounds; fall back to system scheme - const isDark = themeCtx?.colorMode === 'brand' || themeCtx?.colorMode === 'dark' || systemScheme === 'dark' + // brand and dark modes both have dark backgrounds; when context is unavailable + // (e.g. cross-package symlink resolution) default to dark since native app uses brand mode + const isDark = themeCtx + ? themeCtx.colorMode === 'brand' || themeCtx.colorMode === 'dark' + : true + + const textColor = isDark ? '#e8e8e8' : '#1a1a1a' + const headingColor = isDark ? '#f0f0f0' : '#111111' + const accentColor = isDark ? '#38bdf8' : '#0284c7' const themedStyles = useMemo(() => ({ ...markdownStyles, - body: { color: isDark ? '#e8e8e8' : '#1a1a1a' }, + body: { color: textColor }, + text: { color: textColor }, + textgroup: { color: textColor }, + paragraph: { ...markdownStyles.paragraph, color: textColor }, + strong: { ...markdownStyles.strong, color: textColor }, + em: { fontStyle: 'italic' as const, color: textColor }, + s: { textDecorationLine: 'line-through' as const, color: textColor }, + bullet_list: { ...markdownStyles.bullet_list }, + ordered_list: { ...markdownStyles.ordered_list }, + list_item: { ...markdownStyles.list_item, color: textColor }, + bullet_list_icon: { color: textColor, marginLeft: 10, marginRight: 10 }, + ordered_list_icon: { color: textColor, marginLeft: 10, marginRight: 10 }, + bullet_list_content: { flex: 1, color: textColor }, + ordered_list_content: { flex: 1, color: textColor }, + heading1: { ...markdownStyles.heading1, color: headingColor }, + heading2: { ...markdownStyles.heading2, color: headingColor }, + heading3: { ...markdownStyles.heading3, color: headingColor }, + heading4: { fontSize: 14, fontWeight: '600' as const, color: headingColor }, + heading5: { fontSize: 13, fontWeight: '600' as const, color: headingColor }, + heading6: { fontSize: 12, fontWeight: '600' as const, color: headingColor }, code_inline: { ...markdownStyles.code_inline, backgroundColor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)', - color: isDark ? '#38bdf8' : '#0284c7', + color: accentColor, }, code_block: { ...markdownStyles.code_block, backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)', borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', + color: textColor, + }, + fence: { + ...markdownStyles.code_block, + backgroundColor: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)', + borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', + color: textColor, }, blockquote: { ...markdownStyles.blockquote, - borderLeftColor: isDark ? '#38bdf8' : '#0284c7', + borderLeftColor: accentColor, + backgroundColor: isDark ? 'rgba(255,255,255,0.03)' : 'rgba(0,0,0,0.03)', }, link: { ...markdownStyles.link, - color: isDark ? '#38bdf8' : '#0284c7', + color: accentColor, }, - heading1: { ...markdownStyles.heading1, color: isDark ? '#f0f0f0' : '#111111' }, - heading2: { ...markdownStyles.heading2, color: isDark ? '#f0f0f0' : '#111111' }, - heading3: { ...markdownStyles.heading3, color: isDark ? '#f0f0f0' : '#111111' }, - }), [isDark]) + td: { flex: 1, padding: 5, color: textColor }, + th: { flex: 1, padding: 5, fontWeight: '600' as const, color: headingColor }, + hr: { backgroundColor: isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)', height: 1 }, + }), [isDark, textColor, headingColor, accentColor]) return ( diff --git a/packages/agent/src/response/ResponseMessage/ResponseMessage.tsx b/packages/agent/src/response/ResponseMessage/ResponseMessage.tsx index 4fa2561..a450679 100644 --- a/packages/agent/src/response/ResponseMessage/ResponseMessage.tsx +++ b/packages/agent/src/response/ResponseMessage/ResponseMessage.tsx @@ -1,6 +1,7 @@ import React from 'react' import ReactMarkdown from 'react-markdown' import rehypeSanitize from 'rehype-sanitize' +import remarkGfm from 'remark-gfm' import { twMerge } from 'tailwind-merge' type ResponseMessageProps = { @@ -32,12 +33,17 @@ function ResponseMessage({ content, className }: ResponseMessageProps) { '[&_pre]:bg-surface-raised [&_pre]:border [&_pre]:border-border [&_pre]:rounded-xl [&_pre]:p-4 [&_pre]:overflow-x-auto', '[&_hr]:my-3 [&_hr]:border-border', '[&_blockquote]:border-l-2 [&_blockquote]:border-border-strong [&_blockquote]:pl-4 [&_blockquote]:text-text-secondary', + '[&_table]:w-full [&_table]:text-sm [&_table]:border-collapse [&_table]:my-2', + '[&_thead]:border-b [&_thead]:border-border', + '[&_th]:text-left [&_th]:px-2 [&_th]:py-1.5 [&_th]:font-semibold', + '[&_td]:px-2 [&_td]:py-1.5 [&_td]:border-t [&_td]:border-border/50', '[&_a]:text-accent [&_a]:underline-offset-2 [&_a]:hover:text-accent/80', className, )} data-testid="response-message" > null, @@ -65,6 +71,14 @@ function ResponseMessage({ content, className }: ResponseMessageProps) { h3: ({ children }) =>

{children}

, hr: () =>
, code: ({ children }) => {children}, + table: ({ children }) => ( +
+ {children}
+
+ ), + thead: ({ children }) => {children}, + th: ({ children }) => {children}, + td: ({ children }) => {children}, }} > {normalizeMarkdownLists(content)} diff --git a/packages/agent/src/response/ResponseMessage/__tests__/ResponseMessage.test.tsx b/packages/agent/src/response/ResponseMessage/__tests__/ResponseMessage.test.tsx index aa3b706..abbc101 100644 --- a/packages/agent/src/response/ResponseMessage/__tests__/ResponseMessage.test.tsx +++ b/packages/agent/src/response/ResponseMessage/__tests__/ResponseMessage.test.tsx @@ -119,6 +119,20 @@ describe('ResponseMessage', () => { expect(item.textContent).not.toMatch(/^\d+\./) }) + it('renders GFM tables', () => { + const content = [ + '| Header 1 | Header 2 |', + '|---|---|', + '| Cell A | Cell B |', + '| Cell C | Cell D |', + ].join('\n') + render() + expect(screen.getByRole('table')).toBeDefined() + expect(screen.getByText('Header 1').tagName).toBe('TH') + expect(screen.getByText('Cell A').tagName).toBe('TD') + expect(screen.getByText('Cell D').tagName).toBe('TD') + }) + it('has no accessibility violations', async () => { const { container } = render( , diff --git a/packages/agent/src/response/StructuredResponse/StructuredResponse.native.tsx b/packages/agent/src/response/StructuredResponse/StructuredResponse.native.tsx index 2fc5b38..cc69d8c 100644 --- a/packages/agent/src/response/StructuredResponse/StructuredResponse.native.tsx +++ b/packages/agent/src/response/StructuredResponse/StructuredResponse.native.tsx @@ -1,5 +1,7 @@ -import React from 'react' -import { View, Text, Pressable, Linking } from 'react-native' +import React, { useContext, useMemo } from 'react' +import { View, Text, Pressable, Linking, useColorScheme } from 'react-native' +import Markdown from 'react-native-markdown-display' +import { ThemeContext } from '@surf-kit/theme' import type { AgentResponse } from '../../types/agent' type StructuredResponseProps = { @@ -21,7 +23,32 @@ function tryParse(value: unknown): T | null { return value as T } -function renderSteps(data: Record) { +function useIsDark() { + const systemScheme = useColorScheme() + const themeCtx = useContext(ThemeContext) + return themeCtx + ? themeCtx.colorMode === 'brand' || themeCtx.colorMode === 'dark' + : true +} + +function useInlineMarkdownStyles() { + const isDark = useIsDark() + const textColor = isDark ? '#e8e8e8' : '#1a1a1a' + return useMemo(() => ({ + body: { fontSize: 14, lineHeight: 22, margin: 0, padding: 0, color: textColor }, + text: { color: textColor }, + textgroup: { color: textColor }, + paragraph: { marginTop: 0, marginBottom: 0, color: textColor }, + strong: { fontWeight: '600' as const, color: textColor }, + em: { fontStyle: 'italic' as const, color: textColor }, + code_inline: { fontSize: 12, fontFamily: 'monospace', color: isDark ? '#38bdf8' : '#0284c7' }, + bullet_list_icon: { color: textColor }, + ordered_list_icon: { color: textColor }, + }), [isDark, textColor]) +} + +function StepsRenderer({ data }: { data: Record }) { + const styles = useInlineMarkdownStyles() const steps = tryParse(data.steps) if (!steps || !Array.isArray(steps)) return null return ( @@ -31,7 +58,9 @@ function renderSteps(data: Record) { {i + 1} - {step} + + {step} +
))} @@ -119,7 +148,8 @@ function renderCard(data: Record) { ) } -function renderList(data: Record) { +function ListRenderer({ data }: { data: Record }) { + const styles = useInlineMarkdownStyles() const items = tryParse(data.items) const title = typeof data.title === 'string' ? data.title : null if (!items || !Array.isArray(items)) return null @@ -135,7 +165,9 @@ function renderList(data: Record) { {items.map((item, i) => ( - {item} + + {item} + ))} @@ -199,7 +231,7 @@ function StructuredResponse({ uiHint, data: rawData, className }: StructuredResp switch (uiHint) { case 'steps': - content = renderSteps(data) + content = break case 'table': content = renderTable(data) @@ -208,7 +240,7 @@ function StructuredResponse({ uiHint, data: rawData, className }: StructuredResp content = renderCard(data) break case 'list': - content = renderList(data) + content = break case 'warning': content = renderWarning(data) diff --git a/packages/agent/src/response/StructuredResponse/StructuredResponse.tsx b/packages/agent/src/response/StructuredResponse/StructuredResponse.tsx index 637150d..4f4ecd3 100644 --- a/packages/agent/src/response/StructuredResponse/StructuredResponse.tsx +++ b/packages/agent/src/response/StructuredResponse/StructuredResponse.tsx @@ -1,4 +1,6 @@ import React from 'react' +import ReactMarkdown from 'react-markdown' +import rehypeSanitize from 'rehype-sanitize' import type { AgentResponse } from '../../types/agent' type StructuredResponseProps = { @@ -20,6 +22,27 @@ function tryParse(value: unknown): T | null { return value as T } +/** Renders a string with inline markdown (bold, italic, links, code). */ +function InlineMarkdown({ text }: { text: string }) { + return ( + so content stays inline within its parent + p: ({ children }) => <>{children}, + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + code: ({ children }) => {children}, + // Prevent block elements that would break layout + script: () => null, + iframe: () => null, + }} + > + {text} + + ) +} + function renderSteps(data: Record) { const steps = tryParse(data.steps) if (!steps || !Array.isArray(steps)) return null @@ -33,7 +56,9 @@ function renderSteps(data: Record) { > {i + 1} - {step} + + + ))} @@ -148,7 +173,9 @@ function renderList(data: Record) { {items.map((item, i) => (
  • ))} diff --git a/packages/agent/src/response/StructuredResponse/__tests__/StructuredResponse.test.tsx b/packages/agent/src/response/StructuredResponse/__tests__/StructuredResponse.test.tsx index bfd7f61..0fde312 100644 --- a/packages/agent/src/response/StructuredResponse/__tests__/StructuredResponse.test.tsx +++ b/packages/agent/src/response/StructuredResponse/__tests__/StructuredResponse.test.tsx @@ -63,6 +63,31 @@ describe('StructuredResponse', () => { expect(container.firstChild).toBeNull() }) + it('renders steps with inline markdown formatting', () => { + render( + , + ) + expect(screen.getByTestId('structured-steps')).toBeDefined() + // Bold text should be rendered as , not literal asterisks + const strong = screen.getByText('Choose a room') + expect(strong.tagName).toBe('STRONG') + }) + + it('renders list items with inline markdown formatting', () => { + render( + , + ) + expect(screen.getByTestId('structured-list')).toBeDefined() + const strong = screen.getByText('Important') + expect(strong.tagName).toBe('STRONG') + }) + it('has no accessibility violations', async () => { const { container } = render( = { // appears inline with the final text rather than on a new line. // Uses steps(1) for a crisp blink that won't smooth-fade to invisible. const CURSOR_STYLES = ` -.sk-streaming-cursor > :not(ul,ol,blockquote):last-child::after, +.sk-streaming-cursor > :not(ul,ol,blockquote,div:has(table)):last-child::after, .sk-streaming-cursor > :is(ul,ol):last-child > li:last-child::after, -.sk-streaming-cursor > blockquote:last-child > p:last-child::after { +.sk-streaming-cursor > blockquote:last-child > p:last-child::after, +.sk-streaming-cursor > div:has(table):last-child table tbody tr:last-child td:last-child::after { content: ""; display: inline-block; width: 2px; diff --git a/packages/agent/src/types/streaming.ts b/packages/agent/src/types/streaming.ts index 919d0ed..c702af5 100644 --- a/packages/agent/src/types/streaming.ts +++ b/packages/agent/src/types/streaming.ts @@ -4,6 +4,7 @@ import type { ChatError } from './chat' export type StreamEvent = | { type: 'phase'; phase: 'thinking' | 'retrieving' | 'generating' | 'verifying' | 'waiting' } | { type: 'delta'; content: string } + | { type: 'delta_reset' } | { type: 'source'; source: Source } | { type: 'agent'; agent: string } | { type: 'verification'; result: VerificationResult } diff --git a/packages/tokens/src/component/components-brand.json b/packages/tokens/src/component/components-brand.json index 16571cd..7494002 100644 --- a/packages/tokens/src/component/components-brand.json +++ b/packages/tokens/src/component/components-brand.json @@ -1,4 +1,15 @@ { + "button": { + "$type": "color", + "text": { + "primary": { "$value": "{color.brand.dark}" } + } + }, + "message": { + "user": { + "text": { "$type": "color", "$value": "{color.brand.dark}" } + } + }, "confidence": { "high": { "bg": { "$type": "color", "$value": "rgba(56,189,208,0.15)" }, diff --git a/packages/tokens/src/semantic/brand.json b/packages/tokens/src/semantic/brand.json index b36c80c..3b102fa 100644 --- a/packages/tokens/src/semantic/brand.json +++ b/packages/tokens/src/semantic/brand.json @@ -13,11 +13,11 @@ "inverse": { "$value": "{color.brand.dark}" } }, "accent": { - "primary": { "$value": "{color.brand.blue}" }, - "primary-hover": { "$value": "{color.brand.cyan}" }, + "primary": { "$value": "{color.brand.cyan}" }, + "primary-hover": { "$value": "#5DD4E5" }, "primary-active": { "$value": "#2AA8BC" }, - "primary-subtle": { "$value": "rgba(0,145,165,0.15)" }, - "primary-subtlest": { "$value": "rgba(0,145,165,0.08)" } + "primary-subtle": { "$value": "rgba(56,189,208,0.15)" }, + "primary-subtlest": { "$value": "rgba(56,189,208,0.08)" } }, "border": { "default": { "$value": "rgba(225,185,137,0.15)" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18d3c91..4a93af8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ importers: version: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.4(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4)))(babel-plugin-react-compiler@1.0.0)(graphql@16.13.0)(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) expo-router: specifier: ~5.0.0 - version: 5.0.7(6d8c0d6ca03c27db2c298d4ae62d971b) + version: 5.0.7(cc0896eaef82236ef120affe88430fdc) expo-status-bar: specifier: ~3.0.0 version: 3.0.9(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) @@ -357,6 +357,9 @@ importers: rehype-sanitize: specifier: ^6.0.0 version: 6.0.0 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.1 tailwind-merge: specifier: 'catalog:' version: 2.6.1 @@ -14326,7 +14329,7 @@ snapshots: dependencies: invariant: 2.2.4 - expo-router@5.0.7(6d8c0d6ca03c27db2c298d4ae62d971b): + expo-router@5.0.7(cc0896eaef82236ef120affe88430fdc): dependencies: '@expo/metro-runtime': 5.0.4(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4)) '@expo/server': 0.6.3 @@ -14336,7 +14339,7 @@ snapshots: '@react-navigation/native-stack': 7.14.4(@react-navigation/native@7.1.33(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-safe-area-context@5.4.1(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native-screens@4.11.1(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) client-only: 0.0.1 expo: 53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.4(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4)))(babel-plugin-react-compiler@1.0.0)(graphql@16.13.0)(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4) - expo-constants: 17.1.8(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.4(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4)))(babel-plugin-react-compiler@1.0.0)(graphql@16.13.0)(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4)) + expo-constants: 55.0.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.4(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4)))(babel-plugin-react-compiler@1.0.0)(graphql@16.13.0)(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(typescript@5.9.3) expo-linking: 55.0.7(expo@53.0.27(@babel/core@7.29.0)(@expo/metro-runtime@5.0.4(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4)))(babel-plugin-react-compiler@1.0.0)(graphql@16.13.0)(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4))(react-native@0.79.7(@babel/core@7.29.0)(@types/react@19.2.14)(react@19.2.4))(react@19.2.4)(typescript@5.9.3) invariant: 2.2.4 react-fast-compare: 3.2.2