From c49c3610371a4600ec249674efbaaa681252e9c1 Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Sat, 29 Aug 2026 01:53:40 -0400 Subject: [PATCH 1/3] feat(mobile): refine Hermes response presentation - Hide internal messages and expose compacted conversation history - Add Markdown rendering, live activity feedback, and search navigation - Support display-history routing with legacy fallback --- apps/connect/internal/hermes/client.go | 10 + apps/connect/internal/hermes/client_test.go | 2 + .../mobile/scripts/chat-presentation.test.mjs | 130 ++++++++++ .../src/features/home/hermes-home-screen.tsx | 41 ++- .../features/threads/hermes-thread-screen.tsx | 245 ++++++++++++------ apps/mobile/src/lib/brio.test.mjs | 37 +++ apps/mobile/src/lib/brio.ts | 40 ++- apps/mobile/src/lib/chat-presentation.ts | 199 ++++++++++++++ 8 files changed, 612 insertions(+), 92 deletions(-) create mode 100644 apps/mobile/scripts/chat-presentation.test.mjs create mode 100644 apps/mobile/src/lib/chat-presentation.ts diff --git a/apps/connect/internal/hermes/client.go b/apps/connect/internal/hermes/client.go index b82e81b..86d4854 100644 --- a/apps/connect/internal/hermes/client.go +++ b/apps/connect/internal/hermes/client.go @@ -193,6 +193,12 @@ func RoutePath(path string) Route { return Route{Kind: RouteControlForward, Path: mapped} } } + if isSessionDisplayMessagesPath(path) { + return Route{ + Kind: RouteControlForward, + Path: strings.TrimSuffix(path, "/display-messages") + "/messages", + } + } if isSessionMessagesPath(path) || isSessionDetailPath(path) || isSessionModelPath(path) { return Route{Kind: RouteForward, Path: path} } @@ -259,6 +265,10 @@ func isSessionMessagesPath(path string) bool { return isSessionTailPath(path, "messages") } +func isSessionDisplayMessagesPath(path string) bool { + return isSessionTailPath(path, "display-messages") +} + func isSessionDetailPath(path string) bool { const prefix = "/api/sessions/" if !strings.HasPrefix(path, prefix) { diff --git a/apps/connect/internal/hermes/client_test.go b/apps/connect/internal/hermes/client_test.go index 371ee86..8934264 100644 --- a/apps/connect/internal/hermes/client_test.go +++ b/apps/connect/internal/hermes/client_test.go @@ -58,6 +58,7 @@ func TestRoutePath(t *testing.T) { {path: "/api/sessions/sess_1", kind: RouteForward, forwardTo: "/api/sessions/sess_1"}, {path: "/api/sessions/search", kind: RouteControlForward, forwardTo: "/api/sessions/search"}, {path: "/api/sessions/sess_1/messages", kind: RouteForward, forwardTo: "/api/sessions/sess_1/messages"}, + {path: "/api/sessions/sess_1/display-messages", kind: RouteControlForward, forwardTo: "/api/sessions/sess_1/messages"}, {path: "/api/sessions/sess_1/model", kind: RouteForward, forwardTo: "/api/sessions/sess_1/model"}, {path: "/api/model/options", kind: RouteForward, forwardTo: "/api/model/options"}, {path: "/files", kind: RouteControlForward, forwardTo: "/api/files"}, @@ -90,6 +91,7 @@ func TestRoutePath(t *testing.T) { {path: "/v1/sessions/sess_1", kind: RouteUnknown}, {path: "/v1/sessions/sess_1/messages/extra", kind: RouteUnknown}, {path: "/api/sessions/sess_1/messages/extra", kind: RouteUnknown}, + {path: "/api/sessions/sess_1/display-messages/extra", kind: RouteUnknown}, {path: "/api/sessions/sess_1/model/extra", kind: RouteUnknown}, {path: "/api/sessions/sess_1/models", kind: RouteUnknown}, {path: "/api/sessions/sess_1/", kind: RouteUnknown}, diff --git a/apps/mobile/scripts/chat-presentation.test.mjs b/apps/mobile/scripts/chat-presentation.test.mjs new file mode 100644 index 0000000..3c8aed8 --- /dev/null +++ b/apps/mobile/scripts/chat-presentation.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + cleanArchivedSearchSnippet, + hasMatchingUserMessage, + isSafeArchivedUserSnippet, + isVisibleConversationMessage, + runActivityLabel, + toVisibleConversationMessage, + toolActivityLabel, + upsertChatActivity, +} from '../src/lib/chat-presentation.ts'; + +test('chat history excludes Hermes internals and empty transcript records', () => { + const messages = [ + { role: 'system', content: 'private instructions' }, + { role: 'tool', content: 'raw command output' }, + { role: 'assistant', content: '' }, + { role: 'user', content: 'Build the screen' }, + { role: 'assistant', content: 'Done.' }, + ]; + + assert.deepEqual(messages.filter(isVisibleConversationMessage), [ + { role: 'user', content: 'Build the screen' }, + { role: 'assistant', content: 'Done.' }, + ]); +}); + +test('synthetic user-role runtime prompts never appear as messages from the person', () => { + const internalMessages = [ + { + role: 'user', + content: "You've reached the maximum number of tool-calling iterations allowed. Please provide a final response summarizing what you've found and accomplished so far, without calling any more tools.", + }, + { + role: 'user', + content: '[Your active task list was preserved across context compression]\n- Reload skills\n\n[Skills pruned during compression — reload before acting on these tasks]\nThe task list above crossed the compression boundary verbatim.', + }, + { + role: 'user', + content: '[System: Your previous response was truncated by the output length limit. Continue exactly where you left off.]', + }, + { role: 'user', content: '[IMPORTANT: Background process 123 completed]' }, + { role: 'user', content: 'internal event', display_kind: 'internal_notification' }, + { role: 'assistant', content: '(empty)' }, + { role: 'assistant', content: "Task Snapshot\nUser asked (deterministic, from compacted turns): 'Create a form'" }, + { role: 'assistant', content: 'I will inspect it.', tool_calls: [{ id: 'tool-1' }] }, + ]; + + assert.deepEqual(internalMessages.map(toVisibleConversationMessage), internalMessages.map(() => null)); +}); + +test('compaction carriers keep human-authored text while removing internal context', () => { + const todoCarrier = { + role: 'user', + content: 'Please finish the form.\n\n[Your active task list was preserved across context compression]\n- Internal task', + }; + const summaryCarrier = { + role: 'user', + content: '[CONTEXT COMPACTION — REFERENCE ONLY] internal summary\n--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---\nPlease use the blue theme.', + }; + const mergedCarrier = { + role: 'user', + content: '[PRIOR CONTEXT — for reference only; not a new message]\nMy earlier real request\n[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]\n[CONTEXT COMPACTION — REFERENCE ONLY] internal summary', + }; + + assert.equal(toVisibleConversationMessage(todoCarrier)?.content, 'Please finish the form.'); + assert.equal(toVisibleConversationMessage(summaryCarrier)?.content, 'Please use the blue theme.'); + assert.equal(toVisibleConversationMessage(mergedCarrier)?.content, 'My earlier real request'); +}); + +test('dashboard display projections take precedence over physical compaction content', () => { + const projected = toVisibleConversationMessage({ + role: 'user', + content: '[CONTEXT COMPACTION — REFERENCE ONLY] internal summary', + display_content: 'Please create the registration form.', + }); + assert.equal(projected?.content, 'Please create the registration form.'); +}); + +test('archived history search cleans FTS markup and rejects internal summaries', () => { + assert.equal( + cleanArchivedSearchSnippet('...crea un >>>formulario<<< de pagos...'), + '...crea un formulario de pagos...', + ); + assert.equal(isSafeArchivedUserSnippet('Quiero un formulario para ABBA'), true); + assert.equal(isSafeArchivedUserSnippet('[CONTEXT COMPACTION] formulario ABBA'), false); + assert.equal(isSafeArchivedUserSnippet('[tool: skill_view] ## Active State'), false); + assert.equal( + isSafeArchivedUserSnippet("...Task Snapshot User asked (deterministic, from compacted turns): 'Create a form'..."), + false, + ); +}); + +test('optimistic prompts deduplicate even after Hermes has appended a response', () => { + const messages = [ + { role: 'user', content: 'Check the project' }, + { role: 'assistant', content: 'Everything looks good.' }, + ]; + + assert.equal(hasMatchingUserMessage(messages, 'Check the project'), true); + assert.equal(hasMatchingUserMessage(messages, 'Another prompt'), false); +}); + +test('tool activity uses safe category labels instead of names or payloads', () => { + assert.equal(toolActivityLabel('terminal', false), 'Running a command'); + assert.equal(toolActivityLabel('terminal', true), 'Command finished'); + assert.equal(toolActivityLabel('web_search', false), 'Searching sources'); + assert.equal(toolActivityLabel('apply_patch', true), 'Files updated'); + assert.equal(toolActivityLabel('skill_loader', false), 'Preparing the task'); + assert.equal(toolActivityLabel('secret-internal-tool', false), 'Working on the next step'); +}); + +test('run labels never expose raw unknown event names', () => { + assert.equal(runActivityLabel('reasoning.delta'), 'Planning the next step'); + assert.equal(runActivityLabel('tool.start', { id: '1', label: 'Searching sources', status: 'running' }), 'Searching sources'); + assert.equal(runActivityLabel('private.skill.bootstrap'), 'Hermes is working'); +}); + +test('activity list updates in place and stays compact', () => { + let activity = upsertChatActivity([], { id: 'tool-1', label: 'Running a command', status: 'running' }); + activity = upsertChatActivity(activity, { id: 'tool-1', label: 'Command finished', status: 'complete' }); + assert.deepEqual(activity, [{ id: 'tool-1', label: 'Command finished', status: 'complete' }]); + + for (let index = 2; index <= 7; index += 1) { + activity = upsertChatActivity(activity, { id: `tool-${index}`, label: `Step ${index}`, status: 'complete' }); + } + assert.deepEqual(activity.map((item) => item.id), ['tool-4', 'tool-5', 'tool-6', 'tool-7']); +}); diff --git a/apps/mobile/src/features/home/hermes-home-screen.tsx b/apps/mobile/src/features/home/hermes-home-screen.tsx index 5b1cad3..f10f5f7 100644 --- a/apps/mobile/src/features/home/hermes-home-screen.tsx +++ b/apps/mobile/src/features/home/hermes-home-screen.tsx @@ -18,6 +18,7 @@ import { AppText, AppTextInput, EmptyState, StatusDot } from '@/components/t3-ui import { SPLIT_LAYOUT_MIN_WIDTH, T3Radius, T3Spacing, T3Typography } from '@/constants/t3-theme'; import { HermesThreadScreen } from '@/features/threads/hermes-thread-screen'; import { useT3Theme } from '@/hooks/use-t3-theme'; +import { cleanArchivedSearchSnippet, isSafeArchivedUserSnippet } from '@/lib/chat-presentation'; import { getHealth, listSessions, @@ -47,6 +48,7 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } const [settingsOpen, setSettingsOpen] = useState(false); const [search, setSearch] = useState(''); const [activeSessionId, setActiveSessionId] = useState('new'); + const [threadSearchQuery, setThreadSearchQuery] = useState(''); const [conversationEpoch, setConversationEpoch] = useState(0); const agentId = environmentId(connection); const storedProfiles = useProfileStore((state) => state.activeProfiles); @@ -114,6 +116,20 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } () => new Set((searchResults.data?.results ?? []).map((result) => result.session_id)), [searchResults.data?.results], ); + const searchHits = useMemo( + () => new Map( + (searchResults.data?.results ?? []) + .filter((result) => ( + (result.role === 'user' || result.role === 'assistant') + && isSafeArchivedUserSnippet(result.snippet) + )) + .map((result) => [ + result.session_id, + cleanArchivedSearchSnippet(result.snippet), + ]), + ), + [searchResults.data?.results], + ); const visibleSessions = useMemo(() => { const all = sessions.data?.sessions ?? []; if (!search.trim()) return all; @@ -147,10 +163,12 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } const openThread = (sessionId: string) => { setHistoryOpen(false); setActiveSessionId(sessionId); + setThreadSearchQuery(search.trim()); setConversationEpoch((current) => current + 1); }; const startNewChat = () => { setActiveSessionId('new'); + setThreadSearchQuery(''); setConversationEpoch((current) => current + 1); }; const openTool = (href: Href) => { @@ -203,6 +221,7 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } key={`${activeProfile}:${conversationEpoch}`} connection={connection} embedded + initialSearchQuery={threadSearchQuery} onSessionCreated={(sessionId) => setActiveSessionId(sessionId)} profile={activeProfile} routeSessionId={activeSessionId} @@ -278,7 +297,11 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } /> } renderItem={({ item }) => ( - openThread(item.id)} /> + openThread(item.id)} + /> )} /> @@ -423,7 +446,15 @@ function MenuRow({ detail, label, onPress }: { detail: string; label: string; on ); } -function SessionRow({ session, onPress }: { session: HermesSession; onPress: () => void }) { +function SessionRow({ + onPress, + searchSnippet, + session, +}: { + onPress: () => void; + searchSnippet?: string; + session: HermesSession; +}) { const colors = useT3Theme(); const date = formatRelativeTime(session.started_at); const title = session.title?.trim() || 'Untitled conversation'; @@ -446,6 +477,11 @@ function SessionRow({ session, onPress }: { session: HermesSession; onPress: () {session.message_count} {session.message_count === 1 ? 'message' : 'messages'} {session.model ? ` · ${session.model}` : ''} + {searchSnippet ? ( + + {searchSnippet} + + ) : null} @@ -525,6 +561,7 @@ const styles = StyleSheet.create({ sessionTitle: { flex: 1, fontFamily: T3Typography.medium, fontSize: 16 }, sessionDate: { fontSize: 12, lineHeight: 16 }, sessionMeta: { fontSize: 13, lineHeight: 17 }, + sessionMatch: { fontSize: 13, lineHeight: 18, marginTop: 3 }, retry: { padding: T3Spacing.md }, settingsContent: { gap: T3Spacing.lg, diff --git a/apps/mobile/src/features/threads/hermes-thread-screen.tsx b/apps/mobile/src/features/threads/hermes-thread-screen.tsx index cbdb5d7..f8c0dd2 100644 --- a/apps/mobile/src/features/threads/hermes-thread-screen.tsx +++ b/apps/mobile/src/features/threads/hermes-thread-screen.tsx @@ -1,7 +1,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'expo-router'; -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import Markdown from 'react-native-markdown-display'; import { + ActivityIndicator, FlatList, Keyboard, KeyboardAvoidingView, @@ -48,6 +50,14 @@ import { type HermesGatewayState, } from '@/lib/hermes-gateway'; import { isNamedProfile } from '@/lib/profiles'; +import { + hasMatchingUserMessage, + runActivityLabel, + toVisibleConversationMessage, + toolActivityLabel, + upsertChatActivity, + type ChatActivity, +} from '@/lib/chat-presentation'; import { buildRuntimeModelOptions, modelIncompatibilities, @@ -112,6 +122,7 @@ export function HermesThreadScreen({ connection, embedded = false, initialModelOverride, + initialSearchQuery = '', onSessionCreated, profile, routeSessionId, @@ -119,6 +130,7 @@ export function HermesThreadScreen({ connection: AgentConnection; embedded?: boolean; initialModelOverride?: ChatModelOverride; + initialSearchQuery?: string; onSessionCreated?: (sessionId: string) => void; profile: string; routeSessionId: string; @@ -128,6 +140,8 @@ export function HermesThreadScreen({ const router = useRouter(); const queryClient = useQueryClient(); const listRef = useRef>(null); + const stickToBottomRef = useRef(!initialSearchQuery.trim()); + const handledSearchRef = useRef(''); const composerKey = `${connection.id}:${profile}:${routeSessionId}`; const [generatedSessionId] = useState(createDraftSessionId); const persistedDraftSessionId = useComposerStore((state) => state.sessionIds[composerKey]); @@ -145,7 +159,7 @@ export function HermesThreadScreen({ const gatewaySessionRef = useRef<{ runtime: string; stored: string } | null>(null); const [gatewayState, setGatewayState] = useState('connecting'); const [gatewayRun, setGatewayRun] = useState(null); - const [gatewayActivity, setGatewayActivity] = useState([]); + const [gatewayActivity, setGatewayActivity] = useState([]); const [gatewayApproval, setGatewayApproval] = useState(null); const [gatewayInput, setGatewayInput] = useState(null); const [keyboardInset, setKeyboardInset] = useState(0); @@ -213,6 +227,12 @@ export function HermesThreadScreen({ queryFn: () => getSessionMessages(connection, sessionId, profile), enabled: routeSessionId !== 'new' || Boolean(runId), }); + const visibleStoredMessages = useMemo( + () => (messages.data?.messages ?? []) + .map(toVisibleConversationMessage) + .filter((message): message is HermesMessage => message !== null), + [messages.data?.messages], + ); const modelOptions = useQuery({ queryKey: ['model-options', connection.id, connection.url, profile], queryFn: () => getModelOptions(connection, false, profile), @@ -434,37 +454,25 @@ export function HermesThreadScreen({ normalizeLiveUsage(event.payload?.usage ?? event.payload), ); } else if (event.type === 'reasoning.delta' || event.type === 'thinking.delta') { - const delta = gatewayPayloadText(event.payload); - if (delta) { - setGatewayActivity((current) => upsertGatewayActivity(current, { - id: 'gateway-reasoning', - role: 'tool', - content: `${current.find((item) => item.id === 'gateway-reasoning')?.content ?? ''}${delta}`, - tool_name: 'Reasoning', - timestamp: now, - })); - } + setGatewayActivity((current) => upsertChatActivity(current, { + id: 'gateway-reasoning', + label: 'Planning the next step', + status: 'running', + })); } else if (event.type === 'reasoning.available') { - const text = gatewayPayloadText(event.payload); - if (text) { - setGatewayActivity((current) => upsertGatewayActivity(current, { - id: 'gateway-reasoning', - role: 'tool', - content: text, - tool_name: 'Reasoning', - timestamp: now, - })); - } + setGatewayActivity((current) => upsertChatActivity(current, { + id: 'gateway-reasoning', + label: 'Planning complete', + status: 'complete', + })); } else if (event.type === 'tool.start' || event.type === 'tool.complete') { const toolID = String(event.payload?.tool_id ?? event.seq ?? 'current'); const toolName = String(event.payload?.name ?? 'Tool'); - const content = gatewayToolText(event.payload, event.type === 'tool.complete'); - setGatewayActivity((current) => upsertGatewayActivity(current, { + const complete = event.type === 'tool.complete'; + setGatewayActivity((current) => upsertChatActivity(current, { id: `gateway-tool-${toolID}`, - role: 'tool', - content, - tool_name: toolName, - timestamp: now, + label: toolActivityLabel(toolName, complete), + status: complete ? 'complete' : 'running', })); } else if (event.type === 'error') { setGatewayActivity([]); @@ -866,12 +874,12 @@ export function HermesThreadScreen({ }, }); - const feed: FeedItem[] = (messages.data?.messages ?? []).map((message, index) => ({ - ...message, - id: `stored-${index}-${message.timestamp}`, - })); - const latestStored = feed.length > 0 ? feed[feed.length - 1]?.content : undefined; - if (optimisticPrompt && latestStored !== optimisticPrompt.content) { + const feed: FeedItem[] = visibleStoredMessages + .map((message, index) => ({ + ...message, + id: `stored-${index}-${message.timestamp}`, + })); + if (optimisticPrompt && !hasMatchingUserMessage(feed, optimisticPrompt.content)) { feed.push({ id: 'optimistic-user', role: 'user', @@ -882,7 +890,6 @@ export function HermesThreadScreen({ immediateMessages.forEach((message) => { if (!feed.some((item) => item.content === message.content)) feed.push(message); }); - gatewayActivity.forEach((message) => feed.push(message)); if (currentRun?.output && !feed.some((message) => message.content === currentRun.output)) { feed.push({ id: 'current-output', @@ -891,6 +898,21 @@ export function HermesThreadScreen({ timestamp: currentRun.updated_at ?? 0, }); } + const normalizedInitialSearch = initialSearchQuery.trim().toLocaleLowerCase(); + useEffect(() => { + if (!normalizedInitialSearch || handledSearchRef.current === normalizedInitialSearch) return; + const index = feed.findIndex((message) => ( + message.role === 'user' + && message.content.toLocaleLowerCase().includes(normalizedInitialSearch) + )); + if (index < 0) return; + handledSearchRef.current = normalizedInitialSearch; + stickToBottomRef.current = false; + const timer = setTimeout(() => { + listRef.current?.scrollToIndex({ animated: false, index, viewPosition: 0.2 }); + }, 120); + return () => clearTimeout(timer); + }, [feed, normalizedInitialSearch]); const controlsThread: ChatThread = { id: sessionId, @@ -1007,8 +1029,40 @@ export function HermesThreadScreen({ title="What should Hermes work on?" /> } - onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: false })} - renderItem={({ item }) => } + onContentSizeChange={() => { + if (stickToBottomRef.current) listRef.current?.scrollToEnd({ animated: false }); + }} + onScroll={(event) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + stickToBottomRef.current = ( + contentOffset.y + layoutMeasurement.height >= contentSize.height - 96 + ); + }} + onScrollBeginDrag={() => { + stickToBottomRef.current = false; + }} + onScrollToIndexFailed={({ averageItemLength, index }) => { + listRef.current?.scrollToOffset({ + animated: false, + offset: Math.max(0, averageItemLength * index), + }); + }} + scrollEventThrottle={32} + ListFooterComponent={ + active && gatewayActivity.length > 0 + ? + : null + } + renderItem={({ item }) => ( + + )} /> )} @@ -1037,9 +1091,10 @@ export function HermesThreadScreen({ ? 'Reconnecting to Hermes' : gatewayState === 'synchronizing' ? 'Synchronizing missed events' - : currentRun?.last_event - ? humanizeEvent(currentRun.last_event) - : 'Hermes is working'} + : runActivityLabel( + typeof currentRun?.last_event === 'string' ? currentRun.last_event : undefined, + gatewayActivity.at(-1), + )} stop.mutate()}> Stop @@ -1220,10 +1275,9 @@ function QueueAction({ ); } -function MessageBubble({ message }: { message: HermesMessage }) { +function MessageBubble({ highlighted, message }: { highlighted: boolean; message: FeedItem }) { const colors = useT3Theme(); const user = message.role === 'user'; - const tool = Boolean(message.tool_name) || message.role === 'tool'; return ( - {message.tool_name ? ( - - {message.tool_name} - - ) : null} - - {message.content || (tool ? 'Tool completed' : '')} - + {user ? ( + <> + {highlighted ? ( + + Search match + + ) : null} + + {message.content} + + + ) : ( + {message.content} + )} + + + ); +} + +function LiveActivityCard({ items }: { items: ChatActivity[] }) { + const colors = useT3Theme(); + return ( + + + + Hermes is working + {items.map((item) => ( + + + {item.status === 'complete' ? '✓' : '•'} + + {item.label} + + ))} ); } @@ -1409,13 +1483,6 @@ function normalizeGatewayInput( return { kind: 'clarify', requestId, prompt: '', questions, questionIndex }; } -function humanizeEvent(event: string) { - return event - .replaceAll('.', ' ') - .replaceAll('_', ' ') - .replace(/^./, (letter) => letter.toUpperCase()); -} - function gatewayPayloadText(payload?: Record) { for (const key of ['text', 'message', 'summary', 'error']) { const value = payload?.[key]; @@ -1424,20 +1491,22 @@ function gatewayPayloadText(payload?: Record) { return ''; } -function gatewayToolText(payload: Record | undefined, complete: boolean) { - for (const key of ['summary', 'context', 'result_text', 'inline_diff']) { - const value = payload?.[key]; - if (typeof value === 'string' && value.trim()) return value; - } - return complete ? 'Completed' : 'Running…'; -} - -function upsertGatewayActivity(current: FeedItem[], next: FeedItem) { - const index = current.findIndex((item) => item.id === next.id); - if (index < 0) return [...current, next]; - const updated = [...current]; - updated[index] = next; - return updated; +function chatMarkdownStyles(colors: ReturnType) { + return { + body: { color: colors.foreground, fontFamily: T3Typography.regular, fontSize: 15, lineHeight: 23 }, + blockquote: { backgroundColor: colors.subtle, borderLeftColor: colors.primary, borderLeftWidth: 3, color: colors.secondary, paddingHorizontal: 12 }, + bullet_list: { marginBottom: 8 }, + code_block: { backgroundColor: colors.code, borderColor: colors.border, borderRadius: T3Radius.small, borderWidth: StyleSheet.hairlineWidth, color: colors.foreground, fontFamily: T3Typography.mono, padding: 12 }, + code_inline: { backgroundColor: colors.code, borderRadius: 4, color: colors.foreground, fontFamily: T3Typography.mono, paddingHorizontal: 3 }, + fence: { backgroundColor: colors.code, borderColor: colors.border, borderRadius: T3Radius.small, borderWidth: StyleSheet.hairlineWidth, color: colors.foreground, fontFamily: T3Typography.mono, padding: 12 }, + heading1: { color: colors.foreground, fontFamily: T3Typography.bold, fontSize: 22, lineHeight: 28, marginBottom: 8, marginTop: 6 }, + heading2: { color: colors.foreground, fontFamily: T3Typography.bold, fontSize: 19, lineHeight: 25, marginBottom: 7, marginTop: 6 }, + heading3: { color: colors.foreground, fontFamily: T3Typography.bold, fontSize: 17, lineHeight: 23, marginBottom: 6, marginTop: 6 }, + link: { color: colors.primary }, + list_item: { marginBottom: 3 }, + ordered_list: { marginBottom: 8 }, + paragraph: { marginBottom: 8 }, + }; } function displayPrompt(prompt: QueuedPrompt) { @@ -1478,8 +1547,22 @@ const styles = StyleSheet.create({ userMessageRow: { alignItems: 'flex-end' }, bubble: { borderRadius: T3Radius.medium, maxWidth: '88%', paddingHorizontal: 0, paddingVertical: 2 }, userBubble: { borderBottomRightRadius: 5, paddingHorizontal: 14, paddingVertical: 10 }, + searchMatchLabel: { fontFamily: T3Typography.bold, fontSize: 11, lineHeight: 15, opacity: 0.75 }, messageText: { fontSize: 15, lineHeight: 23 }, - toolName: { fontFamily: T3Typography.bold, fontSize: 11, lineHeight: 15, textTransform: 'uppercase' }, + activityCard: { + alignSelf: 'flex-start', + borderRadius: T3Radius.medium, + borderWidth: StyleSheet.hairlineWidth, + gap: T3Spacing.xs, + maxWidth: '88%', + paddingHorizontal: T3Spacing.md, + paddingVertical: T3Spacing.sm, + }, + activityHeader: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.sm, marginBottom: 2 }, + activityTitle: { fontFamily: T3Typography.bold, fontSize: 13, lineHeight: 18 }, + activityRow: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.sm }, + activityIcon: { fontFamily: T3Typography.bold, fontSize: 13, lineHeight: 18, width: 12 }, + activityLabel: { flex: 1, fontSize: 13, lineHeight: 18 }, composerShell: { paddingHorizontal: T3Spacing.lg, paddingVertical: 6 }, composer: { alignSelf: 'center', diff --git a/apps/mobile/src/lib/brio.test.mjs b/apps/mobile/src/lib/brio.test.mjs index b7f203c..1f0c37a 100644 --- a/apps/mobile/src/lib/brio.test.mjs +++ b/apps/mobile/src/lib/brio.test.mjs @@ -25,6 +25,7 @@ import { filterAgentsForControlSession, finalizeConnection, getHealth, + getSessionMessages, listSessions, listJobRuns, listJobs, @@ -49,6 +50,42 @@ test('normalizes current Hermes list envelopes without breaking legacy responses assert.deepEqual(normalizeMessageList({ messages }).messages, messages); }); +test('loads compacted display history and falls back for older connectors', async () => { + const originalFetch = globalThis.fetch; + const requestURLs = []; + globalThis.fetch = async (input) => { + const url = String(input); + requestURLs.push(url); + if (url.includes('/display-messages')) { + return new Response(JSON.stringify({ error: 'not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ data: [{ role: 'user', content: 'Recovered', timestamp: 1 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + try { + const result = await getSessionMessages({ + id: 'direct-1', + name: 'Hermes', + mode: 'self_hosted', + transport: 'direct', + status: 'online', + capabilities: {}, + url: 'http://127.0.0.1:8787', + token: 'secret', + }, 'session 1'); + assert.equal(result.messages[0].content, 'Recovered'); + assert.match(requestURLs[0], /session%201\/display-messages\?include_compacted=true&limit=500&order=latest$/); + assert.match(requestURLs[1], /session%201\/messages\?include_compacted=true&limit=500&order=latest$/); + } finally { + globalThis.fetch = originalFetch; + } +}); + test('keeps automation sessions out of chat history even when Hermes ignores source filters', async () => { const originalFetch = globalThis.fetch; let requestURL = ''; diff --git a/apps/mobile/src/lib/brio.ts b/apps/mobile/src/lib/brio.ts index 4bc82bd..af2a7fc 100644 --- a/apps/mobile/src/lib/brio.ts +++ b/apps/mobile/src/lib/brio.ts @@ -145,6 +145,9 @@ export type HermesSessionCreateResponse = { export type HermesMessage = { role: string; content: string; + display_content?: string; + display_kind?: string; + tool_calls?: unknown[]; tool_name?: string; timestamp: number; }; @@ -715,8 +718,15 @@ export function interruptComposerSession(connection: AgentConnection, sessionId: }); } -function parseSessionCursor(cursor?: string) { - if (!cursor) return { offset: 0, lastId: undefined as string | undefined }; +type SessionCursor = { + offset: number; + strategy: 'raw' | 'window'; + firstId?: string; + lastId?: string; +}; + +function parseSessionCursor(cursor?: string): SessionCursor { + if (!cursor) return { offset: 0, strategy: 'raw' }; try { const parsed = JSON.parse(decodeURIComponent(cursor)) as { offset?: number; @@ -724,14 +734,14 @@ function parseSessionCursor(cursor?: string) { firstId?: string; lastId?: string; }; - if (Number.isSafeInteger(parsed.offset) && parsed.offset >= 0) { + if (typeof parsed.offset === 'number' && Number.isSafeInteger(parsed.offset) && parsed.offset >= 0) { return { offset: parsed.offset, strategy: parsed.strategy ?? 'raw', firstId: parsed.firstId, lastId: parsed.lastId }; } } catch { // Accept the numeric cursor used by early development builds. } const offset = Number(cursor); - return { offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0, strategy: 'raw' as const, firstId: undefined, lastId: undefined }; + return { offset: Number.isSafeInteger(offset) && offset >= 0 ? offset : 0, strategy: 'raw' }; } function makeSessionCursor(offset: number, lastId?: string, firstId?: string, strategy: 'raw' | 'window' = 'raw') { @@ -965,11 +975,23 @@ export function searchSessions(connection: AgentConnection, query: string, profi } export async function getSessionMessages(connection: AgentConnection, sessionId: string, profile?: string) { - const response = await brioFetch( - connection, - scopedPath(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, profile), - ); - return normalizeMessageList(response); + const encodedSessionId = encodeURIComponent(sessionId); + try { + const response = await brioFetch( + connection, + `${scopedPath(`/api/sessions/${encodedSessionId}/display-messages`, profile)}?include_compacted=true&limit=500&order=latest`, + ); + return normalizeMessageList(response); + } catch { + // Older connectors do not expose the display-history route. Keep their + // active transcript usable while the UI recovers archived hits through + // Hermes search. + const response = await brioFetch( + connection, + `${scopedPath(`/api/sessions/${encodedSessionId}/messages`, profile)}?include_compacted=true&limit=500&order=latest`, + ); + return normalizeMessageList(response); + } } export function startRun( diff --git a/apps/mobile/src/lib/chat-presentation.ts b/apps/mobile/src/lib/chat-presentation.ts new file mode 100644 index 0000000..9942f9a --- /dev/null +++ b/apps/mobile/src/lib/chat-presentation.ts @@ -0,0 +1,199 @@ +export type ConversationMessage = { + role: string; + content: string; + display_content?: string; + display_kind?: string; + tool_calls?: unknown[]; + tool_name?: string; +}; + +export type ChatActivityStatus = 'running' | 'complete'; + +export type ChatActivity = { + id: string; + label: string; + status: ChatActivityStatus; +}; + +const TODO_SNAPSHOT_HEADER = '[Your active task list was preserved across context compression]'; +const COMPACTION_PREFIX = '[CONTEXT COMPACTION — REFERENCE ONLY]'; +const LEGACY_COMPACTION_PREFIX = '[CONTEXT SUMMARY]:'; +const COMPACTION_END_MARKER = + '--- END OF CONTEXT SUMMARY — respond to the message below, not the summary above ---'; +const MERGED_CONTEXT_HEADER = '[PRIOR CONTEXT — for reference only; not a new message]'; +const MERGED_CONTEXT_DELIMITER = '[END OF PRIOR CONTEXT — COMPACTION SUMMARY BELOW]'; + +const INTERNAL_USER_MESSAGES = new Set([ + 'Continue from the compressed conversation context above. This marker exists because no human user turn was available.', + 'Continue from the compressed conversation context above. This marker exists because the compacted transcript contained no preserved user turn.', + "You've reached the maximum number of tool-calling iterations allowed. Please provide a final response summarizing what you've found and accomplished so far, without calling any more tools.", + '[System: Your previous response contained only internal reasoning and never produced a visible answer or tool call. Do not keep thinking. Produce your final answer as plain text now (or make the tool call you were planning).]', + '[System: Continue now. Execute the required tool calls and only send your final answer after completing the task.]', + 'Your previous turn indicated a tool call but none was included. Do not narrate a plan or restate intent — issue the actual tool call now to continue the task.', + 'You just executed tool calls but returned an empty response. Please process the tool results above and continue with the task.', +]); + +const INTERNAL_USER_PREFIXES = [ + "You've reached the maximum number of tool-calling iterations allowed.", + 'You have reached the maximum number of tool-calling iterations allowed.', + '[System: Your previous response was truncated', + '[System: The previous response was cut off', + '[System: Your previous tool call ', + '[System: The active model for this chat has changed to ', + '[System: You edited code in this turn, but the workspace does not have fresh passing verification evidence yet.', + '[System: You are a Hermes kanban worker.', + '[IMPORTANT: Background process ', + '[Skills pruned during compression — reload before acting on these tasks]', +]; + +function withoutSyntheticUserSuffix(content: string) { + const snapshotIndex = content.indexOf(TODO_SNAPSHOT_HEADER); + return snapshotIndex >= 0 ? content.slice(0, snapshotIndex).trim() : content.trim(); +} + +function contentOutsideCompaction(content: string) { + const trimmed = content.trim(); + if (trimmed.startsWith(MERGED_CONTEXT_HEADER)) { + const delimiterIndex = trimmed.indexOf(MERGED_CONTEXT_DELIMITER); + if (delimiterIndex >= 0) { + return trimmed.slice(MERGED_CONTEXT_HEADER.length, delimiterIndex).trim(); + } + } + if (trimmed.startsWith(COMPACTION_PREFIX) || trimmed.startsWith(LEGACY_COMPACTION_PREFIX)) { + const markerIndex = trimmed.indexOf(COMPACTION_END_MARKER); + return markerIndex >= 0 + ? trimmed.slice(markerIndex + COMPACTION_END_MARKER.length).trim() + : ''; + } + return trimmed; +} + +/** + * Hermes persists provider-alternation scaffolding under role="user" even + * though nobody typed it. Project the stored transcript to the human-facing + * conversation and retain real text if a compaction carrier contains both. + */ +export function toVisibleConversationMessage(message: T): T | null { + if (message.role !== 'user' && message.role !== 'assistant') return null; + if (message.display_kind?.trim()) return null; + if (message.role === 'assistant' && (message.tool_name || message.tool_calls?.length)) return null; + const projectedContent = typeof message.display_content === 'string' + ? message.display_content + : message.content; + if (typeof projectedContent !== 'string') return null; + + let content = contentOutsideCompaction(projectedContent); + if (message.role === 'user') { + content = withoutSyntheticUserSuffix(content); + if (!content || INTERNAL_USER_MESSAGES.has(content)) return null; + if (INTERNAL_USER_PREFIXES.some((prefix) => content.startsWith(prefix))) return null; + } else if (!content || content === '(empty)') { + return null; + } + if (/^(?:#+\s*)?Task Snapshot\b/i.test(content)) return null; + + return content === message.content ? message : { ...message, content }; +} + +export function cleanArchivedSearchSnippet(snippet: string) { + return snippet + .replace(/>>>|<< content.includes(marker)); +} + +export function isVisibleConversationMessage(message: ConversationMessage) { + return toVisibleConversationMessage(message) !== null; +} + +export function hasMatchingUserMessage(messages: ConversationMessage[], content: string) { + return messages.some( + (message) => message.role === 'user' && message.content === content, + ); +} + +export function toolActivityLabel(toolName: string, complete: boolean) { + const normalized = toolName.trim().toLowerCase(); + const labels = (running: string, done: string) => (complete ? done : running); + + if (/test|lint|typecheck|check|verify|validate/.test(normalized)) { + return labels('Checking the work', 'Checks finished'); + } + if (/write|edit|patch|create|delete|remove|move|rename|replace/.test(normalized)) { + return labels('Updating files', 'Files updated'); + } + if (/search|web|browser|fetch|crawl|http|exa|serper|firecrawl/.test(normalized)) { + return labels('Searching sources', 'Sources reviewed'); + } + if (/read|list|find|glob|grep|file/.test(normalized)) { + return labels('Reading project context', 'Project context reviewed'); + } + if (/terminal|shell|exec|command|bash|powershell/.test(normalized)) { + return labels('Running a command', 'Command finished'); + } + if (/gmail|email|mail/.test(normalized)) { + return labels('Working with email', 'Email step finished'); + } + if (/calendar|schedule/.test(normalized)) { + return labels('Checking the schedule', 'Schedule reviewed'); + } + if (/image|photo|vision/.test(normalized)) { + return labels('Working with an image', 'Image step finished'); + } + if (/skill|prompt|instruction/.test(normalized)) { + return labels('Preparing the task', 'Task prepared'); + } + if (/subagent|delegate|agent/.test(normalized)) { + return labels('Coordinating the work', 'Coordination finished'); + } + return labels('Working on the next step', 'Step finished'); +} + +export function runActivityLabel(event: string | null | undefined, latest?: ChatActivity) { + switch (event) { + case 'message.start': + return 'Starting the response'; + case 'message.delta': + return 'Writing the response'; + case 'reasoning.delta': + case 'reasoning.available': + case 'thinking.delta': + return 'Planning the next step'; + case 'tool.start': + case 'tool.complete': + return latest?.label ?? 'Working on the next step'; + case 'approval.request': + return 'Waiting for your approval'; + case 'clarify.request': + return 'Waiting for your answer'; + case 'sudo.request': + case 'secret.request': + return 'Waiting for a secure value'; + case 'session.usage': + return 'Updating progress'; + default: + return 'Hermes is working'; + } +} + +export function upsertChatActivity(current: ChatActivity[], next: ChatActivity, limit = 4) { + const withoutCurrent = current.filter((item) => item.id !== next.id); + return [...withoutCurrent, next].slice(-limit); +} From 1d629fee68d17e652fdc97fb332c18fb8ed0d7f8 Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Sat, 29 Aug 2026 02:21:19 -0400 Subject: [PATCH 2/3] fix(mobile): order Hermes history chronologically - Sort complete timestamped transcripts by conversation time - Simplify session metadata to show the model or Conversation --- .../mobile/src/features/home/hermes-home-screen.tsx | 5 ++--- apps/mobile/src/lib/brio.test.mjs | 13 +++++++++++++ apps/mobile/src/lib/brio.ts | 10 +++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/home/hermes-home-screen.tsx b/apps/mobile/src/features/home/hermes-home-screen.tsx index f10f5f7..013a664 100644 --- a/apps/mobile/src/features/home/hermes-home-screen.tsx +++ b/apps/mobile/src/features/home/hermes-home-screen.tsx @@ -473,9 +473,8 @@ function SessionRow({ {date} - - {session.message_count} {session.message_count === 1 ? 'message' : 'messages'} - {session.model ? ` · ${session.model}` : ''} + + {session.model || 'Conversation'} {searchSnippet ? ( diff --git a/apps/mobile/src/lib/brio.test.mjs b/apps/mobile/src/lib/brio.test.mjs index 1f0c37a..d68047b 100644 --- a/apps/mobile/src/lib/brio.test.mjs +++ b/apps/mobile/src/lib/brio.test.mjs @@ -50,6 +50,19 @@ test('normalizes current Hermes list envelopes without breaking legacy responses assert.deepEqual(normalizeMessageList({ messages }).messages, messages); }); +test('orders merged compacted history by its real conversation timestamps', () => { + const messages = [ + { role: 'assistant', content: 'Form created', timestamp: 20 }, + { role: 'user', content: 'Create the form', timestamp: 10 }, + { role: 'assistant', content: 'Later unrelated task', timestamp: 30 }, + ]; + + assert.deepEqual( + normalizeMessageList({ messages }).messages.map((message) => message.content), + ['Create the form', 'Form created', 'Later unrelated task'], + ); +}); + test('loads compacted display history and falls back for older connectors', async () => { const originalFetch = globalThis.fetch; const requestURLs = []; diff --git a/apps/mobile/src/lib/brio.ts b/apps/mobile/src/lib/brio.ts index af2a7fc..c3f59a2 100644 --- a/apps/mobile/src/lib/brio.ts +++ b/apps/mobile/src/lib/brio.ts @@ -184,7 +184,15 @@ export function normalizeSessionList(response: HermesSessionListEnvelope) { } export function normalizeMessageList(response: HermesMessageListEnvelope) { - return { ...response, messages: response.messages ?? response.data ?? [] }; + const messages = response.messages ?? response.data ?? []; + // Hermes' display-history endpoint can merge active and compacted rows in + // storage order instead of conversation order. Only reorder complete + // timestamped transcripts so legacy payloads without timestamps retain the + // exact order supplied by their server. + const orderedMessages = messages.every((message) => Number.isFinite(Number(message.timestamp))) + ? [...messages].sort((left, right) => Number(left.timestamp) - Number(right.timestamp)) + : messages; + return { ...response, messages: orderedMessages }; } type HermesControlFileEntry = Omit, 'size'> & { From 5a29e8977bf8fab93fce80d582aafca6afe4fd2f Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Sat, 29 Aug 2026 02:28:15 -0400 Subject: [PATCH 3/3] fix(mobile): clean up Hermes session row spacing --- apps/mobile/src/features/home/hermes-home-screen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/features/home/hermes-home-screen.tsx b/apps/mobile/src/features/home/hermes-home-screen.tsx index 013a664..109fc44 100644 --- a/apps/mobile/src/features/home/hermes-home-screen.tsx +++ b/apps/mobile/src/features/home/hermes-home-screen.tsx @@ -473,7 +473,7 @@ function SessionRow({ {date} - + {session.model || 'Conversation'} {searchSnippet ? (