From a008456657ff719fa14059fb6b1e0fb5c789a9e0 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sat, 23 May 2026 14:39:08 +0900 Subject: [PATCH 01/12] Improvement:upgrade task panel visual --- .../src/pages/Chat/ChatPage.module.css | 26 + .../frontend/src/pages/Chat/ChatPage.tsx | 52 +- .../src/pages/Tasks/TasksPage.module.css | 364 ++++++++++- .../frontend/src/pages/Tasks/TasksPage.tsx | 578 +++++++++++++----- .../frontend/src/utils/taskPlaceholder.ts | 35 ++ 5 files changed, 869 insertions(+), 186 deletions(-) create mode 100644 app/ui_layer/browser/frontend/src/utils/taskPlaceholder.ts diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css index d9c2d34ae..035444c89 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.module.css @@ -458,6 +458,32 @@ font-style: italic; } +/* Active-state placeholder shown at the end of a task's child action list + (Thinking… / Waiting for reply… / Paused…). Non-interactive except for + an always-visible reply button when the task is waiting. */ +.placeholderItem { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + color: var(--text-muted); + font-style: italic; +} + +/* Reply button on the placeholder — same look as .taskReplyBtn but always + visible (no hover-revealed opacity). */ +.placeholderReplyBtn { + flex-shrink: 0; + color: var(--text-muted); + transition: color var(--transition-fast), background var(--transition-fast); +} + +.placeholderReplyBtn:hover { + color: var(--color-primary); + background: var(--color-primary-light); +} + /* Hidden file input */ .hiddenFileInput { display: none; diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx index 149765721..186bb63bf 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx @@ -3,6 +3,7 @@ import { X, Loader2, Reply } from 'lucide-react' import { useWebSocket } from '../../contexts/WebSocketContext' import { IconButton, StatusIndicator } from '../../components/ui' import { Chat } from '../../components/Chat' +import { getActivePlaceholder } from '../../utils/taskPlaceholder' import styles from './ChatPage.module.css' // Panel width limits @@ -147,19 +148,44 @@ export function ChatPage() { )} - {selectedTaskId === task.id && ( -
- {getActionsForTask(task.id).map(action => ( -
- - {action.name} -
- ))} - {getActionsForTask(task.id).length === 0 && ( -
No actions yet
- )} -
- )} + {selectedTaskId === task.id && (() => { + const taskActions = getActionsForTask(task.id) + const listPlaceholder = getActivePlaceholder(task.status, taskActions) + const showListReply = + listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) + return ( +
+ {taskActions.map(action => ( +
+ + {action.name} +
+ ))} + {listPlaceholder && ( +
+ + {listPlaceholder.label} + {showListReply && ( + { + e.stopPropagation() + handleTaskReply(task.id, task.name) + }} + title="Reply to Task" + icon={} + /> + )} +
+ )} + {taskActions.length === 0 && !listPlaceholder && ( +
No actions yet
+ )} +
+ ) + })()} )) )} diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css index bee663306..da9340587 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css @@ -144,6 +144,32 @@ font-style: italic; } +/* Active-state placeholder shown at the end of a task's inline child list + (Thinking… / Waiting for reply… / Paused…). Non-interactive except for + an always-visible reply button when the task is waiting. */ +.placeholderItem { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-1) var(--space-2); + font-size: var(--text-xs); + color: var(--text-muted); + font-style: italic; +} + +/* Reply button on the active-state placeholder — same look as .taskReplyBtn + on regular task rows, but always visible (no hover-revealed opacity). */ +.placeholderReplyBtn { + flex-shrink: 0; + color: var(--text-muted); + transition: color var(--transition-fast), background var(--transition-fast); +} + +.placeholderReplyBtn:hover { + color: var(--color-primary); + background: var(--color-primary-light); +} + .statusIcon { display: flex; align-items: center; @@ -196,13 +222,6 @@ font-size: var(--text-lg); } -.detailActions { - display: flex; - align-items: center; - gap: var(--space-2); -} - - .cancelButton { display: flex; align-items: center; @@ -226,19 +245,6 @@ padding: var(--space-4); } -.detailSection { - margin-bottom: var(--space-6); -} - -.detailSection h4 { - font-size: var(--text-sm); - font-weight: var(--font-semibold); - color: var(--text-secondary); - margin-bottom: var(--space-2); - text-transform: uppercase; - letter-spacing: 0.05em; -} - .detailList { display: grid; grid-template-columns: 140px 1fr; @@ -386,19 +392,295 @@ color: var(--text-primary); } -/* Reasoning item - minimal styling, no icon */ +/* Reasoning item - minimal styling, no icon (used in left list) */ .reasoningItem { color: var(--text-muted); font-style: italic; } -/* Reasoning content in details panel */ +/* ───────────────────────────────────────────────────────────────────── + Body: Task Details card (top of the detail panel body) + ───────────────────────────────────────────────────────────────────── */ + +.taskDetailsCard { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: var(--space-3) var(--space-4); + margin-bottom: var(--space-5); +} + +.taskDetailsLabel { + font-size: var(--text-xs); + font-weight: var(--font-semibold); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-3); +} + +/* Task-level actions (Cancel / Create Skill / Aborted badge) in the page + header, right of the task name. */ +.headerActions { + display: flex; + align-items: center; + gap: var(--space-2); + flex-shrink: 0; +} + +/* ───────────────────────────────────────────────────────────────────── + Body: Transcript timeline (reasoning + actions, connected by dashed line) + ───────────────────────────────────────────────────────────────────── */ + +.transcript { + position: relative; + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.emptyTranscript { + padding: var(--space-6) var(--space-4); + text-align: center; + color: var(--text-muted); + font-size: var(--text-sm); + font-style: italic; +} + +/* Timeline row: icon gutter + content column. Each new item slides up + fades + in so streaming additions don't pop abruptly — same feel as the chat + panel's previewSlideUp animation. */ +.transcriptItem { + position: relative; + display: flex; + align-items: flex-start; + gap: var(--space-3); + scroll-margin-top: var(--space-3); + z-index: 1; + animation: transcriptItemAppear 180ms ease-out; +} + +@keyframes transcriptItemAppear { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +@media (prefers-reduced-motion: reduce) { + .transcriptItem { + animation: none; + } +} + +/* Each item draws its own dashed segment from below its icon down through + the gap to the next item — so the last item has no trailing line. */ +.transcriptItem::before { + content: ''; + position: absolute; + left: 11px; + top: 22px; + bottom: calc(-1 * var(--space-3)); + border-left: 1px dashed var(--border-secondary); + pointer-events: none; + z-index: 0; +} + +.transcriptItem:last-child::before { + display: none; +} + +.gutter { + width: 24px; + flex-shrink: 0; + display: flex; + justify-content: center; +} + +/* Icon wrapper — solid background to punch through the dashed line cleanly */ +.gutterIcon { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: var(--bg-primary); +} + +/* Hollow circle for reasoning items */ +.reasoningCircle { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--bg-primary); + border: 1.5px solid var(--border-secondary); + flex-shrink: 0; +} + +/* Action content column: name + duration (no card), then IO boxes */ +.actionContent { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-top: 2px; +} + +.actionHeader { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.actionName { + flex: 1; + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.actionDuration { + flex-shrink: 0; + font-family: var(--font-mono); + font-size: var(--text-xs); + color: var(--text-muted); +} + +/* Reasoning content sits in the same column position as action content */ .reasoningContent { + flex: 1; + min-width: 0; font-size: var(--text-sm); line-height: var(--leading-relaxed); + color: var(--text-secondary); + font-style: italic; white-space: pre-wrap; word-break: break-word; - color: var(--text-secondary); + padding-top: 2px; +} + +.reasoningPlaceholder { + opacity: 0.6; +} + +/* Wraps the body placeholder so the label and (optional) reply button sit + on one row with the button immediately after the text. */ +.placeholderRow { + display: flex; + align-items: center; + gap: var(--space-2); +} + +/* Shared box holding Input / Output / Error / More-detail trigger / Details */ +.actionBox { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + display: flex; + flex-direction: column; +} + +/* Labelled section inside the box (Input / Output / Error / expanded Details) */ +.actionSection { + padding: var(--space-2) var(--space-3); + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +/* Separator between adjacent IO sections. The expanded detail panel sits + below .actionMoreRow and intentionally has no top border — the "Hide + details" toggle is already visually grouped with the content it reveals. */ +.actionSection + .actionSection { + border-top: 1px solid var(--border-primary); +} + +/* Row at the bottom of the box that holds the More detail toggle */ +.actionMoreRow { + padding: var(--space-1) var(--space-2); + border-top: 1px solid var(--border-primary); +} + +.ioLabel { + font-size: var(--text-xs); + font-weight: var(--font-semibold); + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Plain-text streaming content; transparent inside an actionSection since + the parent box already provides padding/background. */ +.contentText { + padding: var(--space-2) var(--space-3); + margin: 0; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: var(--leading-relaxed); + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + max-height: 280px; + overflow-y: auto; +} + +.actionSection .contentText { + background: transparent; + padding: 0; + max-height: 280px; +} + +/* "More detail" toggle */ +.moreDetailBtn { + display: inline-flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-1) var(--space-2); + background: none; + border: none; + border-radius: var(--radius-sm); + color: var(--text-muted); + font-size: var(--text-xs); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.moreDetailBtn:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.moreDetailChevron { + transition: transform var(--transition-fast); +} + +.moreDetailChevron.expanded { + transform: rotate(90deg); +} + +/* Action's expanded detail grid — borderless, since it sits inside .actionBox */ +.actionDetailList { + grid-template-columns: 100px 1fr; +} + +/* Task-level error block at the bottom of the body */ +.taskErrorSection { + margin-top: var(--space-4); +} + +.taskErrorSection h4 { + font-size: var(--text-xs); + font-weight: var(--font-semibold); + color: var(--color-error); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: var(--space-2); } /* Mobile back button - hidden on desktop */ @@ -503,6 +785,42 @@ margin-left: var(--space-2); word-break: break-word; } + + /* Tighter task details card on mobile */ + .taskDetailsCard { + padding: var(--space-2) var(--space-3); + margin-bottom: var(--space-3); + } + + .headerActions { + flex-wrap: wrap; + gap: var(--space-1); + } + + /* Tighter timeline on mobile */ + .transcriptItem { + gap: var(--space-2); + } + + .gutter { + width: 20px; + } + + .transcript::before { + left: 9px; + } + + .actionSection { + padding: var(--space-2); + } + + .contentText { + max-height: 200px; + } + + .actionDetailList { + grid-template-columns: 90px 1fr; + } } /* ───────────────────────────────────────────────────────────────────── diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx index d5c7938b1..b7fe49853 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx @@ -5,6 +5,7 @@ import { useWebSocket } from '../../contexts/WebSocketContext' import { StatusIndicator, Badge, Button, IconButton, SkillCreatorModal } from '../../components/ui' import type { ActionItem } from '../../types' import { useSkillCreator } from './useSkillCreator' +import { getActivePlaceholder } from '../../utils/taskPlaceholder' import styles from './TasksPage.module.css' function isInternalWorkflowTask( @@ -376,6 +377,159 @@ function TodoListDisplay({ todos }: { todos: TodoEntry[] }) { ) } +// ───────────────────────────────────────────────────────────────────── +// Formatting helpers +// ───────────────────────────────────────────────────────────────────── + +function formatDuration(ms?: number): string { + if (ms == null) return '-' + if (ms < 1000) return `${ms}ms` + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` + const minutes = Math.floor(ms / 60000) + const seconds = Math.floor((ms % 60000) / 1000) + return `${minutes}m ${seconds}s` +} + +function formatTimestamp(ts?: number): string { + if (!ts) return '-' + return new Date(ts).toLocaleString() +} + +// Returns the duration to display: the completed duration if available, +// otherwise the live elapsed time since createdAt for running items. +function getElapsedMs(item: ActionItem): number | undefined { + if (item.duration != null) return item.duration + if ((item.status === 'running' || item.status === 'waiting') && item.createdAt) { + return Date.now() - item.createdAt + } + return undefined +} + +// Heuristic: does the content look like a structured dict/array? +function looksStructured(content: string): boolean { + const trimmed = content.trim() + return ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ) +} + +// Renders streamed input/output. Structured content gets the JsonDisplay grid; +// plain text falls back to a code block so partial streams stay readable. +function ContentDisplay({ content }: { content: string }) { + if (looksStructured(content)) return + return
{content}
+} + +// ───────────────────────────────────────────────────────────────────── +// Transcript blocks +// ───────────────────────────────────────────────────────────────────── + +function ReasoningBlock({ item }: { item: ActionItem }) { + return ( +
+
+
+ +
+
+ {item.output + ? item.output + : Thinking…} +
+
+ ) +} + +interface ActionBlockProps { + item: ActionItem + expanded: boolean + onToggleDetail: () => void +} + +function ActionBlock({ item, expanded, onToggleDetail }: ActionBlockProps) { + const elapsed = getElapsedMs(item) + + const normalizedName = (item.name || '').toLowerCase().replace(/[\s-]+/g, '_') + const isTodoAction = normalizedName === 'task_update_todos' + const todos = isTodoAction && item.input ? extractTodos(item.input) : null + + return ( +
+
+
+ +
+
+
+
+ {item.name} + {elapsed != null && ( + + {formatDuration(elapsed)} + + )} +
+ +
+ {item.input && ( +
+
Input
+ {todos + ? + : } +
+ )} + + {item.output && ( +
+
Output
+ +
+ )} + + {item.error && ( +
+
Error
+
{item.error}
+
+ )} + +
+ +
+ + {expanded && ( +
+
+
Type
+
{item.itemType}
+
ID
+
{item.id}
+
Started
+
{formatTimestamp(item.createdAt)}
+
Duration
+
{formatDuration(item.duration)}
+
+
+ )} +
+
+
+ ) +} + // Panel width limits (1:3 ratio default) const DEFAULT_PANEL_WIDTH = 350 const MIN_PANEL_WIDTH = 200 @@ -387,7 +541,14 @@ export function TasksPage() { const internalSkillNames = useMemo(() => new Set(skillMeta.internalSkillNames), [skillMeta.internalSkillNames]) const reservedSkillNames = useMemo(() => new Set(skillMeta.reservedSkillNames), [skillMeta.reservedSkillNames]) const navigate = useNavigate() - const [selectedItem, setSelectedItem] = useState(null) + + // Body navigation state: + // selectedTaskId — which task's transcript is shown in the body + // scrollTargetId — child action/reasoning to scroll to; null = scroll to latest + // expandedDetailIds — action ids whose "More detail" panel is open + const [selectedTaskId, setSelectedTaskId] = useState(null) + const [scrollTargetId, setScrollTargetId] = useState(null) + const [expandedDetailIds, setExpandedDetailIds] = useState>(new Set()) const [mobileShowDetail, setMobileShowDetail] = useState(false) const skillCreator = useSkillCreator() @@ -395,8 +556,27 @@ export function TasksPage() { const [panelWidth, setPanelWidth] = useState(DEFAULT_PANEL_WIDTH) const [isResizing, setIsResizing] = useState(false) const containerRef = useRef(null) + const bodyRef = useRef(null) + // Tracks whether the user is currently following along near the bottom of + // the transcript. Updated by the scroll listener; consulted by auto-follow. + // Same pattern as the chat panel's wasNearBottomRef. + const wasNearBottomRef = useRef(true) + // A counter we bump every second to re-render live durations for running items. + const [, forceTick] = useState(0) + + const tasks = useMemo(() => actions.filter(a => a.itemType === 'task'), [actions]) + + const selectedTask = useMemo( + () => tasks.find(t => t.id === selectedTaskId) ?? null, + [tasks, selectedTaskId], + ) - const tasks = actions.filter(a => a.itemType === 'task') + const transcriptItems = useMemo(() => { + if (!selectedTaskId) return [] + return actions + .filter(a => a.parentId === selectedTaskId && (a.itemType === 'action' || a.itemType === 'reasoning')) + .sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)) + }, [actions, selectedTaskId]) // Tasks whose latest UX gate is an unanswered option prompt — the user // must click an option, so suppress the reply affordance for these. @@ -421,34 +601,112 @@ export function TasksPage() { navigate('/chat') }, [setReplyTarget, navigate]) - // Get all items (actions + reasoning) for a task - const getItemsForTask = (taskId: string) => - actions.filter(a => (a.itemType === 'action' || a.itemType === 'reasoning') && a.parentId === taskId) - - // Get only actual actions (not reasoning) for count - const getActionCountForTask = (taskId: string) => - actions.filter(a => a.itemType === 'action' && a.parentId === taskId).length - - const formatDuration = (ms?: number) => { - if (!ms) return '-' - if (ms < 1000) return `${ms}ms` - if (ms < 60000) return `${(ms / 1000).toFixed(1)}s` - return `${(ms / 60000).toFixed(1)}m` - } + // Items shown inline beneath a task in the left list (actions + reasoning) + const getItemsForTask = useCallback( + (taskId: string) => + actions.filter(a => (a.itemType === 'action' || a.itemType === 'reasoning') && a.parentId === taskId), + [actions], + ) - const formatTimestamp = (ts?: number) => { - if (!ts) return '-' - return new Date(ts).toLocaleString() - } + // Action count per task (excludes reasoning) — used for the badge + const getActionCountForTask = useCallback( + (taskId: string) => + actions.filter(a => a.itemType === 'action' && a.parentId === taskId).length, + [actions], + ) - // Handle selecting an item (with mobile detail view) - const handleSelectItem = useCallback((item: ActionItem | null) => { - setSelectedItem(item) - if (item) { - setMobileShowDetail(true) + // Clicking a task or action in the left list drives the body's view. + // Tasks → show that task, scroll to latest progress. + // Actions/reasoning → show their parent task, scroll to the clicked item. + const handleSelectFromList = useCallback((item: ActionItem) => { + if (item.itemType === 'task') { + if (selectedTaskId === item.id) { + // Toggle off when clicking the already-selected task + setSelectedTaskId(null) + setScrollTargetId(null) + } else { + setSelectedTaskId(item.id) + setScrollTargetId(null) + } + } else { + setSelectedTaskId(item.parentId ?? null) + setScrollTargetId(item.id) } + setMobileShowDetail(true) + }, [selectedTaskId]) + + const toggleDetailExpansion = useCallback((id: string) => { + setExpandedDetailIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) }, []) + // Scroll-position tracker (mirrors Chat.tsx). Maintains wasNearBottomRef so + // auto-follow can tell at a glance whether the user is still "following + // along" at the bottom or has scrolled up to inspect previous activity. + useEffect(() => { + const body = bodyRef.current + if (!body) return + const handleScroll = () => { + const distFromBottom = body.scrollHeight - body.scrollTop - body.clientHeight + wasNearBottomRef.current = distFromBottom < 100 + } + body.addEventListener('scroll', handleScroll) + return () => body.removeEventListener('scroll', handleScroll) + }, [selectedTaskId]) + + // Scroll to the target item (or to latest) when selection changes. + useEffect(() => { + if (!selectedTaskId) return + const body = bodyRef.current + if (!body) return + // Wait a tick so the new transcript has rendered before measuring. + const timer = setTimeout(() => { + if (scrollTargetId) { + const el = document.getElementById(`transcript-item-${scrollTargetId}`) + if (el && bodyRef.current) { + const bodyEl = bodyRef.current + const bodyRect = bodyEl.getBoundingClientRect() + const elRect = el.getBoundingClientRect() + const offset = elRect.top - bodyRect.top + bodyEl.scrollTop - 16 + bodyEl.scrollTo({ top: offset, behavior: 'smooth' }) + } + // Jumping mid-transcript counts as "not following the tail." + wasNearBottomRef.current = false + } else { + body.scrollTo({ top: body.scrollHeight, behavior: 'smooth' }) + wasNearBottomRef.current = true + } + }, 60) + return () => clearTimeout(timer) + }, [selectedTaskId, scrollTargetId]) + + // Auto-follow: when new transcript items arrive on an active task, stick to + // the bottom only if the user was near the bottom. If they scrolled up to + // look at previous activity, leave them where they are. + useEffect(() => { + if (!selectedTask) return + if (selectedTask.status !== 'running' && selectedTask.status !== 'waiting') return + if (!wasNearBottomRef.current) return + const body = bodyRef.current + if (!body) return + body.scrollTo({ top: body.scrollHeight }) + }, [transcriptItems, selectedTask]) + + // Live ticker for running item durations. 100ms keeps the "X.Xs" decimal + // updating smoothly instead of jumping a second at a time. + useEffect(() => { + const hasRunning = + (selectedTask?.status === 'running' || selectedTask?.status === 'waiting') || + transcriptItems.some(i => i.status === 'running' || i.status === 'waiting') + if (!hasRunning) return + const interval = setInterval(() => forceTick(t => t + 1), 100) + return () => clearInterval(interval) + }, [transcriptItems, selectedTask]) + // Handle back button on mobile const handleMobileBack = useCallback(() => { setMobileShowDetail(false) @@ -486,6 +744,10 @@ export function TasksPage() { } }, [isResizing]) + const canCreateSkill = + selectedTask?.status === 'completed' && + !isInternalWorkflowTask(selectedTask, internalWorkflowIds, internalSkillNames) + return (
{/* Task List - Left Side (resizable) */} @@ -504,25 +766,17 @@ export function TasksPage() { tasks.map(task => { const taskItems = getItemsForTask(task.id) const actionCount = getActionCountForTask(task.id) - const isExpanded = selectedItem?.id === task.id || - taskItems.some(a => a.id === selectedItem?.id) + const isCurrentTask = selectedTaskId === task.id return (
- {isExpanded && ( -
- {taskItems.length > 0 ? ( - taskItems.map(action => ( + {isCurrentTask && (() => { + const listPlaceholder = getActivePlaceholder(task.status, taskItems) + const showListReply = + listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) + return ( +
+ {taskItems.map(action => ( - )) - ) : ( -
No actions yet
- )} -
- )} + ))} + {listPlaceholder && ( +
+ + {listPlaceholder.label} + {showListReply && ( + { + e.stopPropagation() + handleTaskReply(task) + }} + title="Reply to Task" + icon={} + /> + )} +
+ )} + {taskItems.length === 0 && !listPlaceholder && ( +
No actions yet
+ )} +
+ ) + })()}
) }) @@ -590,15 +861,13 @@ export function TasksPage() {
{/* Resize Handle */} -
+
{/* Detail Panel - Right Side */}
- {selectedItem ? ( + {selectedTask ? ( <> + {/* Header — task name + status + task-level actions */}
- -

{selectedItem.name}

+ +

{selectedTask.name}

+
+
+ {(selectedTask.status === 'running' || selectedTask.status === 'waiting') ? ( + + ) : canCreateSkill ? ( + + ) : (selectedTask.status === 'error' || selectedTask.status === 'cancelled') && ( + Aborted + )}
- {selectedItem.itemType === 'task' && ( -
- {(selectedItem.status === 'running' || selectedItem.status === 'waiting') ? ( - - ) : selectedItem.status === 'completed' && !isInternalWorkflowTask(selectedItem, internalWorkflowIds, internalSkillNames) ? ( - - ) : (selectedItem.status === 'error' || selectedItem.status === 'cancelled') && ( - Aborted - )} -
- )}
-
-
-

Details

+ {/* Body — scrollable transcript */} +
+ {/* Task details card sits at the top of the body */} +
+

Task Details

-
Type
-
{selectedItem.itemType}
-
ID
-
{selectedItem.id}
- {selectedItem.parentId && ( - <> -
Parent Task
-
{selectedItem.parentId}
- - )}
Started
-
{formatTimestamp(selectedItem.createdAt)}
+
{formatTimestamp(selectedTask.createdAt)}
Duration
-
{formatDuration(selectedItem.duration)}
- {selectedItem.itemType === 'task' && selectedItem.inputTokens != null && ( +
{formatDuration(getElapsedMs(selectedTask))}
+ {selectedTask.inputTokens != null && ( <>
Input Tokens
-
{selectedItem.inputTokens.toLocaleString()}
+
{selectedTask.inputTokens.toLocaleString()}
)} - {selectedItem.itemType === 'task' && selectedItem.outputTokens != null && ( + {selectedTask.outputTokens != null && ( <>
Output Tokens
-
{selectedItem.outputTokens.toLocaleString()}
+
{selectedTask.outputTokens.toLocaleString()}
)} - {selectedItem.itemType === 'task' && selectedItem.cacheTokens != null && ( + {selectedTask.cacheTokens != null && ( <>
Cache Tokens
-
{selectedItem.cacheTokens.toLocaleString()}
+
{selectedTask.cacheTokens.toLocaleString()}
)}
- {selectedItem.itemType === 'reasoning' ? ( - selectedItem.output && ( -
-

Content

-
- {selectedItem.output} + {/* Transcript: chronological reasoning + actions, with a + trailing placeholder while the task is active (Thinking / + Waiting for reply / Paused) so the timeline visibly continues + past the last real item. */} + {(() => { + const placeholder = getActivePlaceholder(selectedTask.status, transcriptItems) + if (transcriptItems.length === 0 && !placeholder) { + return ( +
+ No actions or reasoning recorded.
+ ) + } + return ( +
+ {transcriptItems.map(item => + item.itemType === 'reasoning' ? ( + + ) : ( + toggleDetailExpansion(item.id)} + /> + ) + )} + {placeholder && (() => { + const showBodyReply = + placeholder.status === 'waiting' && !tasksAwaitingOption.has(selectedTask.id) + return ( +
+
+
+ +
+
+
+ {placeholder.label} + {showBodyReply && ( + handleTaskReply(selectedTask)} + title="Reply to Task" + icon={} + /> + )} +
+
+ ) + })()}
) - ) : ( - <> - {selectedItem.input && (() => { - const normalizedName = (selectedItem.name || '') - .toLowerCase() - .replace(/[\s-]+/g, '_') - const isTodoAction = - selectedItem.itemType === 'action' && - normalizedName === 'task_update_todos' - const todos = isTodoAction ? extractTodos(selectedItem.input) : null - return ( -
-

{todos ? 'Todos' : 'Input'}

- {todos - ? - : } -
- ) - })()} - - {selectedItem.output && ( -
-

Output

- -
- )} - - )} + })()} - {selectedItem.error && ( -
-

Error

+ {selectedTask.error && ( +
+

Task Error

-                    {selectedItem.error}
+                    {selectedTask.error}
                   
)} @@ -729,7 +1007,7 @@ export function TasksPage() { ) : (
-

Select a task or action to view details

+

Select a task to view its progress

)}
diff --git a/app/ui_layer/browser/frontend/src/utils/taskPlaceholder.ts b/app/ui_layer/browser/frontend/src/utils/taskPlaceholder.ts new file mode 100644 index 000000000..4666ab937 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/utils/taskPlaceholder.ts @@ -0,0 +1,35 @@ +import type { ActionItem, ActionStatus } from '../types' + +// Describes the trailing placeholder shown at the end of a transcript/list +// while the task is still active. Returns null for terminal task states +// (completed / error / cancelled / pending / idle). +export interface ActivePlaceholder { + label: string + status: ActionStatus +} + +// `children` are the task's action/reasoning items. The "Thinking…" +// placeholder is suppressed when one of them is currently `running` — the +// running action itself already represents the agent's activity, so a +// separate placeholder would be redundant (and would dangle the timeline's +// dashed line below it with nothing to connect to). The waiting/paused +// placeholders are returned unconditionally because they convey a +// user-facing wait state and host the Reply button. +export function getActivePlaceholder( + status: ActionStatus, + children?: ActionItem[], +): ActivePlaceholder | null { + switch (status) { + case 'running': { + const hasRunningChild = children?.some(c => c.status === 'running') ?? false + if (hasRunningChild) return null + return { label: 'Thinking…', status: 'running' } + } + case 'waiting': + return { label: 'Waiting for your reply…', status: 'waiting' } + case 'paused': + return { label: 'Paused — awaiting confirmation…', status: 'paused' } + default: + return null + } +} From dba042846a599483ed0f118b2a800583ace61b35 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sat, 23 May 2026 14:54:46 +0900 Subject: [PATCH 02/12] improvement:refactor UI code --- .../frontend/src/pages/Chat/ChatPage.tsx | 100 +++---- .../frontend/src/pages/Tasks/TasksPage.tsx | 244 +++++++++++------- 2 files changed, 195 insertions(+), 149 deletions(-) diff --git a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx index 186bb63bf..44db34550 100644 --- a/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Chat/ChatPage.tsx @@ -102,58 +102,60 @@ export function ChatPage() {

No active tasks

) : ( - tasks.map(task => ( -
-
setSelectedTaskId( - selectedTaskId === task.id ? null : task.id - )} - > - - {task.name} - {(task.status === 'running' || task.status === 'waiting') && ( - <> - {!tasksAwaitingOption.has(task.id) && ( + tasks.map(task => { + const isExpanded = selectedTaskId === task.id + const taskActions = isExpanded ? getActionsForTask(task.id) : [] + const listPlaceholder = isExpanded + ? getActivePlaceholder(task.status, taskActions) + : null + const showListReply = + listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) + + return ( +
+
setSelectedTaskId(isExpanded ? null : task.id)} + > + + {task.name} + {(task.status === 'running' || task.status === 'waiting') && ( + <> + {!tasksAwaitingOption.has(task.id) && ( + { + e.stopPropagation() + handleTaskReply(task.id, task.name) + }} + title="Reply to Task" + icon={} + /> + )} { e.stopPropagation() - handleTaskReply(task.id, task.name) + cancelTask(task.id) }} - title="Reply to Task" - icon={} + disabled={cancellingTaskId === task.id} + title="Cancel Task" + icon={ + cancellingTaskId === task.id ? ( + + ) : ( + + ) + } /> - )} - { - e.stopPropagation() - cancelTask(task.id) - }} - disabled={cancellingTaskId === task.id} - title="Cancel Task" - icon={ - cancellingTaskId === task.id ? ( - - ) : ( - - ) - } - /> - - )} -
- {selectedTaskId === task.id && (() => { - const taskActions = getActionsForTask(task.id) - const listPlaceholder = getActivePlaceholder(task.status, taskActions) - const showListReply = - listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) - return ( + + )} +
+ {isExpanded && (
{taskActions.map(action => (
@@ -184,10 +186,10 @@ export function ChatPage() {
No actions yet
)}
- ) - })()} -
- )) + )} +
+ ) + }) )}
diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx index b7fe49853..0c05831b2 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx @@ -5,7 +5,7 @@ import { useWebSocket } from '../../contexts/WebSocketContext' import { StatusIndicator, Badge, Button, IconButton, SkillCreatorModal } from '../../components/ui' import type { ActionItem } from '../../types' import { useSkillCreator } from './useSkillCreator' -import { getActivePlaceholder } from '../../utils/taskPlaceholder' +import { getActivePlaceholder, type ActivePlaceholder } from '../../utils/taskPlaceholder' import styles from './TasksPage.module.css' function isInternalWorkflowTask( @@ -530,6 +530,98 @@ function ActionBlock({ item, expanded, onToggleDetail }: ActionBlockProps) { ) } +// Trailing placeholder rendered at the end of the transcript while the task +// is still active (Thinking / Waiting for reply / Paused). Shows an +// always-visible reply icon-button when the task is awaiting a user reply. +interface TranscriptPlaceholderProps { + placeholder: ActivePlaceholder + showReply: boolean + onReply: () => void +} + +function TranscriptPlaceholder({ placeholder, showReply, onReply }: TranscriptPlaceholderProps) { + return ( +
+
+
+ +
+
+
+ {placeholder.label} + {showReply && ( + } + /> + )} +
+
+ ) +} + +// Scrollable body of the detail panel: chronological list of reasoning + +// action items, with a trailing active-state placeholder while the task is +// still running/waiting/paused. +interface TaskTranscriptProps { + task: ActionItem + items: ActionItem[] + expandedDetailIds: Set + onToggleDetail: (id: string) => void + tasksAwaitingOption: Set + onTaskReply: (task: ActionItem) => void +} + +function TaskTranscript({ + task, + items, + expandedDetailIds, + onToggleDetail, + tasksAwaitingOption, + onTaskReply, +}: TaskTranscriptProps) { + const placeholder = getActivePlaceholder(task.status, items) + + if (items.length === 0 && !placeholder) { + return ( +
+ No actions or reasoning recorded. +
+ ) + } + + const showReply = + placeholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) + + return ( +
+ {items.map(item => + item.itemType === 'reasoning' ? ( + + ) : ( + onToggleDetail(item.id)} + /> + ) + )} + {placeholder && ( + onTaskReply(task)} + /> + )} +
+ ) +} + // Panel width limits (1:3 ratio default) const DEFAULT_PANEL_WIDTH = 350 const MIN_PANEL_WIDTH = 200 @@ -767,6 +859,11 @@ export function TasksPage() { const taskItems = getItemsForTask(task.id) const actionCount = getActionCountForTask(task.id) const isCurrentTask = selectedTaskId === task.id + const listPlaceholder = isCurrentTask + ? getActivePlaceholder(task.status, taskItems) + : null + const showListReply = + listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) return (
@@ -798,49 +895,44 @@ export function TasksPage() { - {isCurrentTask && (() => { - const listPlaceholder = getActivePlaceholder(task.status, taskItems) - const showListReply = - listPlaceholder?.status === 'waiting' && !tasksAwaitingOption.has(task.id) - return ( -
- {taskItems.map(action => ( - - ))} - {listPlaceholder && ( -
- - {listPlaceholder.label} - {showListReply && ( - { - e.stopPropagation() - handleTaskReply(task) - }} - title="Reply to Task" - icon={} - /> - )} -
- )} - {taskItems.length === 0 && !listPlaceholder && ( -
No actions yet
- )} -
- ) - })()} + {isCurrentTask && ( +
+ {taskItems.map(action => ( + + ))} + {listPlaceholder && ( +
+ + {listPlaceholder.label} + {showListReply && ( + { + e.stopPropagation() + handleTaskReply(task) + }} + title="Reply to Task" + icon={} + /> + )} +
+ )} + {taskItems.length === 0 && !listPlaceholder && ( +
No actions yet
+ )} +
+ )}
) }) @@ -938,62 +1030,14 @@ export function TasksPage() {
- {/* Transcript: chronological reasoning + actions, with a - trailing placeholder while the task is active (Thinking / - Waiting for reply / Paused) so the timeline visibly continues - past the last real item. */} - {(() => { - const placeholder = getActivePlaceholder(selectedTask.status, transcriptItems) - if (transcriptItems.length === 0 && !placeholder) { - return ( -
- No actions or reasoning recorded. -
- ) - } - return ( -
- {transcriptItems.map(item => - item.itemType === 'reasoning' ? ( - - ) : ( - toggleDetailExpansion(item.id)} - /> - ) - )} - {placeholder && (() => { - const showBodyReply = - placeholder.status === 'waiting' && !tasksAwaitingOption.has(selectedTask.id) - return ( -
-
-
- -
-
-
- {placeholder.label} - {showBodyReply && ( - handleTaskReply(selectedTask)} - title="Reply to Task" - icon={} - /> - )} -
-
- ) - })()} -
- ) - })()} + {selectedTask.error && (
From b5a2bf4ab72decd874ec06f094a072b086a185d5 Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sat, 23 May 2026 17:16:14 +0900 Subject: [PATCH 03/12] feature:improve visual and interactive of action --- .../browser/frontend/package-lock.json | 29 + app/ui_layer/browser/frontend/package.json | 1 + .../frontend/src/pages/Tasks/TasksPage.tsx | 145 +--- .../src/pages/Tasks/actionRenderers/parse.ts | 182 +++++ .../actionRenderers/primitives.module.css | 352 ++++++++++ .../Tasks/actionRenderers/primitives.tsx | 349 ++++++++++ .../pages/Tasks/actionRenderers/renderers.tsx | 658 ++++++++++++++++++ 7 files changed, 1602 insertions(+), 114 deletions(-) create mode 100644 app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts create mode 100644 app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css create mode 100644 app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx create mode 100644 app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx diff --git a/app/ui_layer/browser/frontend/package-lock.json b/app/ui_layer/browser/frontend/package-lock.json index 94b51372b..7ef85425e 100644 --- a/app/ui_layer/browser/frontend/package-lock.json +++ b/app/ui_layer/browser/frontend/package-lock.json @@ -11,6 +11,7 @@ "@reduxjs/toolkit": "^2.12.0", "@tanstack/react-virtual": "^3.13.23", "lucide-react": "^0.344.0", + "prism-react-renderer": "^2.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", @@ -1486,6 +1487,12 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -2012,6 +2019,15 @@ "url": "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/sponsors/wooorm" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -4243,6 +4259,19 @@ "node": ">= 0.8.0" } }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", diff --git a/app/ui_layer/browser/frontend/package.json b/app/ui_layer/browser/frontend/package.json index 214bc4d0a..33d5584b1 100644 --- a/app/ui_layer/browser/frontend/package.json +++ b/app/ui_layer/browser/frontend/package.json @@ -13,6 +13,7 @@ "@reduxjs/toolkit": "^2.12.0", "@tanstack/react-virtual": "^3.13.23", "lucide-react": "^0.344.0", + "prism-react-renderer": "^2.4.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx index 0c05831b2..64eec20b7 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.tsx @@ -1,11 +1,12 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react' -import { ChevronRight, XCircle, ArrowLeft, Reply, Plus, Square, CheckSquare } from 'lucide-react' +import { ChevronRight, XCircle, ArrowLeft, Reply, Plus } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { useWebSocket } from '../../contexts/WebSocketContext' import { StatusIndicator, Badge, Button, IconButton, SkillCreatorModal } from '../../components/ui' import type { ActionItem } from '../../types' import { useSkillCreator } from './useSkillCreator' import { getActivePlaceholder, type ActivePlaceholder } from '../../utils/taskPlaceholder' +import { getActionRenderer, parseIO } from './actionRenderers/renderers' import styles from './TasksPage.module.css' function isInternalWorkflowTask( @@ -280,103 +281,6 @@ function JsonDisplay({ content }: { content: string }) { ) } -// ───────────────────────────────────────────────────────────────────── -// Todo List Display (for task_update_todos action) -// ───────────────────────────────────────────────────────────────────── - -type TodoStatus = 'pending' | 'in_progress' | 'completed' | string - -interface TodoEntry { - content: string - status: TodoStatus -} - -function extractTodos(content: string): TodoEntry[] | null { - // First try the standard parser - const parsed = parsePythonDict(content) - const raw = (parsed as Record)?.todos - - // The parsePythonDict helper currently stores arrays as the raw "[...]" - // string slice, so try to recover real objects either from a real array - // or by parsing the array string as JSON / a small Python-literal subset. - let items: unknown[] | null = null - - if (Array.isArray(raw)) { - items = raw - } else if (typeof raw === 'string') { - items = tryParseArrayLiteral(raw) - } - - if (!items) return null - - const todos: TodoEntry[] = [] - for (const item of items) { - if (item && typeof item === 'object') { - const obj = item as Record - const todoContent = obj.content - const status = obj.status - if (typeof todoContent === 'string' && typeof status === 'string') { - todos.push({ content: todoContent, status }) - } - } - } - - return todos.length > 0 ? todos : null -} - -function tryParseArrayLiteral(raw: string): unknown[] | null { - // Direct JSON - try { - const parsed = JSON.parse(raw) - if (Array.isArray(parsed)) return parsed - } catch { - // fall through - } - - // Python-style array: single quotes + True/False/None — convert to JSON - try { - let normalized = raw.trim() - // Replace Python literals with JSON equivalents (only when whole-word) - normalized = normalized - .replace(/\bTrue\b/g, 'true') - .replace(/\bFalse\b/g, 'false') - .replace(/\bNone\b/g, 'null') - // Naive single-quote → double-quote swap. Good enough for our schema - // (todo content/status are plain words/sentences without embedded quotes - // most of the time); fall back to null on failure. - normalized = normalized.replace(/'/g, '"') - const parsed = JSON.parse(normalized) - if (Array.isArray(parsed)) return parsed - } catch { - // ignore - } - return null -} - -function TodoListDisplay({ todos }: { todos: TodoEntry[] }) { - return ( -
    - {todos.map((todo, index) => { - const isCompleted = todo.status === 'completed' - const isInProgress = todo.status === 'in_progress' - const itemClass = [ - styles.todoItem, - isCompleted ? styles.todoCompleted : '', - isInProgress ? styles.todoInProgress : '', - ].filter(Boolean).join(' ') - return ( -
  • - - {todo.content} -
  • - ) - })} -
- ) -} - // ───────────────────────────────────────────────────────────────────── // Formatting helpers // ───────────────────────────────────────────────────────────────────── @@ -449,11 +353,15 @@ interface ActionBlockProps { } function ActionBlock({ item, expanded, onToggleDetail }: ActionBlockProps) { + const { openFile } = useWebSocket() const elapsed = getElapsedMs(item) - const normalizedName = (item.name || '').toLowerCase().replace(/[\s-]+/g, '_') - const isTodoAction = normalizedName === 'task_update_todos' - const todos = isTodoAction && item.input ? extractTodos(item.input) : null + // Look up a custom renderer for this action. If one is registered it + // replaces the generic Input/Output sections with a tailored view (diff + // for stream_edit, terminal for run_python, image grid for generate_image, + // …); otherwise we fall back to the structured JSON / plain-text display. + const Renderer = getActionRenderer(item.name) + const { inputObj, outputObj } = Renderer ? parseIO(item) : { inputObj: null, outputObj: null } return (
@@ -473,20 +381,29 @@ function ActionBlock({ item, expanded, onToggleDetail }: ActionBlockProps) {
- {item.input && ( -
-
Input
- {todos - ? - : } -
- )} + {Renderer ? ( + + ) : ( + <> + {item.input && ( +
+
Input
+ +
+ )} - {item.output && ( -
-
Output
- -
+ {item.output && ( +
+
Output
+ +
+ )} + )} {item.error && ( diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts new file mode 100644 index 000000000..7cb66b6a0 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts @@ -0,0 +1,182 @@ +// Parsing helpers shared by all action renderers. +// +// Action input/output strings arrive as either JSON or Python dict repr +// (e.g. `{'file_path': 'C:\\...', 'count': 42, 'flag': True, 'val': None}`). +// `parseDict` is lenient — it tries JSON first, then falls back to a small +// Python-literal parser that handles single-quoted strings and True/False/None. + +export function parseDict(content: string | undefined | null): Record | null { + if (!content) return null + try { + const parsed = JSON.parse(content) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // fall through to Python dict parser + } + + const result: Record = {} + let inner = content.trim() + if (inner.startsWith('{') && inner.endsWith('}')) { + inner = inner.slice(1, -1).trim() + } else { + return null + } + + let i = 0 + while (i < inner.length) { + while (i < inner.length && (inner[i] === ' ' || inner[i] === ',' || inner[i] === '\n')) i++ + if (i >= inner.length) break + + const keyQuote = inner[i] + if (keyQuote !== "'" && keyQuote !== '"') { + i++ + continue + } + i++ + + let key = '' + while (i < inner.length && inner[i] !== keyQuote) { + if (inner[i] === '\\' && i + 1 < inner.length) { + key += inner[i + 1] + i += 2 + } else { + key += inner[i] + i++ + } + } + i++ + + while (i < inner.length && (inner[i] === ':' || inner[i] === ' ')) i++ + if (i >= inner.length) break + + let value: unknown + const valueStart = inner[i] + + if (valueStart === "'" || valueStart === '"') { + i++ + let str = '' + while (i < inner.length && inner[i] !== valueStart) { + if (inner[i] === '\\' && i + 1 < inner.length) { + const next = inner[i + 1] + if (next === 'n') str += '\n' + else if (next === 't') str += '\t' + else if (next === 'r') str += '\r' + else str += next + i += 2 + } else { + str += inner[i] + i++ + } + } + i++ + value = str + } else if (valueStart === '{') { + let depth = 1 + const start = i + i++ + while (i < inner.length && depth > 0) { + if (inner[i] === '{') depth++ + else if (inner[i] === '}') depth-- + i++ + } + value = parseDict(inner.slice(start, i)) ?? inner.slice(start, i) + } else if (valueStart === '[') { + let depth = 1 + const start = i + i++ + while (i < inner.length && depth > 0) { + if (inner[i] === '[') depth++ + else if (inner[i] === ']') depth-- + i++ + } + // Store array slices as raw string; renderers that need it can call parseArray. + value = inner.slice(start, i) + } else { + let raw = '' + while (i < inner.length && inner[i] !== ',' && inner[i] !== '}') { + raw += inner[i] + i++ + } + raw = raw.trim() + if (raw === 'True') value = true + else if (raw === 'False') value = false + else if (raw === 'None') value = null + else if (!isNaN(Number(raw))) value = Number(raw) + else value = raw + } + + if (key) result[key] = value + } + + return result +} + +// Parses a raw "[...]" literal — used to recover arrays that parseDict left +// as raw strings (its top-level dict parser only stores nested arrays as +// slices to keep the implementation small). +export function parseArray(raw: unknown): unknown[] | null { + if (Array.isArray(raw)) return raw + if (typeof raw !== 'string') return null + + const trimmed = raw.trim() + try { + const parsed = JSON.parse(trimmed) + if (Array.isArray(parsed)) return parsed + } catch { + // fall through + } + try { + const normalized = trimmed + .replace(/\bTrue\b/g, 'true') + .replace(/\bFalse\b/g, 'false') + .replace(/\bNone\b/g, 'null') + .replace(/'/g, '"') + const parsed = JSON.parse(normalized) + if (Array.isArray(parsed)) return parsed + } catch { + // ignore + } + return null +} + +// Typed accessors — keep renderers readable. +export function strField(obj: Record | null, key: string): string | undefined { + const v = obj?.[key] + return typeof v === 'string' ? v : undefined +} + +export function arrField(obj: Record | null, key: string): unknown[] | null { + return parseArray(obj?.[key]) +} + +export function dictField(obj: Record | null, key: string): Record | null { + const v = obj?.[key] + if (v && typeof v === 'object' && !Array.isArray(v)) { + return v as Record + } + if (typeof v === 'string') { + return parseDict(v) + } + return null +} + +// Best-effort filename extraction from a path (handles both / and \). +export function basename(path: string): string { + const m = path.match(/[/\\]([^/\\]+)$/) + return m ? m[1] : path +} + +// Detect a programming language from a file extension. Used to label code +// blocks; not a real syntax highlighter (yet). +export function langFromPath(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() ?? '' + return ({ + py: 'python', js: 'javascript', ts: 'typescript', tsx: 'tsx', jsx: 'jsx', + md: 'markdown', html: 'html', css: 'css', json: 'json', yaml: 'yaml', yml: 'yaml', + sh: 'bash', bash: 'bash', ps1: 'powershell', sql: 'sql', go: 'go', + rs: 'rust', rb: 'ruby', java: 'java', c: 'c', cpp: 'cpp', h: 'c', + txt: 'text', csv: 'csv', toml: 'toml', xml: 'xml', + } as Record)[ext] ?? ext ?? 'text' +} diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css new file mode 100644 index 000000000..e7aba1093 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css @@ -0,0 +1,352 @@ +/* Shared UI primitives for action renderers. + The
wrapper reuses TasksPage's .actionSection/.ioLabel from the + parent module — only the renderer-specific primitives live here. */ + +/* File path chip — monospace, click to open */ +/* File link — borderless, transparent, primary color, underline on hover. + text-sm so it reads at link weight, not metadata weight. */ +.filePathChip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-family: var(--font-mono); + font-size: var(--text-sm); + color: var(--color-primary); + background: transparent; + padding: 0; + border: none; + cursor: pointer; + max-width: 100%; + text-align: left; + text-decoration: none; + transition: text-decoration var(--transition-fast); +} + +.filePathChip:hover { + text-decoration: underline; +} + +.filePathChip.nonInteractive { + cursor: default; + color: var(--text-primary); +} + +.filePathChip.nonInteractive:hover { + text-decoration: none; +} + +.filePathChipPath { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + direction: rtl; /* keep filename visible when truncating long paths */ + text-align: left; + min-width: 0; +} + +/* URL link — same shape as the file link: borderless, transparent. */ +.urlChip { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-sm); + color: var(--color-primary); + background: transparent; + padding: 0; + text-decoration: none; + max-width: 100%; + transition: text-decoration var(--transition-fast); +} + +.urlChip:hover { + text-decoration: underline; +} + +.urlChipText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Generic code block — monospace + bg, optional language tag */ +.codeBlock { + position: relative; + margin: 0; + padding: var(--space-2) var(--space-3); + background: var(--bg-tertiary); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: var(--leading-relaxed); + color: var(--text-primary); + white-space: pre-wrap; + word-break: break-word; + max-height: 320px; + overflow-y: auto; +} + +.codeBlockHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + font-family: var(--font-sans, inherit); + font-size: var(--text-xs); + color: var(--text-muted); + margin-bottom: var(--space-1); +} + +.codeBlockLang { + font-weight: var(--font-medium); + text-transform: lowercase; +} + +/* Terminal-style output (run_python, run_shell) — darker bg, no max width */ +.terminal { + margin: 0; + padding: var(--space-2) var(--space-3); + background: #0b0b0b; + color: #e5e5e5; + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.55; + white-space: pre-wrap; + word-break: break-all; + max-height: 320px; + overflow-y: auto; +} + +/* stderr stays grayscale — italic + dimmed opacity is enough to set it apart + from stdout without relying on red. */ +.terminalStderr { + color: #e5e5e5; + opacity: 0.65; + font-style: italic; +} + +.terminalDim { + color: #6b7280; + font-style: italic; +} + +/* Result list (web_search, memory_search, grep) */ +.resultList { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.resultCard { + display: flex; + flex-direction: column; + gap: 2px; + padding: var(--space-2); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + text-decoration: none; + color: inherit; + border: 1px solid transparent; + cursor: pointer; + transition: border-color var(--transition-fast), background var(--transition-fast); +} + +.resultCard:hover { + border-color: var(--border-secondary); + background: var(--bg-secondary); +} + +.resultTitle { + font-size: var(--text-sm); + font-weight: var(--font-medium); + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resultUrl { + font-size: var(--text-xs); + color: var(--text-muted); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.resultSnippet { + font-size: var(--text-xs); + color: var(--text-secondary); + line-height: var(--leading-normal); + margin-top: 2px; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Image thumbnail — click to open fullscreen */ +.imageThumb { + display: block; + max-width: 240px; + max-height: 240px; + border-radius: var(--radius-md); + cursor: zoom-in; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + object-fit: contain; +} + +.imageThumbsRow { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +/* Fullscreen image overlay */ +.imageOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + cursor: zoom-out; + animation: imageFadeIn 0.12s ease-out; +} + +.imageOverlayImg { + max-width: 95vw; + max-height: 95vh; + border-radius: var(--radius-md); + object-fit: contain; +} + +@keyframes imageFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Status / meta pills */ +.metaRow { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + font-size: var(--text-xs); + color: var(--text-secondary); +} + +.metaPill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 1px var(--space-2); + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + color: var(--text-primary); +} + +/* Variant pills inherit the neutral .metaPill look — grayscale only. The + variant prop is preserved on the component API so renderers stay + semantically clear, but no color differentiation is applied. The + accompanying StatusIndicator icon already conveys success/error/etc. */ +.metaPillSuccess, +.metaPillError, +.metaPillWarn { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +/* Diff view (stream_edit) */ +.diffBlock { + margin: 0; + background: var(--bg-tertiary); + border-radius: var(--radius-md); + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.55; + overflow: hidden; + max-height: 360px; + overflow-y: auto; +} + +.diffLine { + display: block; + padding: 0 var(--space-3); + white-space: pre-wrap; + word-break: break-all; +} + +/* Conventional diff colors — red for removed, green for added. */ +.diffLineRemoved { + background: rgba(239, 68, 68, 0.15); + color: rgb(252, 165, 165); +} + +.diffLineAdded { + background: rgba(16, 185, 129, 0.15); + color: rgb(110, 231, 183); +} + +.diffLineRemoved::before { + content: '- '; + color: rgb(239, 68, 68); +} + +.diffLineAdded::before { + content: '+ '; + color: rgb(16, 185, 129); +} + +.diffSeparator { + padding: var(--space-1) var(--space-3); + color: var(--text-muted); + font-style: italic; + font-size: var(--text-xs); + border-top: 1px solid var(--border-primary); +} + +/* Directory tree — monospace listing with ├── / └── branch characters, + used by list_folder so the entries read like `tree` command output. */ +.dirTree { + font-family: var(--font-mono); + font-size: var(--text-xs); + line-height: 1.7; + color: var(--text-primary); + white-space: pre; +} + +.dirTreeBranch { + color: var(--text-muted); +} + +.dirTreeFolder { + color: var(--text-primary); + font-weight: var(--font-medium); +} + +/* Inline collapsible "show more" toggle */ +.expandToggle { + align-self: flex-start; + background: none; + border: none; + padding: 0; + color: var(--color-primary); + font-size: var(--text-xs); + cursor: pointer; + text-decoration: underline; +} + +.expandToggle:hover { + color: var(--color-primary-hover, var(--color-primary)); +} + +/* Empty placeholder when waiting for output */ +.pendingPlaceholder { + font-size: var(--text-xs); + color: var(--text-muted); + font-style: italic; +} diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx new file mode 100644 index 000000000..9286a6a6b --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx @@ -0,0 +1,349 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ExternalLink, File } from 'lucide-react' +import { Highlight, themes, type Language } from 'prism-react-renderer' +import { basename } from './parse' +import styles from './primitives.module.css' +import tasksStyles from '../TasksPage.module.css' + +// ───────────────────────────────────────────────────────────────────── +// Section wrapper used by renderers. Reuses TasksPage's .actionSection / +// .ioLabel so the renderer's sections sit alongside ActionBlock's own +// Error section + expanded detail panel with consistent borders (the +// .actionSection + .actionSection adjacent-sibling rule kicks in). +// ───────────────────────────────────────────────────────────────────── + +interface SectionProps { + label?: string + children: React.ReactNode +} + +export function Section({ label, children }: SectionProps) { + return ( +
+ {label &&
{label}
} + {children} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// File path chip: shows the basename prominently; full path on hover via +// the title attribute. Clickable when an onOpen handler is supplied. +// ───────────────────────────────────────────────────────────────────── + +interface FilePathChipProps { + path: string + onOpen?: (path: string) => void + showFull?: boolean // show full path instead of basename +} + +export function FilePathChip({ path, onOpen, showFull }: FilePathChipProps) { + const label = showFull ? path : basename(path) + const interactive = !!onOpen + return ( + + ) +} + +// ───────────────────────────────────────────────────────────────────── +// URL chip: opens in a new tab. +// ───────────────────────────────────────────────────────────────────── + +interface UrlChipProps { + url: string + label?: string +} + +export function UrlChip({ url, label }: UrlChipProps) { + return ( + + + ) +} + +// ───────────────────────────────────────────────────────────────────── +// CodeBlock: syntax-highlighted code via prism-react-renderer. Falls back +// to plain monospace when the language isn't recognised by Prism. +// ───────────────────────────────────────────────────────────────────── + +// Prism recognises a fixed set of language ids. Map our looser ids onto +// Prism's vocabulary; anything unknown is rendered without highlighting. +const PRISM_LANG_MAP: Record = { + python: 'python', + py: 'python', + javascript: 'javascript', + js: 'javascript', + typescript: 'typescript', + ts: 'typescript', + tsx: 'tsx', + jsx: 'jsx', + markdown: 'markdown', + md: 'markdown', + html: 'markup', + xml: 'markup', + css: 'css', + json: 'json', + yaml: 'yaml', + yml: 'yaml', + bash: 'bash', + sh: 'bash', + shell: 'bash', + sql: 'sql', + go: 'go', + rust: 'rust', + rs: 'rust', + ruby: 'ruby', + rb: 'ruby', + java: 'java', + c: 'c', + cpp: 'cpp', +} + +interface CodeBlockProps { + code: string + lang?: string +} + +export function CodeBlock({ code, lang }: CodeBlockProps) { + const prismLang = lang ? PRISM_LANG_MAP[lang.toLowerCase()] : undefined + + // No recognised language → plain monospace block. + if (!prismLang) { + return ( +
+ {lang && ( +
+ {lang} +
+ )} +
{code}
+
+ ) + } + + return ( +
+
+ {lang} +
+ + {({ className, style, tokens, getLineProps, getTokenProps }) => ( +
+            {tokens.map((line, i) => (
+              
+ {line.map((token, j) => ( + + ))} +
+ ))} +
+ )} +
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// Terminal: stdout + (optional) stderr, dark bg. +// ───────────────────────────────────────────────────────────────────── + +interface TerminalProps { + stdout?: string + stderr?: string +} + +export function Terminal({ stdout, stderr }: TerminalProps) { + const hasOut = !!(stdout && stdout.length > 0) + const hasErr = !!(stderr && stderr.length > 0) + if (!hasOut && !hasErr) { + return
(no output)
+ } + return ( +
+      {hasOut && {stdout}}
+      {hasOut && hasErr && '\n'}
+      {hasErr && {stderr}}
+    
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// ResultList / ResultCard for search-style outputs. +// ───────────────────────────────────────────────────────────────────── + +interface ResultCardProps { + title: string + url?: string + snippet?: string + onClick?: () => void +} + +export function ResultCard({ title, url, snippet, onClick }: ResultCardProps) { + const content = ( + <> +
{title}
+ {url &&
{url}
} + {snippet &&
{snippet}
} + + ) + if (url) { + return ( + + {content} + + ) + } + return ( +
+ {content} +
+ ) +} + +export function ResultList({ children }: { children: React.ReactNode }) { + return
{children}
+} + +// ───────────────────────────────────────────────────────────────────── +// ImageThumbnail: click → fullscreen overlay (close on click or Esc). +// ───────────────────────────────────────────────────────────────────── + +interface ImageThumbnailProps { + src: string + alt?: string +} + +export function ImageThumbnail({ src, alt }: ImageThumbnailProps) { + const [open, setOpen] = useState(false) + + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) } + document.addEventListener('keydown', handler) + return () => document.removeEventListener('keydown', handler) + }, [open]) + + return ( + <> + {alt setOpen(true)} + loading="lazy" + /> + {open && ( +
setOpen(false)}> + {alt +
+ )} + + ) +} + +export function ImageThumbnailRow({ srcs, alt }: { srcs: string[]; alt?: string }) { + return ( +
+ {srcs.map((s, i) => )} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// Meta pills: small status/info chips (status_code, exit_code, etc.). +// ───────────────────────────────────────────────────────────────────── + +type PillVariant = 'default' | 'success' | 'error' | 'warn' + +export function MetaPill({ children, variant = 'default' }: { children: React.ReactNode; variant?: PillVariant }) { + const cls = + variant === 'success' ? `${styles.metaPill} ${styles.metaPillSuccess}` : + variant === 'error' ? `${styles.metaPill} ${styles.metaPillError}` : + variant === 'warn' ? `${styles.metaPill} ${styles.metaPillWarn}` : + styles.metaPill + return {children} +} + +export function MetaRow({ children }: { children: React.ReactNode }) { + return
{children}
+} + +// ───────────────────────────────────────────────────────────────────── +// DiffView: minimal old/new diff rendered as added/removed lines, with +// a "..." separator between them. We're not running a real Myers diff +// here — stream_edit gives us the literal old_string / new_string pair, +// so showing them as a remove-block-then-add-block is honest about what +// the action replaces. +// ───────────────────────────────────────────────────────────────────── + +interface DiffViewProps { + oldText: string + newText: string +} + +export function DiffView({ oldText, newText }: DiffViewProps) { + const oldLines = useMemo(() => oldText.split('\n'), [oldText]) + const newLines = useMemo(() => newText.split('\n'), [newText]) + return ( +
+ {oldLines.map((line, i) => ( + {line || ' '} + ))} + {newLines.map((line, i) => ( + {line || ' '} + ))} +
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// Show-more toggle for long bodies (markdown previews etc.). +// ───────────────────────────────────────────────────────────────────── + +interface CollapsibleProps { + text: string + collapsedLines?: number +} + +export function Collapsible({ text, collapsedLines = 5 }: CollapsibleProps) { + const [expanded, setExpanded] = useState(false) + const lines = text.split('\n') + const isLong = lines.length > collapsedLines + const shown = expanded || !isLong ? text : lines.slice(0, collapsedLines).join('\n') + '\n…' + + return ( + <> +
{shown}
+ {isLong && ( + + )} + + ) +} + +export function Pending({ label }: { label?: string }) { + return
{label ?? 'Waiting for result…'}
+} + +// Re-export the styles object so renderer files in this folder can also use +// a few specific classes (e.g. .section) for ad-hoc layouts. +export { styles as primStyles } diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx new file mode 100644 index 000000000..5f26b7674 --- /dev/null +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx @@ -0,0 +1,658 @@ +import React from 'react' +import { Square, CheckSquare } from 'lucide-react' +import type { ActionItem } from '../../../types' +import { + Section, FilePathChip, UrlChip, CodeBlock, Terminal, ResultList, ResultCard, + ImageThumbnail, ImageThumbnailRow, MetaPill, MetaRow, DiffView, Collapsible, + Pending, primStyles, +} from './primitives' +import { strField, arrField, dictField, langFromPath, parseDict } from './parse' + +// Renderer contract: receives the action item + pre-parsed input/output dicts +// (each is null if absent or unparseable). Returns the JSX that goes inside +// .actionBox (replacing the default Input/Output sections). The error section +// and "More detail" expansion are still added by ActionBlock. +export interface ActionRendererProps { + item: ActionItem + inputObj: Record | null + outputObj: Record | null + onOpenFile: (path: string) => void +} + +export type ActionRenderer = React.FC + +// Turn a workspace-side absolute or relative path into a URL the static +// `/api/workspace/{path}` handler can serve. The server's _validate_path +// rejects anything outside the workspace, so external paths will 404 here — +// the onError fallback renders a placeholder instead of a broken icon. +function toWorkspaceUrl(absPath: string): string { + const normalized = absPath.replace(/\\/g, '/').replace(/^\/+/, '') + return `/api/workspace/${encodeURI(normalized)}` +} + +// ───────────────────────────────────────────────────────────────────── +// FILE OPS +// ───────────────────────────────────────────────────────────────────── + +const StreamEditRenderer: ActionRenderer = ({ inputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const oldStr = strField(inputObj, 'old_string') ?? '' + const newStr = strField(inputObj, 'new_string') ?? '' + + return ( + <> +
+ {filePath + ? + : } +
+
+ {oldStr || newStr + ? + : } +
+ + ) +} + +const WriteFileRenderer: ActionRenderer = ({ inputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const content = strField(inputObj, 'content') ?? '' + + return ( + <> +
+ {filePath + ? + : } +
+
+ {content + ? + : } +
+ + ) +} + +const ReadFileRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const content = strField(outputObj, 'content') + + return ( + <> +
+ {filePath + ? + : } +
+
+ {content + ? + : } +
+ + ) +} + +const FindFilesRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const pattern = strField(inputObj, 'pattern') ?? '' + const matches = (arrField(outputObj, 'matches') ?? []).filter((m): m is string => typeof m === 'string') + + return ( + <> +
+ {pattern ? {pattern} : } +
+
+ {!outputObj + ? + : matches.length === 0 + ? + : ( +
+ {matches.map((m, i) => )} +
+ )} +
+ + ) +} + +const ListFolderRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const path = strField(inputObj, 'path') ?? '' + const contents = (arrField(outputObj, 'contents') ?? []).filter((c): c is string => typeof c === 'string') + // list_folder returns names only, not types — fall back to a simple + // extension heuristic so folder entries get a trailing slash. + const sorted = [...contents].sort((a, b) => { + const aIsFolder = !a.includes('.') + const bIsFolder = !b.includes('.') + if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1 + return a.localeCompare(b) + }) + + return ( + <> +
+ {path + ? + : } +
+
+ {!outputObj + ? + : sorted.length === 0 + ? + : ( +
+ {sorted.map((name, i) => { + const isLast = i === sorted.length - 1 + const branch = isLast ? '└── ' : '├── ' + const isFolder = !name.includes('.') + return ( +
+ {branch} + + {isFolder ? `${name}/` : name} + +
+ ) + })} +
+ )} +
+ + ) +} + +const CreatePdfRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const content = strField(inputObj, 'content') ?? '' + const outPath = strField(outputObj, 'path') ?? filePath + + return ( + <> +
+ {outPath + ? + : } +
+
+ {content + ? + : } +
+ + ) +} + +const ReadPdfRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const filePath = strField(inputObj, 'file_path') ?? '' + const contentObj = dictField(outputObj, 'content') + const elements = arrField(contentObj, 'elements') ?? [] + // Concatenate the extracted text from each layout element so the user sees + // the file's actual content, not just page counts. + const text = elements + .map(el => (el && typeof el === 'object' && !Array.isArray(el) + ? strField(el as Record, 'text') + : null)) + .filter((t): t is string => !!t) + .join('\n') + + return ( + <> +
+ {filePath + ? + : } +
+
+ {!outputObj + ? + : text + ? + : } +
+ + ) +} + +const ConvertToMarkdownRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const input = strField(inputObj, 'input_file') ?? '' + const output = strField(outputObj, 'md_file') ?? strField(inputObj, 'output_md') ?? '' + + return ( + <> +
+ {input + ? + : } +
+
+ {output + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// CODE EXECUTION +// ───────────────────────────────────────────────────────────────────── + +const RunPythonRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const code = strField(inputObj, 'code') ?? '' + const stdout = strField(outputObj, 'stdout') ?? '' + const stderr = strField(outputObj, 'stderr') ?? '' + + return ( + <> +
+ {code + ? + : } +
+
+ {outputObj + ? + : } +
+ + ) +} + +const RunShellRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const command = strField(inputObj, 'command') ?? '' + const stdout = strField(outputObj, 'stdout') ?? '' + const stderr = strField(outputObj, 'stderr') ?? '' + + return ( + <> +
+ {command + ? + : } +
+
+ {outputObj + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// WEB +// ───────────────────────────────────────────────────────────────────── + +const WebSearchRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const query = strField(inputObj, 'query') ?? '' + const results = (arrField(outputObj, 'results') ?? []).filter( + (r): r is Record => !!r && typeof r === 'object' && !Array.isArray(r), + ) + + return ( + <> +
+ {query ?
{query}
: } +
+
+ {!outputObj + ? + : results.length === 0 + ? + : ( + + {results.map((r, i) => ( + + ))} + + )} +
+ + ) +} + +const WebFetchRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const url = strField(inputObj, 'url') ?? '' + const title = strField(outputObj, 'title') + const content = strField(outputObj, 'content') ?? '' + + return ( + <> +
+ {url + ? + : } +
+
+ {!outputObj + ? + : content + ? + : } +
+ + ) +} + +const HttpRequestRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const method = (strField(inputObj, 'method') ?? 'GET').toUpperCase() + const url = strField(inputObj, 'url') ?? '' + const body = strField(inputObj, 'data') + ?? (inputObj?.json ? JSON.stringify(inputObj.json, null, 2) : '') + const respBody = strField(outputObj, 'body') ?? '' + + return ( + <> +
+ + {method} + {url && } + + {body && } +
+
+ {!outputObj + ? + : respBody + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// MEDIA +// ───────────────────────────────────────────────────────────────────── + +const GenerateImageRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const prompt = strField(inputObj, 'prompt') ?? '' + const paths = (arrField(outputObj, 'image_paths') ?? []).filter((p): p is string => typeof p === 'string') + + return ( + <> +
+ {prompt ?
{prompt}
: } +
+
+ {!outputObj + ? + : paths.length === 0 + ? + : } +
+ + ) +} + +const DescribeImageRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const imagePath = strField(inputObj, 'image_path') ?? '' + const prompt = strField(inputObj, 'prompt') + const description = strField(outputObj, 'description') ?? '' + + return ( + <> +
+ {imagePath + ? + : } + {prompt &&
{prompt}
} +
+
+ {!outputObj + ? + : description + ? + : } +
+ + ) +} + +const PerformOcrRenderer: ActionRenderer = ({ inputObj, outputObj }) => { + const imagePath = strField(inputObj, 'image_path') ?? '' + const summary = strField(outputObj, 'summary') ?? '' + + return ( + <> +
+ {imagePath + ? + : } +
+
+ {!outputObj + ? + : summary + ? + : } +
+ + ) +} + +const UnderstandVideoRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const videoPath = strField(inputObj, 'video_path') ?? '' + const query = strField(inputObj, 'query') + const summary = strField(outputObj, 'summary') ?? '' + + return ( + <> +
+ {videoPath + ? + : } + {query &&
{query}
} +
+
+ {!outputObj + ? + : summary + ? + : } +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// SEARCH +// ───────────────────────────────────────────────────────────────────── + +const GrepFilesRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const pattern = strField(inputObj, 'pattern') ?? '' + const mode = strField(inputObj, 'output_mode') ?? 'files_with_matches' + const filenames = (arrField(outputObj, 'filenames') ?? []).filter((f): f is string => typeof f === 'string') + const content = strField(outputObj, 'content') + + return ( + <> +
+ {pattern ? {pattern} : } +
+
+ {!outputObj + ? + : mode === 'content' && content + ? + : filenames.length > 0 + ? ( +
+ {filenames.map((f, i) => )} +
+ ) + : } +
+ + ) +} + +const MemorySearchRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile }) => { + const query = strField(inputObj, 'query') ?? '' + const results = (arrField(outputObj, 'results') ?? []).filter( + (r): r is Record => !!r && typeof r === 'object' && !Array.isArray(r), + ) + + return ( + <> +
+ {query ?
{query}
: } +
+
+ {!outputObj + ? + : results.length === 0 + ? + : ( + + {results.map((r, i) => { + const title = strField(r, 'title') ?? strField(r, 'section_path') ?? '(memory)' + const filePath = strField(r, 'file_path') + const summary = strField(r, 'summary') + return ( + onOpenFile(filePath) : undefined} + /> + ) + })} + + )} +
+ + ) +} + +// ───────────────────────────────────────────────────────────────────── +// TASK UPDATE TODOS +// ───────────────────────────────────────────────────────────────────── + +interface TodoEntry { content: string; status: string } + +function extractTodos(inputObj: Record | null): TodoEntry[] | null { + if (!inputObj) return null + const raw = inputObj.todos + let items: unknown[] | null = null + if (Array.isArray(raw)) items = raw + else if (typeof raw === 'string') { + try { + const norm = raw + .replace(/\bTrue\b/g, 'true') + .replace(/\bFalse\b/g, 'false') + .replace(/\bNone\b/g, 'null') + .replace(/'/g, '"') + const parsed = JSON.parse(norm) + if (Array.isArray(parsed)) items = parsed + } catch { /* fall through */ } + } + if (!items) return null + const todos: TodoEntry[] = [] + for (const it of items) { + if (it && typeof it === 'object') { + const obj = it as Record + if (typeof obj.content === 'string' && typeof obj.status === 'string') { + todos.push({ content: obj.content, status: obj.status }) + } + } + } + return todos.length > 0 ? todos : null +} + +const TaskUpdateTodosRenderer: ActionRenderer = ({ inputObj }) => { + const todos = extractTodos(inputObj) + if (!todos) return
+ return ( +
+
    + {todos.map((t, i) => { + const done = t.status === 'completed' + const inProgress = t.status === 'in_progress' + return ( +
  • + + {done ? : } + + {t.content} +
  • + ) + })} +
+
+ ) +} + +// ───────────────────────────────────────────────────────────────────── +// REGISTRY +// ───────────────────────────────────────────────────────────────────── + +const REGISTRY: Record = { + // file ops + stream_edit: StreamEditRenderer, + write_file: WriteFileRenderer, + read_file: ReadFileRenderer, + find_files: FindFilesRenderer, + list_folder: ListFolderRenderer, + create_pdf: CreatePdfRenderer, + read_pdf: ReadPdfRenderer, + convert_to_markdown: ConvertToMarkdownRenderer, + // code execution + run_python: RunPythonRenderer, + run_shell: RunShellRenderer, + // web + web_search: WebSearchRenderer, + web_fetch: WebFetchRenderer, + http_request: HttpRequestRenderer, + // media + generate_image: GenerateImageRenderer, + describe_image: DescribeImageRenderer, + perform_ocr: PerformOcrRenderer, + understand_video: UnderstandVideoRenderer, + // search + grep_files: GrepFilesRenderer, + memory_search: MemorySearchRenderer, + // todos + task_update_todos: TaskUpdateTodosRenderer, +} + +// Look up a renderer by action name. The name comparison is loose — names +// arrive as either snake_case (canonical) or with spaces/dashes from skill +// metadata, so we normalize them. +export function getActionRenderer(name: string | undefined): ActionRenderer | null { + if (!name) return null + const normalized = name.toLowerCase().replace(/[\s-]+/g, '_') + return REGISTRY[normalized] ?? null +} + +// Convenience: parse both input and output as dicts. Returned null on parse +// failure so callers can fall back to the generic ContentDisplay. +export function parseIO(item: ActionItem) { + return { + inputObj: parseDict(item.input), + outputObj: parseDict(item.output), + } +} From 20070e9c8c51169cdfc2830002fd7e57f054dd5e Mon Sep 17 00:00:00 2001 From: CraftBot Date: Sat, 23 May 2026 18:42:13 +0900 Subject: [PATCH 04/12] feature:added action renderer --- .../src/pages/Tasks/TasksPage.module.css | 10 ++- .../src/pages/Tasks/actionRenderers/parse.ts | 12 +++ .../actionRenderers/primitives.module.css | 27 +++++-- .../Tasks/actionRenderers/primitives.tsx | 12 +++ .../pages/Tasks/actionRenderers/renderers.tsx | 79 +++++++++++++++++-- 5 files changed, 126 insertions(+), 14 deletions(-) diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css index da9340587..eab960504 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/TasksPage.module.css @@ -436,7 +436,7 @@ position: relative; display: flex; flex-direction: column; - gap: var(--space-3); + gap: var(--space-5); } .emptyTranscript { @@ -478,7 +478,7 @@ position: absolute; left: 11px; top: 22px; - bottom: calc(-1 * var(--space-3)); + bottom: calc(-1 * var(--space-5)); border-left: 1px dashed var(--border-secondary); pointer-events: none; z-index: 0; @@ -590,6 +590,12 @@ display: flex; flex-direction: column; gap: var(--space-1); + /* Single body-text size for everything inside the section. Labels, pills, + captions, and other "meta" elements opt back into text-xs via their own + class. Without this rule, plain text divs (like prompts/queries) inherit + the page default and end up visibly larger than the chips/links beside + them. */ + font-size: var(--text-sm); } /* Separator between adjacent IO sections. The expanded detail panel sits diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts index 7cb66b6a0..ad2852f17 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/parse.ts @@ -147,6 +147,18 @@ export function strField(obj: Record | null, key: string): stri return typeof v === 'string' ? v : undefined } +export function boolField(obj: Record | null, key: string): boolean | undefined { + const v = obj?.[key] + if (typeof v === 'boolean') return v + // Python `True`/`False` may come through as raw word tokens when parseDict + // bailed out — treat them defensively. + if (typeof v === 'string') { + if (v === 'True' || v === 'true') return true + if (v === 'False' || v === 'false') return false + } + return undefined +} + export function arrField(obj: Record | null, key: string): unknown[] | null { return parseArray(obj?.[key]) } diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css index e7aba1093..9920de3e2 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.module.css @@ -76,7 +76,7 @@ background: var(--bg-tertiary); border-radius: var(--radius-md); font-family: var(--font-mono); - font-size: var(--text-xs); + font-size: var(--text-sm); line-height: var(--leading-relaxed); color: var(--text-primary); white-space: pre-wrap; @@ -109,7 +109,7 @@ color: #e5e5e5; border-radius: var(--radius-md); font-family: var(--font-mono); - font-size: var(--text-xs); + font-size: var(--text-sm); line-height: 1.55; white-space: pre-wrap; word-break: break-all; @@ -175,7 +175,7 @@ } .resultSnippet { - font-size: var(--text-xs); + font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-normal); margin-top: 2px; @@ -266,7 +266,7 @@ background: var(--bg-tertiary); border-radius: var(--radius-md); font-family: var(--font-mono); - font-size: var(--text-xs); + font-size: var(--text-sm); line-height: 1.55; overflow: hidden; max-height: 360px; @@ -313,7 +313,7 @@ used by list_folder so the entries read like `tree` command output. */ .dirTree { font-family: var(--font-mono); - font-size: var(--text-xs); + font-size: var(--text-sm); line-height: 1.7; color: var(--text-primary); white-space: pre; @@ -344,9 +344,22 @@ color: var(--color-primary-hover, var(--color-primary)); } -/* Empty placeholder when waiting for output */ +/* Pending state text and image/video captions (the user's prompt/query + shown below the media). Same size as body content; italic+muted gives a + gentle hierarchy hint without breaking the uniform sizing. */ .pendingPlaceholder { - font-size: var(--text-xs); + font-size: var(--text-sm); color: var(--text-muted); font-style: italic; } + +/* Subtle "wait_for_user_reply" hint for the message renderers — a small + muted line describing the message's configuration, not the agent's + current state, so it stays accurate after the user has replied. */ +.waitForReplyPill { + display: inline-block; + margin-top: var(--space-1); + font-size: var(--text-xs); + font-style: italic; + color: var(--text-muted); +} diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx index 9286a6a6b..d8adf9025 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/primitives.tsx @@ -344,6 +344,18 @@ export function Pending({ label }: { label?: string }) { return
{label ?? 'Waiting for result…'}
} +// Subtle hint that the message action was sent with wait_for_user_reply +// set to true. Phrased as a description of the message itself ("this message +// waits …"), not the agent's current state — so it stays accurate even +// after the user has replied. +export function WaitForReplyPill() { + return ( + + This message waits for user's reply + + ) +} + // Re-export the styles object so renderer files in this folder can also use // a few specific classes (e.g. .section) for ad-hoc layouts. export { styles as primStyles } diff --git a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx index 5f26b7674..f43012403 100644 --- a/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx +++ b/app/ui_layer/browser/frontend/src/pages/Tasks/actionRenderers/renderers.tsx @@ -4,9 +4,9 @@ import type { ActionItem } from '../../../types' import { Section, FilePathChip, UrlChip, CodeBlock, Terminal, ResultList, ResultCard, ImageThumbnail, ImageThumbnailRow, MetaPill, MetaRow, DiffView, Collapsible, - Pending, primStyles, + Pending, WaitForReplyPill, primStyles, } from './primitives' -import { strField, arrField, dictField, langFromPath, parseDict } from './parse' +import { strField, boolField, arrField, dictField, langFromPath, parseDict } from './parse' // Renderer contract: receives the action item + pre-parsed input/output dicts // (each is null if absent or unparseable). Returns the JSX that goes inside @@ -534,6 +534,71 @@ const MemorySearchRenderer: ActionRenderer = ({ inputObj, outputObj, onOpenFile ) } +// ───────────────────────────────────────────────────────────────────── +// MESSAGING + TASK CONTROL +// ───────────────────────────────────────────────────────────────────── + +const SendMessageRenderer: ActionRenderer = ({ inputObj }) => { + const message = strField(inputObj, 'message') ?? '' + const waitForReply = boolField(inputObj, 'wait_for_user_reply') + + return ( +
+ {message + ?
{message}
+ : } + {waitForReply && } +
+ ) +} + +const SendMessageWithAttachmentRenderer: ActionRenderer = ({ inputObj, onOpenFile }) => { + const message = strField(inputObj, 'message') ?? '' + const waitForReply = boolField(inputObj, 'wait_for_user_reply') + const filePaths = (arrField(inputObj, 'file_paths') ?? []) + .filter((p): p is string => typeof p === 'string') + + return ( + <> +
+ {message + ?
{message}
+ : } + {waitForReply && } +
+
+ {filePaths.length === 0 + ? + : ( +
+ {filePaths.map((p, i) => )} +
+ )} +
+ + ) +} + +const TaskEndRenderer: ActionRenderer = ({ inputObj }) => { + const reason = strField(inputObj, 'reason') ?? '' + const summary = strField(inputObj, 'summary') ?? '' + + return ( + <> + {reason && ( +
+
{reason}
+
+ )} +
+ {summary + ?
{summary}
+ : } +
+ + ) +} + // ───────────────────────────────────────────────────────────────────── // TASK UPDATE TODOS // ───────────────────────────────────────────────────────────────────── @@ -574,7 +639,7 @@ const TaskUpdateTodosRenderer: ActionRenderer = ({ inputObj }) => { if (!todos) return
return (
-