Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/agent/src/hooks/useAgentChat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<View className={twMerge('text-sm', className)} data-testid="response-message">
Expand Down
14 changes: 14 additions & 0 deletions packages/agent/src/response/ResponseMessage/ResponseMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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"
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeSanitize]}
components={{
script: () => null,
Expand Down Expand Up @@ -65,6 +71,14 @@ function ResponseMessage({ content, className }: ResponseMessageProps) {
h3: ({ children }) => <h3 className="text-sm font-semibold mt-2 mb-1">{children}</h3>,
hr: () => <hr className="my-3 border-border" />,
code: ({ children }) => <code className="bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono">{children}</code>,
table: ({ children }) => (
<div className="overflow-x-auto my-2">
<table className="w-full text-sm border-collapse">{children}</table>
</div>
),
thead: ({ children }) => <thead className="border-b border-border">{children}</thead>,
th: ({ children }) => <th className="text-left px-2 py-1.5 font-semibold">{children}</th>,
td: ({ children }) => <td className="px-2 py-1.5 border-t border-border/50">{children}</td>,
}}
>
{normalizeMarkdownLists(content)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ResponseMessage content={content} />)
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(
<ResponseMessage content="This is a **test** with a [link](https://example.com)" />,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -21,7 +23,32 @@ function tryParse<T>(value: unknown): T | null {
return value as T
}

function renderSteps(data: Record<string, unknown>) {
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<string, unknown> }) {
const styles = useInlineMarkdownStyles()
const steps = tryParse<string[]>(data.steps)
if (!steps || !Array.isArray(steps)) return null
return (
Expand All @@ -31,7 +58,9 @@ function renderSteps(data: Record<string, unknown>) {
<View className="mt-0.5 h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent">
<Text className="text-[11px] font-semibold text-white">{i + 1}</Text>
</View>
<Text className="text-sm text-text-primary leading-relaxed flex-1">{step}</Text>
<View className="flex-1">
<Markdown style={styles}>{step}</Markdown>
</View>
</View>
))}
</View>
Expand Down Expand Up @@ -119,7 +148,8 @@ function renderCard(data: Record<string, unknown>) {
)
}

function renderList(data: Record<string, unknown>) {
function ListRenderer({ data }: { data: Record<string, unknown> }) {
const styles = useInlineMarkdownStyles()
const items = tryParse<string[]>(data.items)
const title = typeof data.title === 'string' ? data.title : null
if (!items || !Array.isArray(items)) return null
Expand All @@ -135,7 +165,9 @@ function renderList(data: Record<string, unknown>) {
{items.map((item, i) => (
<View key={i} className="flex-row items-start gap-2.5">
<View className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />
<Text className="text-sm text-text-primary leading-relaxed flex-1">{item}</Text>
<View className="flex-1">
<Markdown style={styles}>{item}</Markdown>
</View>
</View>
))}
</View>
Expand Down Expand Up @@ -199,7 +231,7 @@ function StructuredResponse({ uiHint, data: rawData, className }: StructuredResp

switch (uiHint) {
case 'steps':
content = renderSteps(data)
content = <StepsRenderer data={data} />
break
case 'table':
content = renderTable(data)
Expand All @@ -208,7 +240,7 @@ function StructuredResponse({ uiHint, data: rawData, className }: StructuredResp
content = renderCard(data)
break
case 'list':
content = renderList(data)
content = <ListRenderer data={data} />
break
case 'warning':
content = renderWarning(data)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -20,6 +22,27 @@ function tryParse<T>(value: unknown): T | null {
return value as T
}

/** Renders a string with inline markdown (bold, italic, links, code). */
function InlineMarkdown({ text }: { text: string }) {
return (
<ReactMarkdown
rehypePlugins={[rehypeSanitize]}
components={{
// Unwrap block-level <p> so content stays inline within its parent
p: ({ children }) => <>{children}</>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
code: ({ children }) => <code className="bg-surface-sunken rounded px-1 py-0.5 text-xs font-mono">{children}</code>,
// Prevent block elements that would break layout
script: () => null,
iframe: () => null,
}}
>
{text}
</ReactMarkdown>
)
}

function renderSteps(data: Record<string, unknown>) {
const steps = tryParse<string[]>(data.steps)
if (!steps || !Array.isArray(steps)) return null
Expand All @@ -33,7 +56,9 @@ function renderSteps(data: Record<string, unknown>) {
>
{i + 1}
</span>
<span className="text-sm text-text-primary leading-relaxed">{step}</span>
<span className="text-sm text-text-primary leading-relaxed">
<InlineMarkdown text={step} />
</span>
</li>
))}
</ol>
Expand Down Expand Up @@ -148,7 +173,9 @@ function renderList(data: Record<string, unknown>) {
{items.map((item, i) => (
<li key={i} className="flex items-start gap-2.5">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
<span className="text-sm text-text-primary leading-relaxed">{item}</span>
<span className="text-sm text-text-primary leading-relaxed">
<InlineMarkdown text={item} />
</span>
</li>
))}
</ul>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,31 @@ describe('StructuredResponse', () => {
expect(container.firstChild).toBeNull()
})

it('renders steps with inline markdown formatting', () => {
render(
<StructuredResponse
uiHint="steps"
data={{ steps: ['**Choose a room** — View photos and capacities', 'Review fees'] }}
/>,
)
expect(screen.getByTestId('structured-steps')).toBeDefined()
// Bold text should be rendered as <strong>, not literal asterisks
const strong = screen.getByText('Choose a room')
expect(strong.tagName).toBe('STRONG')
})

it('renders list items with inline markdown formatting', () => {
render(
<StructuredResponse
uiHint="list"
data={{ items: ['**Important** item', 'Plain item'] }}
/>,
)
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(
<StructuredResponse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ const phaseLabels: Record<StreamState['phase'], string> = {
// 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;
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/types/streaming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
11 changes: 11 additions & 0 deletions packages/tokens/src/component/components-brand.json
Original file line number Diff line number Diff line change
@@ -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)" },
Expand Down
Loading
Loading