diff --git a/apps/mobile/__tests__/components/chat/action-chips.test.tsx b/apps/mobile/__tests__/components/chat/action-chips.test.tsx index 0771c19ff..b2739df8c 100644 --- a/apps/mobile/__tests__/components/chat/action-chips.test.tsx +++ b/apps/mobile/__tests__/components/chat/action-chips.test.tsx @@ -148,6 +148,21 @@ describe('ActionChips (mobile)', () => { expect(pressables.length).toBe(0) }) + it('does not render tag mutation chips as Pressable even with handler', () => { + for (const type of ['CreateTag', 'UpdateTag', 'DeleteTag']) { + let tree: any + TestRenderer.act(() => { + tree = TestRenderer.create( + {}} + />, + ) + }) + expect(findPressableByType(tree.root).length).toBe(0) + } + }) + it('does not render DeleteGoal chip as Pressable even with handler', () => { let tree: any TestRenderer.act(() => { @@ -189,6 +204,30 @@ describe('ActionChips (mobile)', () => { expect(findPressableByType(tree.root).length).toBe(0) }) + it('renders localized labels for the new tag and reorder action types', () => { + const cases: Array<[string, string]> = [ + ['CreateTag', 'chat.action.createdTag'], + ['UpdateTag', 'chat.action.updatedTag'], + ['DeleteTag', 'chat.action.deletedTag'], + ['ReorderGoals', 'chat.action.reorderedGoals'], + ['ReorderHabits', 'chat.action.reorderedHabits'], + ] + for (const [type, labelKey] of cases) { + let tree: any + TestRenderer.act(() => { + tree = TestRenderer.create( + , + ) + }) + const matches = tree.root.findAll( + (node: any) => + typeof node.props?.children === 'string' && + node.props.children.startsWith(labelKey), + ) + expect(matches.length).toBeGreaterThan(0) + } + }) + it('does not render chip with null entityId as Pressable', () => { let tree: any TestRenderer.act(() => { diff --git a/apps/mobile/__tests__/components/message-bubble.test.tsx b/apps/mobile/__tests__/components/message-bubble.test.tsx index 85ab0f37e..8a235852f 100644 --- a/apps/mobile/__tests__/components/message-bubble.test.tsx +++ b/apps/mobile/__tests__/components/message-bubble.test.tsx @@ -61,6 +61,21 @@ vi.mock('lucide-react-native', () => { return { Sparkles: (props: Record) => React.createElement('Sparkles', props), User: (props: Record) => React.createElement('User', props), + ArrowUpRight: (props: Record) => + React.createElement('ArrowUpRight', props), + } +}) + +const push = vi.fn() +vi.mock('expo-router', () => ({ + useRouter: () => ({ push }), +})) + +vi.mock('@/components/ui/markdown', () => { + const React = require('react') + return { + Markdown: ({ children }: { children: string }) => + React.createElement('Markdown', null, children), } }) @@ -76,9 +91,6 @@ vi.mock('@/components/chat/clarification-card', () => ({ vi.mock('@/components/chat/pending-operation-card', () => ({ PendingOperationCard: () => null, })) -vi.mock('@/components/chat/format-chat-message', () => ({ - formatChatMessage: (text: string) => text, -})) function makeMessage(overrides: Partial = {}): ChatMessage { return { @@ -155,3 +167,55 @@ describe('MessageBubble trace footer (mobile)', () => { expect(setStringAsync).toHaveBeenCalledWith('req-abc-123') }) }) + +function findSurfaceLinks(root: TestTreeRoot, label: string): TestNode[] { + return root.findAll( + (node) => + node.props != null && + typeof node.type !== 'string' && + typeof node.props.onPress === 'function' && + node.props.accessibilityLabel === label, + ) +} + +describe('MessageBubble related-surfaces footer (mobile)', () => { + beforeEach(() => { + push.mockClear() + }) + + it('renders deep links for known surfaces and drops unknown ones', async () => { + let tree!: TestInstance + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + }) + + const links = findSurfaceLinks(tree.root, 'chat.related.surface.gamification') + expect(links).toHaveLength(1) + expect(findSurfaceLinks(tree.root, 'chat.related.surface.mystery')).toHaveLength(0) + + await TestRenderer.act(async () => { + links[0]?.props.onPress?.() + }) + expect(push).toHaveBeenCalledWith('/achievements') + }) + + it('does not render the footer for user messages', async () => { + let tree!: TestInstance + await TestRenderer.act(async () => { + tree = TestRenderer.create( + , + ) + }) + + expect(findSurfaceLinks(tree.root, 'chat.related.surface.gamification')).toHaveLength(0) + }) +}) diff --git a/apps/mobile/__tests__/components/ui/markdown.test.tsx b/apps/mobile/__tests__/components/ui/markdown.test.tsx new file mode 100644 index 000000000..9aca8c2d6 --- /dev/null +++ b/apps/mobile/__tests__/components/ui/markdown.test.tsx @@ -0,0 +1,94 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { isValidElement, type ReactElement } from 'react' + +const TestRenderer = require('react-test-renderer') + +const openURL = vi.fn((_url: string) => Promise.resolve()) +vi.mock('react-native', () => ({ + Linking: { openURL: (url: string) => openURL(url) }, + Text: 'Text', +})) + +// Capture the props the Markdown wrapper passes into react-native-marked so we +// can assert theming + exercise the safe-link renderer it provides. +const markedProps: { current: Record | null } = { current: null } +vi.mock('react-native-marked', () => { + class Renderer { + getKey() { + return 'k' + } + } + return { + __esModule: true, + default: (props: Record) => { + markedProps.current = props + return null + }, + Renderer, + } +}) + +import { Markdown } from '@/components/ui/markdown' + +interface LinkElement { + props: { onPress?: () => void } +} + +interface CapturedRenderer { + link(children: unknown, href: string): ReactElement & LinkElement +} + +function renderMarkdown(props: Parameters[0]): Record { + markedProps.current = null + TestRenderer.act(() => { + TestRenderer.create() + }) + if (!markedProps.current) throw new Error('Markdown did not render react-native-marked') + return markedProps.current +} + +describe('mobile Markdown wrapper', () => { + beforeEach(() => { + openURL.mockClear() + }) + + it('passes the content through as the markdown value', () => { + const props = renderMarkdown({ children: '# Hello' }) + expect(props.value).toBe('# Hello') + }) + + it('opens http(s) and mailto links', () => { + const props = renderMarkdown({ children: 'x' }) + const renderer = props.renderer as CapturedRenderer + + for (const href of ['https://orbit.app', 'http://x', 'mailto:a@b.com']) { + const element = renderer.link(['label'], href) + expect(isValidElement(element)).toBe(true) + expect(typeof element.props.onPress).toBe('function') + element.props.onPress?.() + } + + expect(openURL).toHaveBeenCalledTimes(3) + expect(openURL).toHaveBeenCalledWith('https://orbit.app') + }) + + it('refuses to open javascript: and data: link schemes', () => { + const props = renderMarkdown({ children: 'x' }) + const renderer = props.renderer as CapturedRenderer + + for (const href of ['javascript:alert(1)', 'data:text/html,')).toBe( - '<script>alert("xss")</script>', - ) - }) - - it('escapes ampersands', () => { - expect(formatChatMessage('A & B')).toBe('A & B') - }) - - it('converts **bold** to ', () => { - expect(formatChatMessage('This is **bold** text')).toBe( - 'This is bold text', - ) - }) - - it('converts *italic* to ', () => { - expect(formatChatMessage('This is *italic* text')).toBe( - 'This is italic text', - ) - }) - - it('handles bold and italic together', () => { - expect(formatChatMessage('**bold** and *italic*')).toBe( - 'bold and italic', - ) - }) - - it('does not convert bullet asterisks to italic', () => { - const input = '* item one\n* item two' - const result = formatChatMessage(input) - expect(result).not.toContain('') - }) - - it('returns empty string for empty input', () => { - expect(formatChatMessage('')).toBe('') - }) - - it('handles text with no formatting', () => { - expect(formatChatMessage('plain text')).toBe('plain text') - }) - - it('handles multiple bold sections', () => { - expect(formatChatMessage('**a** and **b**')).toBe( - 'a and b', - ) - }) -}) diff --git a/apps/web/__tests__/components/chat/message-bubble.test.tsx b/apps/web/__tests__/components/chat/message-bubble.test.tsx index 175904625..2b8c2127b 100644 --- a/apps/web/__tests__/components/chat/message-bubble.test.tsx +++ b/apps/web/__tests__/components/chat/message-bubble.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from 'vitest' +import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' vi.mock('next-intl', () => ({ @@ -8,15 +8,21 @@ vi.mock('next-intl', () => ({ values ? `${key}:${JSON.stringify(values)}` : key, })) +const push = vi.fn() +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push }), +})) + +vi.mock('@/components/ui/markdown', () => ({ + Markdown: ({ content }: { content: string }) =>
{content}
, +})) + vi.mock('./action-chips', () => ({ ActionChips: () =>
, })) vi.mock('./breakdown-suggestion', () => ({ BreakdownSuggestion: () =>
, })) -vi.mock('./format-chat-message', () => ({ - formatChatMessage: (text: string) => text, -})) vi.mock('@/components/chat/action-chips', () => ({ ActionChips: () =>
, @@ -24,9 +30,6 @@ vi.mock('@/components/chat/action-chips', () => ({ vi.mock('@/components/chat/breakdown-suggestion', () => ({ BreakdownSuggestion: () =>
, })) -vi.mock('@/components/chat/format-chat-message', () => ({ - formatChatMessage: (text: string) => text, -})) vi.mock('@/components/chat/pending-operation-card', () => ({ PendingOperationCard: () =>
, })) @@ -46,6 +49,10 @@ function makeMessage(overrides: Partial = {}): ChatMessage { } describe('MessageBubble', () => { + beforeEach(() => { + push.mockClear() + }) + it('renders user message with user label', () => { render() expect(screen.getByText('chat.senderYou')).toBeInTheDocument() @@ -177,6 +184,34 @@ describe('MessageBubble', () => { expect(screen.getByText('Fresh confirmation required')).toBeInTheDocument() }) + it('renders a related-surfaces footer that deep-links known surfaces', () => { + render( + , + ) + + expect(screen.getByText('chat.related.title')).toBeInTheDocument() + const link = screen.getByRole('button', { name: 'chat.related.surface.gamification' }) + fireEvent.click(link) + expect(push).toHaveBeenCalledWith('/achievements') + // unknown surface IDs are dropped + expect(screen.queryByText('mystery')).not.toBeInTheDocument() + }) + + it('does not render a related-surfaces footer for user messages', () => { + render( + , + ) + expect(screen.queryByText('chat.related.title')).not.toBeInTheDocument() + }) + it('renders the trace footer for AI messages with a correlationId', () => { render( { + it('renders bold, lists, and headings from markdown', () => { + const { container } = render( + , + ) + expect(container.querySelector('h1')?.textContent).toBe('Title') + expect(container.querySelector('strong')?.textContent).toBe('bold') + expect(container.querySelectorAll('li')).toHaveLength(2) + }) + + it('strips script tags and event handlers (XSS safe)', () => { + const { container } = render( + alert(1)'} />, + ) + expect(container.querySelector('script')).toBeNull() + expect(container.innerHTML).not.toContain('onerror') + }) + + it('renders nothing for empty content', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('applies a custom className alongside the prose scope', () => { + const { container } = render() + const root = container.firstChild as HTMLElement + expect(root.className).toContain('prose-orbit') + expect(root.className).toContain('text-sm') + }) +}) diff --git a/apps/web/components/chat/action-chips.tsx b/apps/web/components/chat/action-chips.tsx index b743653ad..680fa8e09 100644 --- a/apps/web/components/chat/action-chips.tsx +++ b/apps/web/components/chat/action-chips.tsx @@ -33,6 +33,16 @@ const ACTION_LABELS: Record = { UpdateGoalProgress: 'chat.action.updatedGoalProgress', UpdateGoalStatus: 'chat.action.updatedGoalStatus', LinkHabitsToGoal: 'chat.action.linkedGoalHabits', + create_tag: 'chat.action.createdTag', + update_tag: 'chat.action.updatedTag', + delete_tag: 'chat.action.deletedTag', + reorder_goals: 'chat.action.reorderedGoals', + reorder_habits: 'chat.action.reorderedHabits', + CreateTag: 'chat.action.createdTag', + UpdateTag: 'chat.action.updatedTag', + DeleteTag: 'chat.action.deletedTag', + ReorderGoals: 'chat.action.reorderedGoals', + ReorderHabits: 'chat.action.reorderedHabits', } const NON_NAVIGABLE_ACTION_TYPES = new Set([ @@ -43,6 +53,12 @@ const NON_NAVIGABLE_ACTION_TYPES = new Set([ 'DeleteSubHabit', 'suggest_breakdown', 'SuggestBreakdown', + 'create_tag', + 'CreateTag', + 'update_tag', + 'UpdateTag', + 'delete_tag', + 'DeleteTag', ]) const CHIP_STYLES: Record< diff --git a/apps/web/components/chat/format-chat-message.ts b/apps/web/components/chat/format-chat-message.ts deleted file mode 100644 index bef9960fa..000000000 --- a/apps/web/components/chat/format-chat-message.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Formats AI chat message text for display. - * Escapes HTML first (preventing XSS), then converts basic markdown to HTML. - * Since we escape ALL HTML entities before adding our own safe tags, - * the output is inherently safe without needing DOMPurify. - */ -export function formatChatMessage(text: string): string { - // 1. Escape HTML entities to prevent XSS - let html = text - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - - // 2. Convert **bold** to - html = html.replaceAll(/\*\*(.+?)\*\*/g, '$1') - - // 3. Convert *italic* (single asterisk not followed by space, i.e. not a bullet) - html = html.replaceAll(/(?$1') - - return html -} diff --git a/apps/web/components/chat/message-bubble.tsx b/apps/web/components/chat/message-bubble.tsx index 905c9a7c6..a33ff805a 100644 --- a/apps/web/components/chat/message-bubble.tsx +++ b/apps/web/components/chat/message-bubble.tsx @@ -1,16 +1,18 @@ 'use client' import { useState, useMemo } from 'react' -import { Sparkles, User } from 'lucide-react' +import { Sparkles, User, ArrowUpRight } from 'lucide-react' import { useTranslations } from 'next-intl' +import { useRouter } from 'next/navigation' import type { ChatMessage } from '@orbit/shared/types/chat' import type { AgentExecuteOperationResponse } from '@orbit/shared/types/ai' +import { getRelatedSurfaces } from '@orbit/shared/chat' import { resolveUpgradeEntitlementFromPolicyDenial } from '@orbit/shared/utils' import { LocalImage } from '@/components/ui/local-image' +import { Markdown } from '@/components/ui/markdown' import { ActionChips } from './action-chips' import { BreakdownSuggestion } from './breakdown-suggestion' import { ClarificationCard } from './clarification-card' -import { formatChatMessage } from './format-chat-message' import { PendingOperationCard } from './pending-operation-card' interface MessageBubbleProps { @@ -42,9 +44,15 @@ export function MessageBubble({ onUpgradeClick, }: Readonly) { const t = useTranslations() + const router = useRouter() const [dismissedBreakdowns, setDismissedBreakdowns] = useState>(new Set()) const [traceCopied, setTraceCopied] = useState(false) + const relatedSurfaces = useMemo( + () => getRelatedSurfaces(message.relatedSurfaces), + [message.relatedSurfaces], + ) + async function copyTraceId(correlationId: string) { if (!navigator.clipboard) return await navigator.clipboard.writeText(correlationId) @@ -119,12 +127,7 @@ export function MessageBubble({ className="rounded-xl max-h-48 mb-2" /> )} -

+

{!isUser && message.correlationId && ( @@ -140,6 +143,27 @@ export function MessageBubble({ )} + {!isUser && relatedSurfaces.length > 0 && ( +
+ + {t('chat.related.title')} + +
+ {relatedSurfaces.map((surface) => ( + + ))} +
+
+ )} + {!isUser && nonSuggestionActions.length > 0 && ( )} diff --git a/apps/web/components/habits/description-viewer.tsx b/apps/web/components/habits/description-viewer.tsx index 151a56627..9f468bd7b 100644 --- a/apps/web/components/habits/description-viewer.tsx +++ b/apps/web/components/habits/description-viewer.tsx @@ -1,12 +1,10 @@ 'use client' -import { useMemo } from 'react' import { createPortal } from 'react-dom' -import { marked } from 'marked' -import DOMPurify from 'dompurify' import { ArrowLeft } from 'lucide-react' import { useTranslations } from 'next-intl' import { useIsClient } from '@/hooks/use-is-client' +import { Markdown } from '@/components/ui/markdown' interface DescriptionViewerProps { open: boolean @@ -24,15 +22,6 @@ export function DescriptionViewer({ const t = useTranslations() const mounted = useIsClient() - const renderedHtml = useMemo(() => { - if (!open || !description) return '' - const raw = marked.parse(description, { async: false }) as string // NOSONAR - marked.parse with async:false returns string but typed as string | Promise - return DOMPurify.sanitize(raw, { - ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'a'], - ALLOWED_ATTR: ['href', 'target', 'rel'], - }) - }, [open, description]) - if (!mounted || !open) return null return createPortal( @@ -51,10 +40,7 @@ export function DescriptionViewer({ {/* Markdown content */}
-
+
, document.body, diff --git a/apps/web/components/ui/markdown.tsx b/apps/web/components/ui/markdown.tsx new file mode 100644 index 000000000..205e11dfa --- /dev/null +++ b/apps/web/components/ui/markdown.tsx @@ -0,0 +1,54 @@ +'use client' + +import { useMemo } from 'react' +import { marked } from 'marked' +import DOMPurify from 'dompurify' + +interface MarkdownProps { + content: string + className?: string +} + +const ALLOWED_TAGS = [ + 'p', + 'br', + 'strong', + 'em', + 'ul', + 'ol', + 'li', + 'code', + 'pre', + 'blockquote', + 'h1', + 'h2', + 'h3', + 'a', +] +const ALLOWED_ATTR = ['href', 'target', 'rel'] + +/** + * The single web markdown renderer for chat messages and habit/goal + * descriptions. Parses with `marked`, then sanitizes through DOMPurify with a + * fixed tag/attribute allowlist (no scripts, no event handlers, links only) and + * renders inside the `.prose-orbit` typographic scope. + */ +export function Markdown({ content, className }: Readonly) { + const html = useMemo(() => { + if (!content) return '' + const raw = marked.parse(content, { async: false }) as string // NOSONAR - marked.parse with async:false returns string but typed as string | Promise + return DOMPurify.sanitize(raw, { + ALLOWED_TAGS, + ALLOWED_ATTR, + }) + }, [content]) + + if (!html) return null + + return ( +
+ ) +} diff --git a/apps/web/hooks/use-chat-composer.ts b/apps/web/hooks/use-chat-composer.ts index d58d7f0eb..3934ce9d8 100644 --- a/apps/web/hooks/use-chat-composer.ts +++ b/apps/web/hooks/use-chat-composer.ts @@ -13,7 +13,7 @@ import { import { useQueryClient } from '@tanstack/react-query' import { useLocale, useTranslations } from 'next-intl' import { useRouter } from 'next/navigation' -import { goalKeys, habitKeys, profileKeys } from '@orbit/shared/query' +import { goalKeys, habitKeys, profileKeys, tagKeys } from '@orbit/shared/query' import type { Profile } from '@orbit/shared/types/profile' import type { AgentExecuteOperationResponse, @@ -213,6 +213,7 @@ export function useChatComposer() { pendingOperations: result.data.pendingOperations, policyDenials: result.data.policyDenials, correlationId: result.data.correlationId, + relatedSurfaces: result.data.relatedSurfaces, timestamp: new Date(), }) @@ -240,6 +241,9 @@ export function useChatComposer() { if (invalidations.goals) { queryClient.invalidateQueries({ queryKey: goalKeys.lists() }) } + if (invalidations.tags) { + queryClient.invalidateQueries({ queryKey: tagKeys.lists() }) + } if (result.data.operations?.some((operation) => operation.status === 'Succeeded')) { await invalidateAgentQueries(queryClient) diff --git a/package-lock.json b/package-lock.json index 5ab6690ec..4c0ac0e2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -82,6 +82,7 @@ "react-native-draggable-flatlist": "^4.0.3", "react-native-gesture-handler": "~2.30.0", "react-native-google-mobile-ads": "^16.3.2", + "react-native-marked": "^8.1.0", "react-native-reanimated": "4.2.1", "react-native-safe-area-context": "~5.6.2", "react-native-screens": "~4.23.0", @@ -4051,6 +4052,23 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsamr/counter-style": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@jsamr/counter-style/-/counter-style-2.0.2.tgz", + "integrity": "sha512-2mXudGVtSzVxWEA7B9jZLKjoXUeUFYDDtFrQoC0IFX9/Dszz4t1vZOmafi3JSw/FxD+udMQ+4TAFR8Qs0J3URQ==", + "license": "MIT" + }, + "node_modules/@jsamr/react-native-li": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@jsamr/react-native-li/-/react-native-li-2.3.1.tgz", + "integrity": "sha512-Qbo4NEj48SQ4k8FZJHFE2fgZDKTWaUGmVxcIQh3msg5JezLdTMMHuRRDYctfdHI6L0FZGObmEv3haWbIvmol8w==", + "license": "MIT", + "peerDependencies": { + "@jsamr/counter-style": "^1.0.0 || ^2.0.0", + "react": "*", + "react-native": "*" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -11885,6 +11903,12 @@ "node": ">=6" } }, + "node_modules/github-slugger": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", + "license": "ISC" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -12124,6 +12148,22 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -16080,6 +16120,41 @@ "react-native": "*" } }, + "node_modules/react-native-marked": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/react-native-marked/-/react-native-marked-8.1.0.tgz", + "integrity": "sha512-nNsA0YZ73EvlZzSODms253gnZBYqxr4j3Qqf38NYAzmdVxULZMHB7qmt8yiYwtdEZ2IsTDxexlHckyhwLfjTlg==", + "license": "MIT", + "dependencies": { + "@jsamr/counter-style": "2.0.2", + "@jsamr/react-native-li": "2.3.1", + "github-slugger": "2.0.0", + "html-entities": "2.6.0", + "marked": "18.0.3", + "react-native-reanimated-table": "0.0.2", + "svg-parser": "2.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": ">=16.8.6", + "react-native": ">=0.76.0", + "react-native-svg": ">=12.3.0" + } + }, + "node_modules/react-native-marked/node_modules/marked": { + "version": "18.0.3", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz", + "integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/react-native-reanimated": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz", @@ -16095,6 +16170,16 @@ "react-native-worklets": ">=0.7.0" } }, + "node_modules/react-native-reanimated-table": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/react-native-reanimated-table/-/react-native-reanimated-table-0.0.2.tgz", + "integrity": "sha512-OeuqfU1AFEmHNTJlEOLWrV78JgAXnM0/ZrCm0Ab+9e5nwYJ+xab/UFXkNKz3Gyf08ZfLSNzwMQRjt3eZWPWoGA==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-native": ">=0.6.0" + } + }, "node_modules/react-native-reanimated/node_modules/react-native-is-edge-to-edge": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz", @@ -17725,6 +17810,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "license": "MIT" + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", diff --git a/packages/shared/src/__tests__/chat-composer-core.test.ts b/packages/shared/src/__tests__/chat-composer-core.test.ts index 48c38dcd9..b0a2e4203 100644 --- a/packages/shared/src/__tests__/chat-composer-core.test.ts +++ b/packages/shared/src/__tests__/chat-composer-core.test.ts @@ -123,6 +123,7 @@ describe('selectActionInvalidations', () => { expect(selectActionInvalidations([makeAction({ type: 'CreateHabit' })])).toEqual({ habits: true, goals: false, + tags: false, }) }) @@ -130,6 +131,7 @@ describe('selectActionInvalidations', () => { expect(selectActionInvalidations([makeAction({ type: 'CreateGoal' })])).toEqual({ habits: false, goals: true, + tags: false, }) }) @@ -139,18 +141,46 @@ describe('selectActionInvalidations', () => { makeAction({ type: 'CreateHabit' }), makeAction({ type: 'UpdateGoal' }), ]), - ).toEqual({ habits: true, goals: true }) + ).toEqual({ habits: true, goals: true, tags: false }) + }) + + it('flags tags and habits when a successful tag action is present', () => { + expect(selectActionInvalidations([makeAction({ type: 'CreateTag' })])).toEqual({ + habits: true, + goals: false, + tags: true, + }) + }) + + it('flags habits when a habit reorder succeeds', () => { + expect(selectActionInvalidations([makeAction({ type: 'ReorderHabits' })])).toEqual({ + habits: true, + goals: false, + tags: false, + }) + }) + + it('flags goals when a goal reorder succeeds', () => { + expect(selectActionInvalidations([makeAction({ type: 'ReorderGoals' })])).toEqual({ + habits: false, + goals: true, + tags: false, + }) }) it('flags nothing when no action succeeded', () => { expect( selectActionInvalidations([makeAction({ type: 'CreateHabit', status: 'Failed' })]), - ).toEqual({ habits: false, goals: false }) + ).toEqual({ habits: false, goals: false, tags: false }) }) it('flags nothing for an empty or undefined action list', () => { - expect(selectActionInvalidations(undefined)).toEqual({ habits: false, goals: false }) - expect(selectActionInvalidations([])).toEqual({ habits: false, goals: false }) + expect(selectActionInvalidations(undefined)).toEqual({ + habits: false, + goals: false, + tags: false, + }) + expect(selectActionInvalidations([])).toEqual({ habits: false, goals: false, tags: false }) }) }) diff --git a/packages/shared/src/__tests__/related-surfaces.test.ts b/packages/shared/src/__tests__/related-surfaces.test.ts new file mode 100644 index 000000000..98119bacd --- /dev/null +++ b/packages/shared/src/__tests__/related-surfaces.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + getRelatedSurfaces, + RELATED_SURFACE_ROUTES, +} from '../chat/related-surfaces' + +// The surface IDs the orbit-api feature-explanation bundle can emit today. +// Kept in sync manually: the bundle lives in orbit-api, so this list guards the +// shared map against drift (see plan Risk 6). +const FEATURE_FILE_SURFACE_IDS = [ + 'today', + 'gamification', + 'notifications', + 'subscriptions', + 'ai-settings', +] as const + +describe('getRelatedSurfaces', () => { + it('resolves known surface IDs to their mapped entries in order', () => { + const result = getRelatedSurfaces(['gamification', 'today']) + expect(result.map((surface) => surface.id)).toEqual(['gamification', 'today']) + expect(result[0]?.webRoute).toBe('/achievements') + expect(result[1]?.mobileRoute).toBe('/') + }) + + it('drops unknown surface IDs', () => { + const result = getRelatedSurfaces(['today', 'mystery-surface']) + expect(result.map((surface) => surface.id)).toEqual(['today']) + }) + + it('drops duplicate surface IDs, keeping first occurrence', () => { + const result = getRelatedSurfaces(['today', 'today']) + expect(result).toHaveLength(1) + }) + + it('returns an empty array for null, undefined, or empty input', () => { + expect(getRelatedSurfaces(null)).toEqual([]) + expect(getRelatedSurfaces(undefined)).toEqual([]) + expect(getRelatedSurfaces([])).toEqual([]) + }) + + it('maps every surface ID the feature bundle can emit', () => { + for (const id of FEATURE_FILE_SURFACE_IDS) { + expect(RELATED_SURFACE_ROUTES[id]).toBeDefined() + expect(RELATED_SURFACE_ROUTES[id]?.labelKey).toMatch(/^chat\.related\.surface\./) + } + }) +}) diff --git a/packages/shared/src/__tests__/types.test.ts b/packages/shared/src/__tests__/types.test.ts index d7f006931..d9357ea1b 100644 --- a/packages/shared/src/__tests__/types.test.ts +++ b/packages/shared/src/__tests__/types.test.ts @@ -785,6 +785,20 @@ describe('chat schemas', () => { }) expect(result.success).toBe(true) }) + + it('accepts action types beyond the canonical enum', () => { + const result = actionResultSchema.safeParse({ + type: 'ReorderHabits', + status: 'Success', + entityId: null, + entityName: null, + error: null, + field: null, + suggestedSubHabits: null, + conflictWarning: null, + }) + expect(result.success).toBe(true) + }) }) describe('chatMessageSchema', () => { @@ -861,6 +875,17 @@ describe('chat schemas', () => { }) expect(result.success).toBe(false) }) + + it('parses an ai message with relatedSurfaces', () => { + const result = chatMessageSchema.safeParse({ + id: 'msg-6', + role: 'ai', + content: 'Streaks work like this.', + relatedSurfaces: ['gamification', 'today'], + timestamp: new Date(), + }) + expect(result.success).toBe(true) + }) }) describe('chatResponseSchema', () => { @@ -920,6 +945,15 @@ describe('chat schemas', () => { }) expect(result.success).toBe(false) }) + + it('parses a response with relatedSurfaces', () => { + const result = chatResponseSchema.safeParse({ + aiMessage: 'Streaks work like this.', + actions: [], + relatedSurfaces: ['gamification', 'today'], + }) + expect(result.success).toBe(true) + }) }) }) diff --git a/packages/shared/src/chat/index.ts b/packages/shared/src/chat/index.ts index 7443b9905..f0682717c 100644 --- a/packages/shared/src/chat/index.ts +++ b/packages/shared/src/chat/index.ts @@ -1,3 +1,5 @@ +export * from './related-surfaces' + export const CHAT_SPEECH_LANG_KEY = 'orbit:speech-lang' export const CHAT_SPEECH_LANGUAGES = [ diff --git a/packages/shared/src/chat/related-surfaces.ts b/packages/shared/src/chat/related-surfaces.ts new file mode 100644 index 000000000..3662770ae --- /dev/null +++ b/packages/shared/src/chat/related-surfaces.ts @@ -0,0 +1,75 @@ +/** + * Maps the app-surface IDs the assistant emits in a describe_feature reply + * (`ChatResponse.relatedSurfaces`) to their i18n label key and per-platform + * client route. Both web and mobile consume this map; only the route field they + * read differs (platform adapter). IDs not present here are dropped — an unknown + * surface renders no link rather than a broken one. + */ +export interface RelatedSurface { + id: string + labelKey: string + webRoute: string + mobileRoute: string +} + +/** + * The five surface IDs the feature-explanation bundle in orbit-api can emit + * today (today, gamification, notifications, subscriptions, ai-settings). + * Notifications live on the Today header bell, so they share the Today route. + */ +export const RELATED_SURFACE_ROUTES: Readonly> = { + today: { + id: 'today', + labelKey: 'chat.related.surface.today', + webRoute: '/', + mobileRoute: '/', + }, + gamification: { + id: 'gamification', + labelKey: 'chat.related.surface.gamification', + webRoute: '/achievements', + mobileRoute: '/achievements', + }, + notifications: { + id: 'notifications', + labelKey: 'chat.related.surface.notifications', + webRoute: '/', + mobileRoute: '/', + }, + subscriptions: { + id: 'subscriptions', + labelKey: 'chat.related.surface.subscriptions', + webRoute: '/upgrade', + mobileRoute: '/upgrade', + }, + 'ai-settings': { + id: 'ai-settings', + labelKey: 'chat.related.surface.aiSettings', + webRoute: '/ai-settings', + mobileRoute: '/ai-settings', + }, +} + +/** + * Resolves a list of raw surface IDs to their mapped entries, preserving order + * and dropping unknown IDs and duplicates. Returns an empty array when nothing + * resolves so callers can skip rendering the footer entirely. + */ +export function getRelatedSurfaces( + ids: readonly string[] | null | undefined, +): RelatedSurface[] { + if (!ids?.length) return [] + + const resolved: RelatedSurface[] = [] + const seen = new Set() + + for (const id of ids) { + const surface = RELATED_SURFACE_ROUTES[id] + if (surface && !seen.has(id)) { + seen.add(id) + resolved.push(surface) + } + } + + return resolved +} diff --git a/packages/shared/src/hooks/chat-composer-core.ts b/packages/shared/src/hooks/chat-composer-core.ts index 76e876c55..8659123b3 100644 --- a/packages/shared/src/hooks/chat-composer-core.ts +++ b/packages/shared/src/hooks/chat-composer-core.ts @@ -43,6 +43,7 @@ const CHAT_HABIT_ACTION_TYPES: ReadonlySet = new Set([ 'DuplicateHabit', 'MoveHabit', 'SuggestBreakdown', + 'ReorderHabits', ]) export const CHAT_GOAL_ACTION_TYPES: ReadonlySet = new Set([ @@ -52,6 +53,15 @@ export const CHAT_GOAL_ACTION_TYPES: ReadonlySet = new Set([ 'UpdateGoalProgress', 'UpdateGoalStatus', 'LinkHabitsToGoal', + 'ReorderGoals', +]) + +// Tag mutations refresh the tag list and, because habits carry tag chips, the +// habit lists too (handled via CHAT_HABIT_ACTION_TYPES membership below). +const CHAT_TAG_ACTION_TYPES: ReadonlySet = new Set([ + 'CreateTag', + 'UpdateTag', + 'DeleteTag', ]) export const CHAT_DRAFT_STORAGE_KEY = 'orbit-chat-draft' @@ -145,21 +155,26 @@ export function findPremiumPolicyDenial( interface ActionInvalidations { habits: boolean goals: boolean + tags: boolean } /** * Determines which list caches to invalidate from a chat turn's successful - * actions. Returns `false` for both when there were no successful actions. + * actions. Returns `false` for all when there were no successful actions. Tag + * mutations also flag `habits` because habit rows render tag chips. */ export function selectActionInvalidations( actions: readonly ActionResult[] | undefined, ): ActionInvalidations { const hasSuccess = actions?.some((action) => action.status === 'Success') ?? false if (!hasSuccess) { - return { habits: false, goals: false } + return { habits: false, goals: false, tags: false } } + const tags = actions?.some((action) => CHAT_TAG_ACTION_TYPES.has(action.type)) ?? false return { - habits: actions?.some((action) => CHAT_HABIT_ACTION_TYPES.has(action.type)) ?? false, + habits: + tags || (actions?.some((action) => CHAT_HABIT_ACTION_TYPES.has(action.type)) ?? false), goals: actions?.some((action) => CHAT_GOAL_ACTION_TYPES.has(action.type)) ?? false, + tags, } } diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index d13314c2c..c1210dacb 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -655,8 +655,23 @@ "updatedGoalProgress": "Updated goal progress: {name}", "updatedGoalStatus": "Updated goal status: {name}", "linkedGoalHabits": "Linked habits to goal: {name}", + "createdTag": "Tag created: {name}", + "updatedTag": "Tag updated: {name}", + "deletedTag": "Tag deleted", + "reorderedGoals": "Goals reordered", + "reorderedHabits": "Habits reordered", "openEntity": "Open details: {name}" }, + "related": { + "title": "Related", + "surface": { + "today": "Today", + "gamification": "Achievements", + "notifications": "Notifications", + "subscriptions": "Upgrade", + "aiSettings": "AI settings" + } + }, "conflict": { "title": "Potential conflict" }, diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index bde43c3d8..bce0e4796 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -655,8 +655,23 @@ "updatedGoalProgress": "Progresso da meta atualizado: {name}", "updatedGoalStatus": "Status da meta atualizado: {name}", "linkedGoalHabits": "Hábitos vinculados à meta: {name}", + "createdTag": "Tag criada: {name}", + "updatedTag": "Tag atualizada: {name}", + "deletedTag": "Tag excluída", + "reorderedGoals": "Metas reordenadas", + "reorderedHabits": "Hábitos reordenados", "openEntity": "Abrir detalhes: {name}" }, + "related": { + "title": "Relacionado", + "surface": { + "today": "Hoje", + "gamification": "Conquistas", + "notifications": "Notificações", + "subscriptions": "Assinar", + "aiSettings": "Configurações de IA" + } + }, "conflict": { "title": "Potencial conflito" }, diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index 2063c74aa..8015f81c8 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -99,7 +99,10 @@ export type SuggestedSubHabit = z.infer export const actionResultSchema = z .object({ - type: aiActionTypeSchema, + // The backend emits PascalCase tool names beyond the 18-value aiActionTypeSchema + // enum (e.g. CreateTag, ReorderHabits), and the chat path renders these without + // ever calling .parse() — so a plain string keeps the type honest about the wire. + type: z.string(), status: actionStatusSchema, entityId: z.string().nullable(), entityName: z.string().nullable(), @@ -137,6 +140,9 @@ export const chatMessageSchema = z.object({ policyDenials: z.array(agentPolicyDenialSchema).optional(), imageUrl: z.string().nullable().optional(), correlationId: z.string().nullable().optional(), + // App surface IDs (e.g. "today", "gamification") the assistant linked to via a + // describe_feature reply; rendered as a deep-link footer when present. + relatedSurfaces: z.array(z.string()).optional(), timestamp: z.date(), }) @@ -149,6 +155,7 @@ export const chatResponseSchema = z.object({ pendingOperations: z.array(pendingAgentOperationSchema).optional(), policyDenials: z.array(agentPolicyDenialSchema).optional(), correlationId: z.string().nullable().optional(), + relatedSurfaces: z.array(z.string()).optional(), }) export type ChatResponse = z.infer