diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index ef625f896..1d4b27285 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -1,6 +1,5 @@ import { convertFileSrc } from '@tauri-apps/api/core'; import { useEffect, useRef, useState } from 'react'; -import Markdown from 'react-markdown'; import { useNavigate } from 'react-router-dom'; import { type ChatSendError, chatSendError } from '../chat/chatSendError'; @@ -14,7 +13,6 @@ import { beginInferenceTurn, clearRuntimeForThread, setToolTimelineForThread, - type ToolTimelineEntry, } from '../store/chatRuntimeSlice'; import { useAppDispatch, useAppSelector } from '../store/hooks'; import { selectSocketStatus } from '../store/socketSelectors'; @@ -30,8 +28,7 @@ import { setSelectedThread, } from '../store/threadSlice'; import type { ThreadMessage } from '../types/thread'; -import { parseMarkdownTable, splitAgentMessageIntoBubbles } from '../utils/agentMessageBubbles'; -import { openUrl } from '../utils/openUrl'; +import { splitAgentMessageIntoBubbles } from '../utils/agentMessageBubbles'; import { isTauri, notifyOverlaySttState, @@ -42,6 +39,19 @@ import { openhumanVoiceTts, } from '../utils/tauriCommands'; import { formatTimelineEntry } from '../utils/toolTimelineFormatting'; +import { + AgentMessageBubble, + BubbleMarkdown, +} from './conversations/components/AgentMessageBubble'; +import { LimitPill } from './conversations/components/LimitPill'; +import { ToolTimelineBlock } from './conversations/components/ToolTimelineBlock'; +import { + type AgentBubblePosition, + buildAcceptedInlineCompletion, + formatRelativeTime, + formatResetTime, + getInlineCompletionSuffix, +} from './conversations/utils/format'; // Chat uses the reasoning model; `agentic-v1` is reserved for sub-agents // that execute tool calls, not the primary user-facing conversation. @@ -56,314 +66,6 @@ type ReplyMode = 'text' | 'voice'; const AUTOCOMPLETE_POLL_DEBOUNCE_MS = 320; const AUTOCOMPLETE_MIN_CONTEXT_CHARS = 3; -function formatRelativeTime(dateStr: string): string { - const now = Date.now(); - const then = new Date(dateStr).getTime(); - const diffMs = now - then; - if (diffMs < 60_000) return 'just now'; - const mins = Math.floor(diffMs / 60_000); - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - return `${days}d ago`; -} - -function getInlineCompletionSuffix(input: string, suggestion: string): string { - if (!input || !suggestion) return ''; - const normalize = (value: string) => - value - .replace(/\u2192/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - const normalizedInput = normalize(input); - const normalizedSuggestion = normalize(suggestion); - if (!normalizedSuggestion) return ''; - - // Full-text response: strip already-typed prefix. - if (normalizedSuggestion.startsWith(normalizedInput)) { - return normalizedSuggestion.slice(normalizedInput.length).trimStart(); - } - - // Remove overlap to prevent duplicate phrase insertion: - // "...want to" + "want to create..." => "create..." - const maxOverlap = Math.min(normalizedInput.length, normalizedSuggestion.length, 120); - for (let overlap = maxOverlap; overlap >= 1; overlap -= 1) { - if ( - normalizedInput.slice(normalizedInput.length - overlap) === - normalizedSuggestion.slice(0, overlap) - ) { - return normalizedSuggestion.slice(overlap).trimStart(); - } - } - - // Suffix-only fallback (the backend is intended to return suffix text). - if (normalizedInput.endsWith(normalizedSuggestion)) { - return ''; - } - return normalizedSuggestion; -} - -function buildAcceptedInlineCompletion(input: string, suffix: string): string { - const normalizedInput = input.replace(/\u2192/g, ' ').replace(/\t+/g, ' '); - const cleanSuffix = suffix - .replace(/\u2192/g, ' ') - .replace(/\t+/g, ' ') - .replace(/\s+/g, ' ') - .trim(); - - if (!cleanSuffix) return normalizedInput; - - const needsSpace = - normalizedInput.length > 0 && !/\s$/.test(normalizedInput) && !/^[,.;:!?)]/.test(cleanSuffix); - - return `${normalizedInput}${needsSpace ? ' ' : ''}${cleanSuffix}`; -} - -function isAllowedExternalHref(rawHref: string): boolean { - try { - const url = new URL(rawHref); - return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'; - } catch { - return false; - } -} - -type AgentBubblePosition = 'single' | 'first' | 'middle' | 'last'; - -function getAgentBubbleChrome(position: AgentBubblePosition): string { - if (position === 'single') return 'rounded-2xl rounded-bl-md'; - if (position === 'first') return 'rounded-2xl rounded-bl-lg'; - if (position === 'middle') return 'rounded-2xl rounded-tl-md rounded-bl-lg'; - return 'rounded-2xl rounded-tl-md rounded-bl-md'; -} - -function BubbleMarkdown({ content, tone = 'agent' }: { content: string; tone?: 'agent' | 'user' }) { - const proseTone = - tone === 'user' - ? 'prose-invert prose-p:text-white prose-li:text-white prose-a:text-white prose-code:text-white prose-strong:text-white prose-headings:text-white [&_li::marker]:text-white/85' - : 'prose-a:text-primary-500 prose-code:text-primary-700 prose-headings:text-sm [&_li::marker]:text-stone-700'; - - return ( -
- ( - { - e.preventDefault(); - if (!href || !isAllowedExternalHref(href)) return; - void openUrl(href).catch(() => { - // Ignore launcher errors from OS URL handler failures. - }); - }} - className="cursor-pointer underline"> - {children} - - ), - }}> - {content} - -
- ); -} - -function TableCellMarkdown({ content }: { content: string }) { - return ( -
- ( - { - e.preventDefault(); - if (!href || !isAllowedExternalHref(href)) return; - void openUrl(href).catch(() => { - // Ignore launcher errors from OS URL handler failures. - }); - }} - className="cursor-pointer underline"> - {children} - - ), - }}> - {content} - -
- ); -} - -function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] }) { - const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; - - const normalizeToolBody = (value?: string): string | undefined => { - if (!value) return undefined; - const trimmed = value.trim(); - if (trimmed.length === 0) return undefined; - if (trimmed === '{}' || trimmed === '[]' || trimmed === 'null') return undefined; - return value; - }; - - return ( -
- {entries.map(entry => { - const formatted = formatTimelineEntry(entry); - const detailContent = - normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer); - const shouldAutoExpand = latestRunningEntryId != null && latestRunningEntryId === entry.id; - const statusTone = - entry.status === 'running' - ? { - pill: 'bg-amber-100 text-amber-600', - bubble: 'bg-amber-50 text-amber-900', - code: 'text-amber-800', - chevron: 'text-amber-500', - } - : entry.status === 'success' - ? { - pill: 'bg-sage-100 text-sage-600', - bubble: 'bg-sage-50 text-sage-900', - code: 'text-sage-800', - chevron: 'text-sage-500', - } - : { - pill: 'bg-coral-100 text-coral-600', - bubble: 'bg-coral-50 text-coral-900', - code: 'text-coral-800', - chevron: 'text-coral-500', - }; - - return ( -
- {detailContent ? ( -
- - - ▶ - - {formatted.title} - - {entry.status} - - - {formatted.detail ? ( -
- {formatted.detail} -
- ) : ( -
-                    {detailContent}
-                  
- )} -
- ) : ( -
- {formatted.title} - - {entry.status} - -
- )} -
- ); - })} -
- ); -} - -function AgentMessageBubble({ - content, - position = 'single', -}: { - content: string; - position?: AgentBubblePosition; -}) { - const table = parseMarkdownTable(content); - const bubbleChrome = getAgentBubbleChrome(position); - - if (table) { - return ( -
-
- - - - {table.headers.map(header => ( - - ))} - - - - {table.rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - ))} - -
- {header} -
- -
-
-
- ); - } - - return ( -
- -
- ); -} - -function formatResetTime(isoStr: string): string { - const ms = new Date(isoStr).getTime() - Date.now(); - if (ms <= 0) return 'now'; - const mins = Math.ceil(ms / 60_000); - if (mins < 60) return `in ${mins}m`; - const hours = Math.floor(mins / 60); - const remMins = mins % 60; - if (hours < 24) return remMins > 0 ? `in ${hours}h ${remMins}m` : `in ${hours}h`; - const days = Math.floor(hours / 24); - const remHours = hours % 24; - return remHours > 0 ? `in ${days}d ${remHours}h` : `in ${days}d`; -} - -function LimitPill({ label, usedPct }: { label: string; usedPct: number }) { - const barColor = - usedPct >= 1 ? 'bg-coral-500' : usedPct >= 0.8 ? 'bg-amber-500' : 'bg-primary-500'; - return ( -
- {label} -
-
-
- {Math.round(usedPct * 100)}% -
- ); -} interface ConversationsProps { /** diff --git a/app/src/pages/conversations/components/AgentMessageBubble.tsx b/app/src/pages/conversations/components/AgentMessageBubble.tsx new file mode 100644 index 000000000..8cbc83762 --- /dev/null +++ b/app/src/pages/conversations/components/AgentMessageBubble.tsx @@ -0,0 +1,130 @@ +import Markdown from 'react-markdown'; + +import { parseMarkdownTable } from '../../../utils/agentMessageBubbles'; +import { openUrl } from '../../../utils/openUrl'; +import { + type AgentBubblePosition, + getAgentBubbleChrome, + isAllowedExternalHref, +} from '../utils/format'; + +export function BubbleMarkdown({ + content, + tone = 'agent', +}: { + content: string; + tone?: 'agent' | 'user'; +}) { + const proseTone = + tone === 'user' + ? 'prose-invert prose-p:text-white prose-li:text-white prose-a:text-white prose-code:text-white prose-strong:text-white prose-headings:text-white [&_li::marker]:text-white/85' + : 'prose-a:text-primary-500 prose-code:text-primary-700 prose-headings:text-sm [&_li::marker]:text-stone-700'; + + return ( +
+ ( + { + e.preventDefault(); + if (!href || !isAllowedExternalHref(href)) return; + void openUrl(href).catch(() => { + // Ignore launcher errors from OS URL handler failures. + }); + }} + className="cursor-pointer underline"> + {children} + + ), + }}> + {content} + +
+ ); +} + +export function TableCellMarkdown({ content }: { content: string }) { + return ( +
+ ( + { + e.preventDefault(); + if (!href || !isAllowedExternalHref(href)) return; + void openUrl(href).catch(() => { + // Ignore launcher errors from OS URL handler failures. + }); + }} + className="cursor-pointer underline"> + {children} + + ), + }}> + {content} + +
+ ); +} + +export function AgentMessageBubble({ + content, + position = 'single', +}: { + content: string; + position?: AgentBubblePosition; +}) { + const table = parseMarkdownTable(content); + const bubbleChrome = getAgentBubbleChrome(position); + + if (table) { + return ( +
+
+ + + + {table.headers.map(header => ( + + ))} + + + + {table.rows.map((row, rowIndex) => ( + + {row.map((cell, cellIndex) => ( + + ))} + + ))} + +
+ {header} +
+ +
+
+
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/app/src/pages/conversations/components/LimitPill.tsx b/app/src/pages/conversations/components/LimitPill.tsx new file mode 100644 index 000000000..73c3867a8 --- /dev/null +++ b/app/src/pages/conversations/components/LimitPill.tsx @@ -0,0 +1,16 @@ +export function LimitPill({ label, usedPct }: { label: string; usedPct: number }) { + const barColor = + usedPct >= 1 ? 'bg-coral-500' : usedPct >= 0.8 ? 'bg-amber-500' : 'bg-primary-500'; + return ( +
+ {label} +
+
+
+ {Math.round(usedPct * 100)}% +
+ ); +} diff --git a/app/src/pages/conversations/components/ToolTimelineBlock.tsx b/app/src/pages/conversations/components/ToolTimelineBlock.tsx new file mode 100644 index 000000000..78a2d9ec4 --- /dev/null +++ b/app/src/pages/conversations/components/ToolTimelineBlock.tsx @@ -0,0 +1,83 @@ +import type { ToolTimelineEntry } from '../../../store/chatRuntimeSlice'; +import { formatTimelineEntry } from '../../../utils/toolTimelineFormatting'; + +export function ToolTimelineBlock({ entries }: { entries: ToolTimelineEntry[] }) { + const latestRunningEntryId = [...entries].reverse().find(entry => entry.status === 'running')?.id; + + const normalizeToolBody = (value?: string): string | undefined => { + if (!value) return undefined; + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed === '{}' || trimmed === '[]' || trimmed === 'null') return undefined; + return value; + }; + + return ( +
+ {entries.map(entry => { + const formatted = formatTimelineEntry(entry); + const detailContent = + normalizeToolBody(formatted.detail) ?? normalizeToolBody(entry.argsBuffer); + const shouldAutoExpand = latestRunningEntryId != null && latestRunningEntryId === entry.id; + const statusTone = + entry.status === 'running' + ? { + pill: 'bg-amber-100 text-amber-600', + bubble: 'bg-amber-50 text-amber-900', + code: 'text-amber-800', + chevron: 'text-amber-500', + } + : entry.status === 'success' + ? { + pill: 'bg-sage-100 text-sage-600', + bubble: 'bg-sage-50 text-sage-900', + code: 'text-sage-800', + chevron: 'text-sage-500', + } + : { + pill: 'bg-coral-100 text-coral-600', + bubble: 'bg-coral-50 text-coral-900', + code: 'text-coral-800', + chevron: 'text-coral-500', + }; + + return ( +
+ {detailContent ? ( +
+ + + ▶ + + {formatted.title} + + {entry.status} + + + {formatted.detail ? ( +
+ {formatted.detail} +
+ ) : ( +
+                    {detailContent}
+                  
+ )} +
+ ) : ( +
+ {formatted.title} + + {entry.status} + +
+ )} +
+ ); + })} +
+ ); +} diff --git a/app/src/pages/conversations/utils/format.ts b/app/src/pages/conversations/utils/format.ts new file mode 100644 index 000000000..6d9c934e9 --- /dev/null +++ b/app/src/pages/conversations/utils/format.ts @@ -0,0 +1,91 @@ +export function formatRelativeTime(dateStr: string): string { + const now = Date.now(); + const then = new Date(dateStr).getTime(); + const diffMs = now - then; + if (diffMs < 60_000) return 'just now'; + const mins = Math.floor(diffMs / 60_000); + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +export function getInlineCompletionSuffix(input: string, suggestion: string): string { + if (!input || !suggestion) return ''; + const normalize = (value: string) => + value + .replace(/\u2192/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + const normalizedInput = normalize(input); + const normalizedSuggestion = normalize(suggestion); + if (!normalizedSuggestion) return ''; + + if (normalizedSuggestion.startsWith(normalizedInput)) { + return normalizedSuggestion.slice(normalizedInput.length).trimStart(); + } + + const maxOverlap = Math.min(normalizedInput.length, normalizedSuggestion.length, 120); + for (let overlap = maxOverlap; overlap >= 1; overlap -= 1) { + if ( + normalizedInput.slice(normalizedInput.length - overlap) === + normalizedSuggestion.slice(0, overlap) + ) { + return normalizedSuggestion.slice(overlap).trimStart(); + } + } + + if (normalizedInput.endsWith(normalizedSuggestion)) { + return ''; + } + return normalizedSuggestion; +} + +export function buildAcceptedInlineCompletion(input: string, suffix: string): string { + const normalizedInput = input.replace(/\u2192/g, ' ').replace(/\t+/g, ' '); + const cleanSuffix = suffix + .replace(/\u2192/g, ' ') + .replace(/\t+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!cleanSuffix) return normalizedInput; + + const needsSpace = + normalizedInput.length > 0 && !/\s$/.test(normalizedInput) && !/^[,.;:!?)]/.test(cleanSuffix); + + return `${normalizedInput}${needsSpace ? ' ' : ''}${cleanSuffix}`; +} + +export function isAllowedExternalHref(rawHref: string): boolean { + try { + const url = new URL(rawHref); + return url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'mailto:'; + } catch { + return false; + } +} + +export type AgentBubblePosition = 'single' | 'first' | 'middle' | 'last'; + +export function getAgentBubbleChrome(position: AgentBubblePosition): string { + if (position === 'single') return 'rounded-2xl rounded-bl-md'; + if (position === 'first') return 'rounded-2xl rounded-bl-lg'; + if (position === 'middle') return 'rounded-2xl rounded-tl-md rounded-bl-lg'; + return 'rounded-2xl rounded-tl-md rounded-bl-md'; +} + +export function formatResetTime(isoStr: string): string { + const ms = new Date(isoStr).getTime() - Date.now(); + if (ms <= 0) return 'now'; + const mins = Math.ceil(ms / 60_000); + if (mins < 60) return `in ${mins}m`; + const hours = Math.floor(mins / 60); + const remMins = mins % 60; + if (hours < 24) return remMins > 0 ? `in ${hours}h ${remMins}m` : `in ${hours}h`; + const days = Math.floor(hours / 24); + const remHours = hours % 24; + return remHours > 0 ? `in ${days}d ${remHours}h` : `in ${days}d`; +}