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 }) => (
+
+ ),
+ 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) => (
- {item}
+
+
+
))}
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