diff --git a/src/webui/features/context/hooks/use-navigate-to-context-path.ts b/src/webui/features/context/hooks/use-navigate-to-context-path.ts new file mode 100644 index 000000000..cb899b6db --- /dev/null +++ b/src/webui/features/context/hooks/use-navigate-to-context-path.ts @@ -0,0 +1,20 @@ +import {useNavigate} from 'react-router-dom' +import {toast} from 'sonner' + +import {useGetContextNodes} from '../api/get-context-nodes' +import {stalePathMessage} from '../utils/topic-viewer-navigation' +import {findNodeByPath} from '../utils/tree-utils' + +export function useNavigateToContextPath() { + const navigate = useNavigate() + const {data} = useGetContextNodes() + const nodes = data?.nodes ?? [] + + return (path: string) => { + if (findNodeByPath(nodes, path)) { + navigate(`/contexts?path=${encodeURIComponent(path)}`) + } else { + toast.error(stalePathMessage(path)) + } + } +} diff --git a/src/webui/features/context/hooks/use-topic-viewer-navigation.ts b/src/webui/features/context/hooks/use-topic-viewer-navigation.ts index 6728d5511..71feb9e50 100644 --- a/src/webui/features/context/hooks/use-topic-viewer-navigation.ts +++ b/src/webui/features/context/hooks/use-topic-viewer-navigation.ts @@ -1,7 +1,7 @@ import {useMemo} from 'react' import {toast} from 'sonner' -import {createTopicViewerNavigation} from '../utils/topic-viewer-navigation' +import {createTopicViewerNavigation, stalePathMessage} from '../utils/topic-viewer-navigation' import {findNodeByPath} from '../utils/tree-utils' import {useContextTree} from './use-context-tree' @@ -12,7 +12,7 @@ export function useTopicViewerNavigation() { () => createTopicViewerNavigation({ navigate: navigateToPath, - onStalePath: (path) => toast.error(`Path not found in context tree: ${path}`), + onStalePath: (path) => toast.error(stalePathMessage(path)), pathExists: (path) => findNodeByPath(nodes, path) !== undefined, }), [navigateToPath, nodes], diff --git a/src/webui/features/context/utils/topic-viewer-navigation.ts b/src/webui/features/context/utils/topic-viewer-navigation.ts index 179454dba..6532f932d 100644 --- a/src/webui/features/context/utils/topic-viewer-navigation.ts +++ b/src/webui/features/context/utils/topic-viewer-navigation.ts @@ -1,3 +1,5 @@ +export const stalePathMessage = (path: string): string => `Path not found in context tree: ${path}` + interface TopicViewerNavigationDeps { navigate: (path: string) => void onStalePath: (path: string) => void diff --git a/src/webui/features/tasks/components/query-results-list.tsx b/src/webui/features/tasks/components/query-results-list.tsx new file mode 100644 index 000000000..792cbdb53 --- /dev/null +++ b/src/webui/features/tasks/components/query-results-list.tsx @@ -0,0 +1,91 @@ +import {Badge} from '@campfirein/byterover-packages/components/badge' +import {Card} from '@campfirein/byterover-packages/components/card' +import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' +import {cn} from '@campfirein/byterover-packages/lib/utils' +import {ChevronDown, ChevronUp, FileText} from 'lucide-react' +import {type ComponentRef, useLayoutEffect, useRef, useState} from 'react' + +import type {QueryToolModeMatchedDoc} from '../utils/query-tool-mode-results' + +import {useNavigateToContextPath} from '../../context/hooks/use-navigate-to-context-path' +import {MarkdownInline} from './markdown-inline' +import {SectionLabel, TerminalDot} from './task-detail-shared' + +export function QueryResultsList({matchedDocs}: {matchedDocs: QueryToolModeMatchedDoc[]}) { + const label = `Result · ${matchedDocs.length} ${matchedDocs.length === 1 ? 'match' : 'matches'}` + const openPath = useNavigateToContextPath() + + return ( +
+ + {label} + {matchedDocs.length === 0 ? ( +

No matching documents.

+ ) : ( +
+ {matchedDocs.map((doc, index) => ( + + ))} +
+ )} +
+ ) +} + +function QueryResultRow({doc, onOpenPath}: {doc: QueryToolModeMatchedDoc; onOpenPath: (path: string) => void}) { + const body = typeof doc.rendered_md === 'string' ? doc.rendered_md : undefined + const bodyRef = useRef>(null) + const [expanded, setExpanded] = useState(false) + const [overflowing, setOverflowing] = useState(false) + + useLayoutEffect(() => { + const el = bodyRef.current + if (!el) return + setOverflowing(el.scrollHeight > el.clientHeight + 1) + }, [doc.rendered_md]) + + return ( + +
+
+ + {doc.title} +
+ + + {doc.score.toFixed(2)} + + } + /> + Match score + +
+ + {body && ( +
+
+ {body} +
+ {overflowing && ( + + )} +
+ )} +
+ ) +} diff --git a/src/webui/features/tasks/components/task-detail-event-log.tsx b/src/webui/features/tasks/components/task-detail-event-log.tsx deleted file mode 100644 index 9fffd152b..000000000 --- a/src/webui/features/tasks/components/task-detail-event-log.tsx +++ /dev/null @@ -1,261 +0,0 @@ -import {cn} from '@campfirein/byterover-packages/lib/utils' -import {type ReactNode, useEffect, useMemo, useRef} from 'react' - -import type {ReasoningContentItem, StoredTask} from '../types/stored-task' - -import {buildEventTimeline, type TimelineEvent} from '../utils/build-event-timeline' -import {formatTimeOfDay} from '../utils/format-time' -import {formatToolArgs} from '../utils/format-tool-args' -import {isActiveStatus, isTerminalStatus} from '../utils/task-status' -import {MarkdownInline} from './markdown-inline' -import {EventDot, type EventTone, RAIL_BG, SectionLabel} from './task-detail-shared' -import {ToolCallContent} from './task-detail-tool-call' - -const ACTIVE_VERB: Record = { - curate: 'Curating', - 'curate-folder': 'Curating', - query: 'Querying', -} - -function eventKey(event: TimelineEvent, index: number): string { - return `${event.kind}-${index}-${event.timestamp}` -} - -function eventTone(event: TimelineEvent): EventTone { - if (event.kind === 'reasoning') return 'muted' - if (event.call.status === 'error') return 'error' - if (event.call.status === 'running') return 'running' - return 'completed' -} - -export function EventLogSection({now, task}: {now: number; task: StoredTask}) { - const events = useMemo(() => buildEventTimeline(task), [task]) - const isActive = isActiveStatus(task.status) - const seenRef = useRef>(null) - const prevToneRef = useRef>(new Map()) - - // First render — seed with all current events so initial paint doesn't stagger. - // Only events that arrive *after* mount will animate in. - if (seenRef.current === null) { - seenRef.current = new Set(events.map((e, i) => eventKey(e, i))) - } - - const newKeys: string[] = [] - const flashKeys = new Set() - for (const [i, event] of events.entries()) { - const key = eventKey(event, i) - if (!seenRef.current.has(key)) newKeys.push(key) - const tone = eventTone(event) - const prev = prevToneRef.current.get(key) - if (prev === 'running' && (tone === 'completed' || tone === 'error')) { - flashKeys.add(key) - } - } - - useEffect(() => { - if (!seenRef.current) return - for (const [i, event] of events.entries()) { - const key = eventKey(event, i) - seenRef.current.add(key) - prevToneRef.current.set(key, eventTone(event)) - } - }) - - if (events.length === 0) { - return ( -
- Event log - {isActive ? ( -
    - -
- ) : ( -

No events captured for this task.

- )} -
- ) - } - - return ( -
- Event log -
    - {events.map((event, i) => { - const key = eventKey(event, i) - const newIndex = newKeys.indexOf(key) - const isLast = i === events.length - 1 - const hasResult = task.status === 'completed' && Boolean(task.result) - const hasError = task.status === 'error' && Boolean(task.error) - const hasTerminalSection = hasResult || hasError - // Rail extends past the last event when something follows below: - // active footer, Result section, or Error section. - const hasNext = !isLast || isActive || hasTerminalSection - const endTimestamp = events[i + 1]?.timestamp ?? task.completedAt ?? now - // Rail tone for the last event matches what's below it so the visual - // story continues without a color seam. - let railTone: EventTone = eventTone(event) - if (isLast) { - if (isActive) railTone = 'running' - else if (hasResult) railTone = 'completed' - else if (hasError) railTone = 'error' - } - - return ( - - ) - })} - {isActive && } -
-
- ) -} - -function ActiveFooterRow({taskType}: {taskType: string}) { - const verb = ACTIVE_VERB[taskType] ?? 'Working' - return ( -
  • - - - now - {verb}… -
  • - ) -} - -function EventRow({ - endTimestamp, - event, - flash, - hasNext, - isNew, - railTone, - staggerIndex, - taskId, - taskTerminal, - tooltip, -}: { - endTimestamp: number - event: TimelineEvent - flash: boolean - hasNext: boolean - isNew: boolean - railTone: EventTone - staggerIndex: number - taskId: string - taskTerminal: boolean - tooltip: ReactNode -}) { - return ( -
  • - {hasNext &&
    } - {event.kind === 'reasoning' ? ( - - ) : ( - - )} -
  • - ) -} - -function buildTooltip(event: TimelineEvent, endTimestamp: number): ReactNode { - const time = formatTimeOfDay(event.timestamp) - const duration = formatShortDuration(endTimestamp - event.timestamp) - if (event.kind === 'reasoning') { - return ( - - - {time} - +{duration} - - reasoning - - ) - } - - const summary = formatToolArgs(event.call).slice(0, 60) - return ( - - - {time} - +{duration} - - - {event.call.toolName} - {summary && ` · ${summary}`} - - - ) -} - -function formatShortDuration(ms: number): string { - if (ms < 1000) return `${Math.max(1, Math.round(ms))}ms` - const sec = ms / 1000 - if (sec < 60) return `${sec.toFixed(sec < 10 ? 1 : 0)}s` - return `${Math.floor(sec / 60)}m ${Math.round(sec % 60)}s` -} - -function ReasoningContent({ - endTimestamp, - flash, - hasNext, - item, - taskTerminal, - tooltip, -}: { - endTimestamp: number - flash: boolean - hasNext: boolean - item: ReasoningContentItem - taskTerminal: boolean - tooltip: ReactNode -}) { - const stillThinking = (item.isThinking || !item.content) && !taskTerminal && !hasNext - const thoughtFor = stillThinking ? undefined : formatThoughtDuration(endTimestamp - item.timestamp) - return ( - <> - -
    - reasoning - {stillThinking ? ( - - - thinking… - - ) : ( - thoughtFor && Thought for {thoughtFor} - )} -
    - {item.content && {item.content}} - - ) -} - -function formatThoughtDuration(ms: number): string { - const sec = Math.max(1, Math.round(ms / 1000)) - if (sec < 60) return `${sec}s` - const min = Math.floor(sec / 60) - const remSec = sec % 60 - return remSec > 0 ? `${min}m ${remSec}s` : `${min}m` -} diff --git a/src/webui/features/tasks/components/task-detail-header.tsx b/src/webui/features/tasks/components/task-detail-header.tsx index 85f043285..f355112cc 100644 --- a/src/webui/features/tasks/components/task-detail-header.tsx +++ b/src/webui/features/tasks/components/task-detail-header.tsx @@ -6,8 +6,8 @@ import {toast} from 'sonner' import type {StoredTask} from '../types/stored-task' -import {curateHtmlDirectRowTitle, isCurateHtmlDirectType} from '../utils/curate-tool-mode' import {formatDuration, formatRelative} from '../utils/format-time' +import {taskDisplayTitle} from '../utils/task-display-title' import {displayTaskType, isActiveStatus, isTerminalStatus} from '../utils/task-status' import {StatusPill} from './status-pill' import {elapsedMs, Separator} from './task-detail-shared' @@ -34,9 +34,7 @@ export function DetailHeader({cancelling, now, onCancel, task}: DetailHeaderProp const referenceTime = task.startedAt ?? task.createdAt const verb = STATUS_VERB[task.status] const elapsedLabel = isTerminal ? 'ran' : 'running' - // For curate-tool-mode the raw `content` is a JSON blob; decode it so the - // header shows the user's intent (CLI) or topic path (MCP) instead. - const displayTitle = isCurateHtmlDirectType(task.type) ? curateHtmlDirectRowTitle(task.content) : task.content + const displayTitle = taskDisplayTitle(task) return (
    diff --git a/src/webui/features/tasks/components/task-detail-sections.tsx b/src/webui/features/tasks/components/task-detail-sections.tsx index a033dcfac..21a65ae3b 100644 --- a/src/webui/features/tasks/components/task-detail-sections.tsx +++ b/src/webui/features/tasks/components/task-detail-sections.tsx @@ -13,10 +13,12 @@ import { } from '../utils/curate-tool-mode' import {shortTaskId} from '../utils/format-time' import {isBvTopicHtml} from '../utils/is-bv-topic-html' +import {isQueryToolModeType, parseQueryToolModeResult, queryToolModeRowTitle} from '../utils/query-tool-mode-results' import {isActiveStatus} from '../utils/task-status' import {AttachmentChip} from './attachment-chip' import {CurateHtmlDirectInputView, CurateHtmlDirectResultView} from './curate-tool-mode-sections' import {MarkdownInline} from './markdown-inline' +import {QueryResultsList} from './query-results-list' import {SectionLabel, TerminalDot} from './task-detail-shared' export function InputSection({task}: {task: StoredTask}) { @@ -25,6 +27,8 @@ export function InputSection({task}: {task: StoredTask}) { if (payload) return } + const queryInput = isQueryToolModeType(task.type) ? queryToolModeRowTitle(task.content) : undefined + const displayContent = queryInput ?? task.content const {folderPath} = task const files = task.files ?? [] const hasAttachments = Boolean(folderPath) || files.length > 0 @@ -32,7 +36,7 @@ export function InputSection({task}: {task: StoredTask}) {
    Input
    - {task.content || (empty)} + {displayContent || (empty)}
    {hasAttachments && (
    @@ -86,6 +90,11 @@ export function ResultSection({content, taskType}: {content: string; taskType?: if (payload) return } + if (taskType && isQueryToolModeType(taskType)) { + const docs = parseQueryToolModeResult(content) + if (docs) return + } + return (
    diff --git a/src/webui/features/tasks/components/task-detail-shared.tsx b/src/webui/features/tasks/components/task-detail-shared.tsx index 2128f355f..4ed7d9dc4 100644 --- a/src/webui/features/tasks/components/task-detail-shared.tsx +++ b/src/webui/features/tasks/components/task-detail-shared.tsx @@ -1,6 +1,5 @@ import type {ReactNode} from 'react' -import {Tooltip, TooltipContent, TooltipTrigger} from '@campfirein/byterover-packages/components/tooltip' import {cn} from '@campfirein/byterover-packages/lib/utils' import {Check, X} from 'lucide-react' @@ -24,48 +23,6 @@ export function SectionLabel({children, count}: {children: ReactNode; count?: nu ) } -export type EventTone = 'completed' | 'error' | 'muted' | 'running' - -const DOT_BG: Record = { - completed: 'bg-emerald-500', - error: 'bg-red-400', - muted: 'bg-muted-foreground/60', - running: 'bg-blue-400', -} - -export const RAIL_BG: Record = { - completed: 'bg-emerald-500/70', - error: 'bg-red-400/70', - muted: 'bg-muted-foreground/30', - running: 'rail-running', -} - -export function EventDot({flash, tone, tooltip}: {flash?: boolean; tone: EventTone; tooltip?: ReactNode}) { - const dot = ( - - - {tone === 'running' && ( - - )} - - ) - - if (!tooltip) return dot - - return ( - - - {tooltip} - - ) -} - export function TerminalDot({tone}: {tone: 'completed' | 'error'}) { const Icon = tone === 'completed' ? Check : X const bg = tone === 'completed' ? 'bg-emerald-500' : 'bg-red-400' diff --git a/src/webui/features/tasks/components/task-detail-tool-call.tsx b/src/webui/features/tasks/components/task-detail-tool-call.tsx deleted file mode 100644 index 8e46a0af2..000000000 --- a/src/webui/features/tasks/components/task-detail-tool-call.tsx +++ /dev/null @@ -1,345 +0,0 @@ -import {cn} from '@campfirein/byterover-packages/lib/utils' -import {ChevronDown, ChevronUp} from 'lucide-react' -import {Fragment, memo, ReactNode, useMemo, useState} from 'react' - -import type {ToolCallEvent} from '../types/stored-task' - -import {oneDark, SyntaxHighlighter} from '../../../lib/syntax-highlighter' -import {formatToolArgs} from '../utils/format-tool-args' -import {stripTaskIdSuffix} from '../utils/strip-task-id' -import {MarkdownInline} from './markdown-inline' -import {EventDot} from './task-detail-shared' - -type RowFormat = 'code' | 'keypath' | 'markdown' | 'plain' - -const TOOL_LABEL_TONE: Record = { - completed: 'text-emerald-500/80', - error: 'text-red-400/80', - running: 'text-blue-400/80', -} - -interface ToolLangs { - in: string - out: string -} - -/* eslint-disable camelcase */ -// Tool names match daemon registrations — see src/agent/core/domain/tools/constants.ts -// and the .txt files under src/agent/resources/tools/. -const TOOL_LANGUAGES: Record = { - bash_exec: {in: 'bash', out: 'bash'}, - bash_output: {in: 'bash', out: 'bash'}, - code_exec: {in: 'javascript', out: 'json'}, -} - -const TOOL_DISPLAY_NAME: Record = { - agentic_map: 'map', - bash_exec: 'bash', - bash_output: 'bash output', - code_exec: 'code exec', - create_knowledge_topic: 'new topic', - delete_memory: 'delete memory', - detect_domains: 'detect domains', - edit_file: 'edit', - edit_memory: 'edit memory', - expand_knowledge: 'expand', - glob_files: 'glob', - grep_content: 'grep', - ingest_resource: 'ingest', - kill_process: 'kill', - list_directory: 'list', - list_memories: 'memories', - llm_map: 'llm map', - read_file: 'read', - read_memory: 'read memory', - read_todos: 'todos', - search_history: 'history', - search_knowledge: 'search', - swarm_query: 'swarm query', - swarm_store: 'swarm store', - write_file: 'write', - write_memory: 'write memory', - write_todos: 'todos', -} -/* eslint-enable camelcase */ - -function getToolDisplayName(toolName: string): string { - return TOOL_DISPLAY_NAME[toolName] ?? toolName.replaceAll('_', ' ') -} - -const EXTENSION_LANGUAGE: Record = { - bash: 'bash', - c: 'c', - cjs: 'javascript', - cpp: 'cpp', - cs: 'csharp', - css: 'css', - go: 'go', - h: 'c', - hpp: 'cpp', - htm: 'html', - html: 'html', - java: 'java', - js: 'javascript', - json: 'json', - jsx: 'jsx', - kt: 'kotlin', - less: 'less', - md: 'markdown', - mdx: 'markdown', - mjs: 'javascript', - php: 'php', - py: 'python', - rb: 'ruby', - rs: 'rust', - scss: 'scss', - sh: 'bash', - sql: 'sql', - swift: 'swift', - toml: 'toml', - ts: 'typescript', - tsx: 'tsx', - xml: 'xml', - yaml: 'yaml', - yml: 'yaml', - zsh: 'bash', -} - -function inferFileLanguage(args: Record): string | undefined { - const path = - (typeof args.path === 'string' && args.path) || - (typeof args.file_path === 'string' && args.file_path) || - (typeof args.filePath === 'string' && args.filePath) - if (!path) return undefined - const dot = path.lastIndexOf('.') - if (dot === -1 || dot === path.length - 1) return undefined - const ext = path.slice(dot + 1).toLowerCase() - return EXTENSION_LANGUAGE[ext] -} - -const MEMORY_TOOLS = new Set(['delete_memory', 'edit_memory', 'list_memories', 'read_memory', 'write_memory']) -const PLAIN_OUT_TOOLS = new Set(['bash_output']) -const JSON_OUT_TOOLS = new Set([ - 'create_knowledge_topic', - 'curate', - 'detect_domains', - 'expand_knowledge', - 'ingest_resource', - 'list_directory', - 'list_memories', - 'read_todos', - 'search_history', - 'search_knowledge', - 'swarm_query', - 'swarm_store', - 'write_todos', -]) - -function getInFormat(toolName: string): {format: RowFormat; language?: string} { - if (toolName in TOOL_LANGUAGES) return {format: 'code', language: TOOL_LANGUAGES[toolName].in} - if (MEMORY_TOOLS.has(toolName)) return {format: 'keypath'} - return {format: 'markdown'} -} - -function getOutFormat(toolName: string, fileLang: string | undefined): {format: RowFormat; language?: string} { - if (PLAIN_OUT_TOOLS.has(toolName)) return {format: 'plain'} - if (fileLang) return {format: 'code', language: fileLang} - if (toolName in TOOL_LANGUAGES) return {format: 'code', language: TOOL_LANGUAGES[toolName].out} - if (JSON_OUT_TOOLS.has(toolName)) return {format: 'code', language: 'json'} - return {format: 'markdown'} -} - -const CodeBlock = memo(({content, language = 'bash'}: {content: string; language?: string}) => ( - - {content} - -)) -CodeBlock.displayName = 'CodeBlock' - -export function ToolCallContent({ - call, - flash, - taskId, - tooltip, -}: { - call: ToolCallEvent - flash: boolean - taskId: string - tooltip: ReactNode -}) { - const [expanded, setExpanded] = useState(false) - const argsText = useMemo(() => stripTaskIdSuffix(formatToolArgs(call), taskId), [call, taskId]) - const resultText = useMemo(() => stripTaskIdSuffix(formatResult(call), taskId), [call, taskId]) - const fileLang = useMemo(() => inferFileLanguage(call.args), [call.args]) - const inFormat = getInFormat(call.toolName) - const outFormat = getOutFormat(call.toolName, fileLang) - const hasResult = resultText.length > 0 - const isRunning = call.status === 'running' - - const toggle = () => setExpanded((prev) => !prev) - - return ( - <> - - -
    - - {getToolDisplayName(call.toolName)} - - {isRunning && running} -
    - - - - ) -} - -const IN_COLLAPSED_LINES = 1 -const OUT_COLLAPSED_LINES = 3 - -function IORow({ - collapsedHeight, - collapsedLines, - content, - empty, - expanded, - format, - label, - language, - placeholder, -}: { - collapsedHeight: string - collapsedLines: number - content: string - empty: boolean - expanded: boolean - format: RowFormat - label: 'in' | 'out' - language?: string - placeholder?: string -}) { - const overflowLines = useMemo(() => { - if (expanded) return 0 - return Math.max(0, content.split('\n').length - collapsedLines) - }, [content, collapsedLines, expanded]) - - const renderContent = () => { - switch (format) { - case 'code': { - return - } - - case 'keypath': { - return - } - - case 'plain': { - return - } - - default: { - return {content} - } - } - } - - return ( -
    - - {label} - {overflowLines > 0 && ( - +{overflowLines} - )} - -
    - {empty ? ( - {placeholder ?? '—'} - ) : expanded ? ( - renderContent() - ) : ( -
    {renderContent()}
    - )} -
    -
    - ) -} - -function PlainBlock({content}: {content: string}) { - return
    {content}
    -} - -function KeyPath({path}: {path: string}) { - const parts = path.split('/') - return ( - - {parts.map((part, i) => ( - - {i > 0 && /} - {part} - - ))} - - ) -} - -function formatResult(call: ToolCallEvent): string { - if (call.status === 'error' && call.error) return call.error - if (call.result === undefined || call.result === null) return '' - if (typeof call.result === 'string') { - const trimmed = call.result.trim() - return trimmed.length > 1200 ? `${trimmed.slice(0, 1200)}\n…` : trimmed - } - - try { - const json = JSON.stringify(call.result, null, 2) - return json.length > 1200 ? `${json.slice(0, 1200)}\n…` : json - } catch { - return String(call.result) - } -} diff --git a/src/webui/features/tasks/components/task-detail-view.tsx b/src/webui/features/tasks/components/task-detail-view.tsx index 0362262dc..0529226b9 100644 --- a/src/webui/features/tasks/components/task-detail-view.tsx +++ b/src/webui/features/tasks/components/task-detail-view.tsx @@ -8,7 +8,6 @@ import {useTickingNow} from '../hooks/use-ticking-now' import {useTaskById} from '../stores/task-store' import {taskHistoryEntryToStoredTask} from '../utils/task-history-entry-to-stored-task' import {isActiveStatus} from '../utils/task-status' -import {EventLogSection} from './task-detail-event-log' import {DetailHeader} from './task-detail-header' import {ErrorSection, InputSection, LiveStreamSection, NotFound, ResultSection} from './task-detail-sections' @@ -81,7 +80,6 @@ export function TaskDetailView({cancelling, onCancel, taskId}: TaskDetailViewPro ref={scrollRef} > - {showLive && } {result && } {error && } diff --git a/src/webui/features/tasks/components/task-list-table.tsx b/src/webui/features/tasks/components/task-list-table.tsx index 683dff5a7..c92bce617 100644 --- a/src/webui/features/tasks/components/task-list-table.tsx +++ b/src/webui/features/tasks/components/task-list-table.tsx @@ -15,11 +15,11 @@ import {CircleStop, LoaderCircle, Trash2} from 'lucide-react' import type {StatusFilter} from '../stores/task-store' import type {StoredTask} from '../types/stored-task' -import {curateHtmlDirectRowTitle, isCurateHtmlDirectType} from '../utils/curate-tool-mode' import {getCurrentActivity} from '../utils/current-activity' import {formatDuration, formatRelative, formatTimeOfDay, shortTaskId} from '../utils/format-time' import {isInterrupted} from '../utils/is-interrupted' import {rowActionKind} from '../utils/row-action-kind' +import {taskDisplayTitle} from '../utils/task-display-title' import {displayTaskType, isTerminalStatus} from '../utils/task-status' import {StatusPill} from './status-pill' import {NoMatchState} from './task-list-empty' @@ -141,9 +141,7 @@ function TaskRow({ const isRunning = !terminal const interrupted = isInterrupted(task) const activity = getCurrentActivity(task) - // For curate-tool-mode, task.content is a JSON blob — decode it so the - // row shows the user's intent (CLI) or topic path (MCP) instead. - const displayInput = isCurateHtmlDirectType(task.type) ? curateHtmlDirectRowTitle(task.content) : task.content + const displayInput = taskDisplayTitle(task) const actionKind = rowActionKind(task.status) const row = ( diff --git a/src/webui/features/tasks/utils/build-event-timeline.ts b/src/webui/features/tasks/utils/build-event-timeline.ts deleted file mode 100644 index 339946e9a..000000000 --- a/src/webui/features/tasks/utils/build-event-timeline.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type {ReasoningContentItem, StoredTask, ToolCallEvent} from '../types/stored-task' - -export type TimelineEvent = - | {call: ToolCallEvent; kind: 'toolCall'; timestamp: number} - | {item: ReasoningContentItem; kind: 'reasoning'; timestamp: number} - -export function buildEventTimeline(task: StoredTask): TimelineEvent[] { - const events: TimelineEvent[] = [] - for (const item of task.reasoningContents ?? []) { - events.push({item, kind: 'reasoning', timestamp: item.timestamp}) - } - - for (const call of task.toolCalls ?? []) { - events.push({call, kind: 'toolCall', timestamp: call.timestamp}) - } - - events.sort((a, b) => a.timestamp - b.timestamp) - return events -} diff --git a/src/webui/features/tasks/utils/curate-tool-mode.ts b/src/webui/features/tasks/utils/curate-tool-mode.ts index f0c4c698f..bd5c002c9 100644 --- a/src/webui/features/tasks/utils/curate-tool-mode.ts +++ b/src/webui/features/tasks/utils/curate-tool-mode.ts @@ -7,6 +7,8 @@ * structured view instead of dumping the raw JSON. */ +import {safeJsonParse} from './safe-json-parse' + export interface CurateHtmlDirectInputPayload { confirmOverwrite?: boolean html: string @@ -122,11 +124,3 @@ function isWriteError(value: unknown): value is CurateHtmlWriteError { const obj = value as Record return typeof obj.kind === 'string' && typeof obj.message === 'string' } - -function safeJsonParse(content: string): unknown { - try { - return JSON.parse(content) - } catch { - return undefined - } -} diff --git a/src/webui/features/tasks/utils/query-tool-mode-results.ts b/src/webui/features/tasks/utils/query-tool-mode-results.ts new file mode 100644 index 000000000..8b7b59151 --- /dev/null +++ b/src/webui/features/tasks/utils/query-tool-mode-results.ts @@ -0,0 +1,34 @@ +import {safeJsonParse} from './safe-json-parse' + +export type QueryToolModeMatchedDoc = { + format?: string + path: string + rendered_md?: string + score: number + title: string +} + +export function isQueryToolModeType(type: string): boolean { + return type === 'query-tool-mode' +} + +export function parseQueryToolModeResult(content: string): QueryToolModeMatchedDoc[] | undefined { + const parsed = safeJsonParse(content) + if (!parsed || typeof parsed !== 'object') return undefined + const obj = parsed as Record + if (!Array.isArray(obj.matchedDocs)) return undefined + return obj.matchedDocs.filter((element) => isMatchedDoc(element)) +} + +export function queryToolModeRowTitle(content: string): string | undefined { + const parsed = safeJsonParse(content) + if (!parsed || typeof parsed !== 'object') return undefined + const obj = parsed as Record + return typeof obj.query === 'string' ? obj.query : undefined +} + +function isMatchedDoc(value: unknown): value is QueryToolModeMatchedDoc { + if (typeof value !== 'object' || value === null) return false + const obj = value as Record + return typeof obj.title === 'string' && typeof obj.path === 'string' && typeof obj.score === 'number' +} diff --git a/src/webui/features/tasks/utils/safe-json-parse.ts b/src/webui/features/tasks/utils/safe-json-parse.ts new file mode 100644 index 000000000..5dcdd11d0 --- /dev/null +++ b/src/webui/features/tasks/utils/safe-json-parse.ts @@ -0,0 +1,7 @@ +export function safeJsonParse(content: string): unknown { + try { + return JSON.parse(content) + } catch { + return undefined + } +} diff --git a/src/webui/features/tasks/utils/task-display-title.ts b/src/webui/features/tasks/utils/task-display-title.ts new file mode 100644 index 000000000..275398115 --- /dev/null +++ b/src/webui/features/tasks/utils/task-display-title.ts @@ -0,0 +1,8 @@ +import {curateHtmlDirectRowTitle, isCurateHtmlDirectType} from './curate-tool-mode' +import {isQueryToolModeType, queryToolModeRowTitle} from './query-tool-mode-results' + +export function taskDisplayTitle(task: {content: string; type: string}): string | undefined { + if (isCurateHtmlDirectType(task.type)) return curateHtmlDirectRowTitle(task.content) + if (isQueryToolModeType(task.type)) return queryToolModeRowTitle(task.content) ?? task.content + return task.content +} diff --git a/src/webui/lib/syntax-highlighter.ts b/src/webui/lib/syntax-highlighter.ts index 46c57eb00..ae5229bc7 100644 --- a/src/webui/lib/syntax-highlighter.ts +++ b/src/webui/lib/syntax-highlighter.ts @@ -5,8 +5,8 @@ * react-syntax-highlighter — `Prism` eagerly bundles ~280 PrismJS language * definitions (~1MB minified), while `PrismLight` only ships languages we * explicitly register. This module is the single registration point so the - * three call sites (markdown-inline, markdown-view, task-detail-tool-call) - * share one configured highlighter. + * call sites (markdown-inline, markdown-view) share one configured + * highlighter. * * Add a language here when content using it appears in the UI; unregistered * languages render as plain text (no error, just no syntax colors). diff --git a/test/unit/webui/features/tasks/utils/query-tool-mode-results.test.ts b/test/unit/webui/features/tasks/utils/query-tool-mode-results.test.ts new file mode 100644 index 000000000..7b610e8ac --- /dev/null +++ b/test/unit/webui/features/tasks/utils/query-tool-mode-results.test.ts @@ -0,0 +1,115 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + isQueryToolModeType, + parseQueryToolModeResult, + queryToolModeRowTitle, +} from '../../../../../../src/webui/features/tasks/utils/query-tool-mode-results.js' + +const doc = (overrides: Record = {}) => ({ + format: 'html', + path: 'analytics/lifecycle_pipeline.html', + rendered_md: '# Analytics Lifecycle Pipeline\n\nBody text.', + score: 0.85, + title: 'Analytics Lifecycle Pipeline', + ...overrides, +}) + +describe('query-tool-mode result parser', () => { + describe('isQueryToolModeType', () => { + it('matches query-tool-mode', () => { + expect(isQueryToolModeType('query-tool-mode')).to.equal(true) + }) + + it('does not match the LLM query type', () => { + expect(isQueryToolModeType('query')).to.equal(false) + }) + + it('does not match unrelated task types', () => { + expect(isQueryToolModeType('curate-tool-mode')).to.equal(false) + }) + }) + + describe('parseQueryToolModeResult', () => { + it('parses a tool-mode result with matched docs', () => { + const content = JSON.stringify({matchedDocs: [doc()], metadata: {}, status: 'ok'}) + const parsed = parseQueryToolModeResult(content) + if (!parsed) throw new Error('expected parsed docs') + + expect(parsed).to.have.lengthOf(1) + const [first] = parsed + expect(first.title).to.equal('Analytics Lifecycle Pipeline') + expect(first.path).to.equal('analytics/lifecycle_pipeline.html') + expect(first.score).to.equal(0.85) + expect(first.rendered_md).to.contain('Analytics Lifecycle Pipeline') + }) + + it('keeps multiple matched docs in order', () => { + const content = JSON.stringify({ + matchedDocs: [doc({path: 'a', title: 'A'}), doc({path: 'b', title: 'B'})], + status: 'ok', + }) + const parsed = parseQueryToolModeResult(content) + if (!parsed) throw new Error('expected parsed docs') + + expect(parsed.map((entry) => entry.title)).to.deep.equal(['A', 'B']) + }) + + it('returns an empty array for a no-matches result', () => { + const content = JSON.stringify({matchedDocs: [], metadata: {}, status: 'no-matches'}) + expect(parseQueryToolModeResult(content)).to.deep.equal([]) + }) + + it('drops entries that are missing a required field', () => { + const content = JSON.stringify({ + matchedDocs: [ + doc(), + {path: 'x', score: 0.1}, + {score: 0.2, title: 'no path'}, + {path: 'y', title: 'no score'}, + ], + status: 'ok', + }) + const parsed = parseQueryToolModeResult(content) + if (!parsed) throw new Error('expected parsed docs') + + expect(parsed).to.have.lengthOf(1) + }) + + it('returns undefined for malformed JSON', () => { + expect(parseQueryToolModeResult('not-json{')).to.equal(undefined) + }) + + it('returns undefined when matchedDocs is missing', () => { + expect(parseQueryToolModeResult(JSON.stringify({status: 'ok'}))).to.equal(undefined) + }) + + it('returns undefined when matchedDocs is not an array', () => { + expect(parseQueryToolModeResult(JSON.stringify({matchedDocs: 'nope'}))).to.equal(undefined) + }) + + it('returns undefined for a non-object payload', () => { + expect(parseQueryToolModeResult(JSON.stringify('a string'))).to.equal(undefined) + }) + }) + + describe('queryToolModeRowTitle', () => { + it('returns the decoded query from an encoded payload', () => { + const content = JSON.stringify({limit: 10, query: 'agent loop and computer use automation'}) + expect(queryToolModeRowTitle(content)).to.equal('agent loop and computer use automation') + }) + + it('returns undefined when query is missing', () => { + expect(queryToolModeRowTitle(JSON.stringify({limit: 10}))).to.equal(undefined) + }) + + it('returns undefined for malformed JSON', () => { + expect(queryToolModeRowTitle('not-json{')).to.equal(undefined) + }) + + it('returns undefined for a non-object payload', () => { + expect(queryToolModeRowTitle(JSON.stringify('a string'))).to.equal(undefined) + }) + }) +}) diff --git a/test/unit/webui/features/tasks/utils/task-display-title.test.ts b/test/unit/webui/features/tasks/utils/task-display-title.test.ts new file mode 100644 index 000000000..993277613 --- /dev/null +++ b/test/unit/webui/features/tasks/utils/task-display-title.test.ts @@ -0,0 +1,24 @@ +import {expect} from 'chai' + +import {taskDisplayTitle} from '../../../../../../src/webui/features/tasks/utils/task-display-title.js' + +describe('taskDisplayTitle', () => { + it('returns the decoded query for a query-tool-mode task', () => { + const content = JSON.stringify({limit: 10, query: 'agent loop and computer use automation'}) + expect(taskDisplayTitle({content, type: 'query-tool-mode'})).to.equal('agent loop and computer use automation') + }) + + it('falls back to the raw content when a query-tool-mode payload is unparseable', () => { + expect(taskDisplayTitle({content: 'not-json{', type: 'query-tool-mode'})).to.equal('not-json{') + }) + + it('returns the raw content for a plain query task', () => { + const content = 'what is the point on the corner top-right?' + expect(taskDisplayTitle({content, type: 'query'})).to.equal(content) + }) + + it('decodes the row title for a curate-tool-mode task', () => { + const content = JSON.stringify({html: ''}) + expect(taskDisplayTitle({content, type: 'curate-tool-mode'})).to.equal('security/auth') + }) +})