From 21d2c1ee67fce60a49560fde1a4666e0d3f668fe Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 09:12:29 +0700 Subject: [PATCH 1/3] feat: [ENG-3022] render query results as a list in LocalWebUI Parse the query-tool-mode task result into matched docs and render them as a list in the task-detail Result section: each row shows the title, a match-score badge with a "Match score" tooltip, the path, and the doc content with a Show more/less expander. Legacy or non-JSON results fall through to the existing markdown/HTML rendering. Adds parseQueryToolModeResult / isQueryToolModeType with unit tests. --- .../tasks/components/query-results-list.tsx | 83 ++++++++++++++++ .../tasks/components/task-detail-sections.tsx | 7 ++ .../tasks/utils/query-tool-mode-results.ts | 37 ++++++++ .../utils/query-tool-mode-results.test.ts | 95 +++++++++++++++++++ 4 files changed, 222 insertions(+) create mode 100644 src/webui/features/tasks/components/query-results-list.tsx create mode 100644 src/webui/features/tasks/utils/query-tool-mode-results.ts create mode 100644 test/unit/webui/features/tasks/utils/query-tool-mode-results.test.ts 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..4083fe13e --- /dev/null +++ b/src/webui/features/tasks/components/query-results-list.tsx @@ -0,0 +1,83 @@ +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 {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'}` + + return ( +
+ + {label} + {matchedDocs.length === 0 ? ( +

No matching documents.

+ ) : ( +
+ {matchedDocs.map((doc, index) => ( + + ))} +
+ )} +
+ ) +} + +function QueryResultRow({doc}: {doc: QueryToolModeMatchedDoc}) { + const hasBody = typeof doc.rendered_md === 'string' && doc.rendered_md.length > 0 + 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 + +
+

{doc.path}

+ {hasBody && ( +
+
+ {doc.rendered_md ?? ''} +
+ {overflowing && ( + + )} +
+ )} +
+ ) +} diff --git a/src/webui/features/tasks/components/task-detail-sections.tsx b/src/webui/features/tasks/components/task-detail-sections.tsx index a033dcfac..ea7ff4386 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} 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}) { @@ -86,6 +88,11 @@ export function ResultSection({content, taskType}: {content: string; taskType?: if (payload) return } + if (taskType && isQueryToolModeType(taskType)) { + const payload = parseQueryToolModeResult(content) + if (payload) return + } + return (
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..ccb2c8783 --- /dev/null +++ b/src/webui/features/tasks/utils/query-tool-mode-results.ts @@ -0,0 +1,37 @@ +export type QueryToolModeMatchedDoc = { + format?: string + path: string + rendered_md?: string + score: number + title: string +} + +export type QueryToolModeResultPayload = { + matchedDocs: QueryToolModeMatchedDoc[] +} + +export function isQueryToolModeType(type: string): boolean { + return type === 'query-tool-mode' +} + +export function parseQueryToolModeResult(content: string): QueryToolModeResultPayload | 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 {matchedDocs: obj.matchedDocs.filter((element) => isMatchedDoc(element))} +} + +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' +} + +function safeJsonParse(content: string): unknown { + try { + return JSON.parse(content) + } catch { + return undefined + } +} 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..0d3c35a78 --- /dev/null +++ b/test/unit/webui/features/tasks/utils/query-tool-mode-results.test.ts @@ -0,0 +1,95 @@ +/* eslint-disable camelcase */ +import {expect} from 'chai' + +import { + isQueryToolModeType, + parseQueryToolModeResult, +} 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 a parsed payload') + + expect(parsed.matchedDocs).to.have.lengthOf(1) + const [first] = parsed.matchedDocs + 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 a parsed payload') + + expect(parsed.matchedDocs.map((entry) => entry.title)).to.deep.equal(['A', 'B']) + }) + + it('returns an empty grid for a no-matches result', () => { + const content = JSON.stringify({matchedDocs: [], metadata: {}, status: 'no-matches'}) + expect(parseQueryToolModeResult(content)).to.deep.equal({matchedDocs: []}) + }) + + 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 a parsed payload') + + expect(parsed.matchedDocs).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) + }) + }) +}) From c2302f361d3239262b2876feabf9ff1eb68aac8b Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 09:12:52 +0700 Subject: [PATCH 2/3] feat: [ENG-3023] remove EVENT LOG section from task detail Drop the EventLogSection from the LocalWebUI task-detail view for all task types and delete the now-unused task-detail-event-log component. --- .../components/task-detail-event-log.tsx | 261 ------------------ .../tasks/components/task-detail-view.tsx | 2 - 2 files changed, 263 deletions(-) delete mode 100644 src/webui/features/tasks/components/task-detail-event-log.tsx 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-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 && } From ed031e53802b7a1354ececfb4cc12e356eb134bc Mon Sep 17 00:00:00 2001 From: ncnthien Date: Tue, 2 Jun 2026 11:37:04 +0700 Subject: [PATCH 3/3] feat: [ENG-3022] link result paths to Context tab + decode tool-mode input - Decode the encoded {limit,query} content so the input box, panel title, and list-row title show the query instead of raw JSON (shared taskDisplayTitle). - Make each query result path clickable: opens the file in the Context tab if it still exists, otherwise toasts (useNavigateToContextPath reuses findNodeByPath). - Slim the result parser to return the matched-docs array; dedupe safeJsonParse and the stale-path toast message; drop the unused limit field. - Remove the dead event-log helpers (build-event-timeline, task-detail-tool-call, EventDot/RAIL_BG/EventTone) orphaned by the EVENT LOG removal. --- .../hooks/use-navigate-to-context-path.ts | 20 + .../hooks/use-topic-viewer-navigation.ts | 4 +- .../context/utils/topic-viewer-navigation.ts | 2 + .../tasks/components/query-results-list.tsx | 20 +- .../tasks/components/task-detail-header.tsx | 6 +- .../tasks/components/task-detail-sections.tsx | 10 +- .../tasks/components/task-detail-shared.tsx | 43 --- .../components/task-detail-tool-call.tsx | 345 ------------------ .../tasks/components/task-list-table.tsx | 6 +- .../tasks/utils/build-event-timeline.ts | 19 - .../features/tasks/utils/curate-tool-mode.ts | 10 +- .../tasks/utils/query-tool-mode-results.ts | 25 +- .../features/tasks/utils/safe-json-parse.ts | 7 + .../tasks/utils/task-display-title.ts | 8 + src/webui/lib/syntax-highlighter.ts | 4 +- .../utils/query-tool-mode-results.test.ts | 38 +- .../tasks/utils/task-display-title.test.ts | 24 ++ 17 files changed, 131 insertions(+), 460 deletions(-) create mode 100644 src/webui/features/context/hooks/use-navigate-to-context-path.ts delete mode 100644 src/webui/features/tasks/components/task-detail-tool-call.tsx delete mode 100644 src/webui/features/tasks/utils/build-event-timeline.ts create mode 100644 src/webui/features/tasks/utils/safe-json-parse.ts create mode 100644 src/webui/features/tasks/utils/task-display-title.ts create mode 100644 test/unit/webui/features/tasks/utils/task-display-title.test.ts 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 index 4083fe13e..792cbdb53 100644 --- a/src/webui/features/tasks/components/query-results-list.tsx +++ b/src/webui/features/tasks/components/query-results-list.tsx @@ -7,11 +7,13 @@ 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 (
    @@ -22,7 +24,7 @@ export function QueryResultsList({matchedDocs}: {matchedDocs: QueryToolModeMatch ) : (
    {matchedDocs.map((doc, index) => ( - + ))}
    )} @@ -30,8 +32,8 @@ export function QueryResultsList({matchedDocs}: {matchedDocs: QueryToolModeMatch ) } -function QueryResultRow({doc}: {doc: QueryToolModeMatchedDoc}) { - const hasBody = typeof doc.rendered_md === 'string' && doc.rendered_md.length > 0 +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) @@ -60,11 +62,17 @@ function QueryResultRow({doc}: {doc: QueryToolModeMatchedDoc}) { Match score -

    {doc.path}

    - {hasBody && ( + + {body && (
    - {doc.rendered_md ?? ''} + {body}
    {overflowing && ( - - ) -} - -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-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 index ccb2c8783..8b7b59151 100644 --- a/src/webui/features/tasks/utils/query-tool-mode-results.ts +++ b/src/webui/features/tasks/utils/query-tool-mode-results.ts @@ -1,3 +1,5 @@ +import {safeJsonParse} from './safe-json-parse' + export type QueryToolModeMatchedDoc = { format?: string path: string @@ -6,20 +8,23 @@ export type QueryToolModeMatchedDoc = { title: string } -export type QueryToolModeResultPayload = { - matchedDocs: QueryToolModeMatchedDoc[] -} - export function isQueryToolModeType(type: string): boolean { return type === 'query-tool-mode' } -export function parseQueryToolModeResult(content: string): QueryToolModeResultPayload | undefined { +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 {matchedDocs: obj.matchedDocs.filter((element) => isMatchedDoc(element))} + 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 { @@ -27,11 +32,3 @@ function isMatchedDoc(value: unknown): value is QueryToolModeMatchedDoc { const obj = value as Record return typeof obj.title === 'string' && typeof obj.path === 'string' && typeof obj.score === 'number' } - -function safeJsonParse(content: string): unknown { - try { - return JSON.parse(content) - } catch { - return undefined - } -} 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 index 0d3c35a78..7b610e8ac 100644 --- 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 @@ -4,6 +4,7 @@ import {expect} from 'chai' import { isQueryToolModeType, parseQueryToolModeResult, + queryToolModeRowTitle, } from '../../../../../../src/webui/features/tasks/utils/query-tool-mode-results.js' const doc = (overrides: Record = {}) => ({ @@ -34,10 +35,10 @@ describe('query-tool-mode result parser', () => { 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 a parsed payload') + if (!parsed) throw new Error('expected parsed docs') - expect(parsed.matchedDocs).to.have.lengthOf(1) - const [first] = parsed.matchedDocs + 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) @@ -50,14 +51,14 @@ describe('query-tool-mode result parser', () => { status: 'ok', }) const parsed = parseQueryToolModeResult(content) - if (!parsed) throw new Error('expected a parsed payload') + if (!parsed) throw new Error('expected parsed docs') - expect(parsed.matchedDocs.map((entry) => entry.title)).to.deep.equal(['A', 'B']) + expect(parsed.map((entry) => entry.title)).to.deep.equal(['A', 'B']) }) - it('returns an empty grid for a no-matches result', () => { + 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({matchedDocs: []}) + expect(parseQueryToolModeResult(content)).to.deep.equal([]) }) it('drops entries that are missing a required field', () => { @@ -71,9 +72,9 @@ describe('query-tool-mode result parser', () => { status: 'ok', }) const parsed = parseQueryToolModeResult(content) - if (!parsed) throw new Error('expected a parsed payload') + if (!parsed) throw new Error('expected parsed docs') - expect(parsed.matchedDocs).to.have.lengthOf(1) + expect(parsed).to.have.lengthOf(1) }) it('returns undefined for malformed JSON', () => { @@ -92,4 +93,23 @@ describe('query-tool-mode result parser', () => { 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') + }) +})