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 (
-
- );
-}
-
-function TableCellMarkdown({ content }: { content: string }) {
- return (
-
- );
-}
-
-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 => (
- |
- {header}
- |
- ))}
-
-
-
- {table.rows.map((row, rowIndex) => (
-
- {row.map((cell, cellIndex) => (
- |
-
- |
- ))}
-
- ))}
-
-
-
-
- );
- }
-
- 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 (
+
+ );
+}
+
+export function TableCellMarkdown({ content }: { content: string }) {
+ return (
+
+ );
+}
+
+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 => (
+ |
+ {header}
+ |
+ ))}
+
+
+
+ {table.rows.map((row, rowIndex) => (
+
+ {row.map((cell, cellIndex) => (
+ |
+
+ |
+ ))}
+
+ ))}
+
+
+
+
+ );
+ }
+
+ 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`;
+}