diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a7506e9171..8b9948fa61 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -728,6 +728,8 @@ Recommended workflow: ordinary chains stay as `Blocks N` so noise stays low, hig ### Logs → Agent Log view +The **Chat** tab sits between Definition and Logs and presents a live, chat-styled transcript of task agent output. Consecutive entries are grouped by role and labeled as Planner, Executor, Reviewer, or Merger; legacy log rows without an agent role use the neutral Agent fallback. The transcript follows new live output when you are already near the bottom, but it preserves your scroll position when you review older messages. For active `in-progress` tasks with an assigned agent session, the composer sends guidance to the running agent through the same steering path used by comments; when no active session is available, the composer is disabled with an explanatory hint. + The **Logs** tab includes an **Agent Log** subview designed for debugging long-running and tool-heavy sessions: - Full `thinking`, `tool_result`, and `tool_error` payloads are shown without entry-content truncation. diff --git a/packages/dashboard/app/components/AgentLogViewer.tsx b/packages/dashboard/app/components/AgentLogViewer.tsx index bb37865633..c71229ca4c 100644 --- a/packages/dashboard/app/components/AgentLogViewer.tsx +++ b/packages/dashboard/app/components/AgentLogViewer.tsx @@ -48,7 +48,7 @@ function formatTimestamp(iso: string, t: TFunction<"app">): string { return date.toLocaleDateString(); } -const markdownComponents: Components = { +export const markdownComponents: Components = { p: ({ children, ...props }) =>

{linkifyReactChildren(children)}

, li: ({ children, ...props }) =>
  • {linkifyReactChildren(children)}
  • , code: ({ children, ...props }) => { diff --git a/packages/dashboard/app/components/ChatView.tsx b/packages/dashboard/app/components/ChatView.tsx index d15aeb38a3..c847d96710 100644 --- a/packages/dashboard/app/components/ChatView.tsx +++ b/packages/dashboard/app/components/ChatView.tsx @@ -48,6 +48,13 @@ import { matchesAgentMentionFilter } from "./mentionMatching"; import { useNavigationHistoryContext } from "../hooks/useNavigationHistory"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; import { recordResumeEvent } from "../utils/resumeInstrumentation"; +import { + CHAT_INPUT_MAX_HEIGHT_PX, + TABLET_INPUT_MAX_HEIGHT_PX, + clampChatInputHeight, + resolveChatInputOverflowY, +} from "../utils/chatInputAutosize"; +export { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -57,28 +64,10 @@ export interface ChatViewProps { experimentalFeatures?: Record; } -// Keep a generous cap so pasted multi-paragraph text stays visible while -// still preventing the composer from overtaking the message pane on short viewports. -const CHAT_INPUT_MAX_HEIGHT_PX = 640; -const TABLET_INPUT_MAX_HEIGHT_PX = 200; /** Canonical definition lives in packages/dashboard/src/chat.ts (ROOM_SKIP_SENTINEL). */ const ROOM_SKIP_SENTINEL = "__SKIP__"; let chatViewWasPreviouslyInactive = false; -export function resolveChatInputOverflowY( - scrollHeight: number, - maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX, -): "auto" | "hidden" { - return scrollHeight > maxHeight ? "auto" : "hidden"; -} - -export function clampChatInputHeight(scrollHeight: number, maxHeight: number = CHAT_INPUT_MAX_HEIGHT_PX): number { - // Floor matches QuickChat (clampQuickChatInputHeight) and the CSS min-height, - // so a 0-scrollHeight measurement (e.g. before layout) still yields a - // sensible inline height instead of collapsing the composer to 0. - return Math.max(40, Math.min(scrollHeight, maxHeight)); -} - function formatRelativeTime(dateStr: string, t: TFunction<"app">): string { const date = new Date(dateStr); const now = new Date(); diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css new file mode 100644 index 0000000000..41fb5ada7a --- /dev/null +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -0,0 +1,193 @@ +.task-chat-tab { + display: flex; + flex-direction: column; + gap: var(--space-md); + min-height: 0; +} + +.task-chat-transcript { + display: flex; + flex-direction: column; + gap: var(--space-lg); + min-height: 0; + max-height: var(--task-chat-transcript-max-height, 70vh); + overflow-y: auto; + padding: var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-secondary); +} + +.task-chat-empty { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + min-height: calc(var(--space-2xl) * 3); + padding: var(--space-xl); + color: var(--text-muted); + text-align: center; +} + +.task-chat-group { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: var(--space-sm); +} + +.task-chat-group-header { + display: flex; + align-items: center; + align-self: start; + gap: var(--space-sm); + min-width: min(calc(var(--space-2xl) * 4), 32vw); + color: var(--text-muted); +} + +.task-chat-avatar { + flex: 0 0 auto; +} + +.task-chat-role-label { + color: var(--text); + font-weight: 600; +} + +.task-chat-group-meta { + color: var(--text-muted); + font-size: var(--space-md); +} + +.task-chat-group-bubbles { + display: flex; + min-width: 0; + flex-direction: column; + gap: var(--space-sm); +} + +.task-chat-entry { + min-width: 0; + padding: var(--space-sm) var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--text); + overflow-wrap: anywhere; +} + +.task-chat-entry--thinking { + border-color: color-mix(in srgb, var(--color-warning) 35%, var(--border)); + background: color-mix(in srgb, var(--color-warning) 8%, var(--surface)); +} + +.task-chat-entry--tool { + background: var(--bg-tertiary); +} + +.task-chat-entry--tool-error { + border-color: color-mix(in srgb, var(--color-error) 45%, var(--border)); + background: color-mix(in srgb, var(--color-error) 8%, var(--surface)); +} + +.task-chat-entry-kicker { + margin-bottom: var(--space-xs); + color: var(--text-muted); + font-size: var(--space-md); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.task-chat-entry--thinking .task-chat-entry-kicker { + color: var(--color-warning); +} + +.task-chat-entry--tool-error .task-chat-entry-kicker { + color: var(--color-error); +} + +.task-chat-entry-text { + white-space: pre-wrap; +} + +.task-chat-markdown > :first-child, +.task-chat-markdown p:first-child { + margin-top: 0; +} + +.task-chat-markdown > :last-child, +.task-chat-markdown p:last-child { + margin-bottom: 0; +} + +.task-chat-tool-detail { + margin: var(--space-sm) 0 0; + max-width: 100%; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.task-chat-composer { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: var(--space-md); +} + +.task-chat-session-hint { + color: var(--text-muted); + font-size: var(--space-md); +} + +.task-chat-composer-row { + display: flex; + align-items: flex-end; + gap: var(--space-sm); +} + +.task-chat-input { + min-height: calc(var(--space-2xl) + var(--space-sm)); + max-height: var(--task-chat-composer-max-height, 40vh); + resize: none; + flex: 1 1 auto; +} + +.task-chat-send { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +@media (max-width: 768px) { + .task-chat-tab { + gap: var(--space-sm); + } + + .task-chat-transcript { + max-height: var(--task-chat-transcript-mobile-max-height, 62vh); + padding: var(--space-sm); + } + + .task-chat-group { + grid-template-columns: 1fr; + } + + .task-chat-group-header { + min-width: 0; + } + + .task-chat-composer { + padding: var(--space-sm); + } + + .task-chat-composer-row { + flex-direction: column; + align-items: stretch; + } + + .task-chat-send { + justify-content: center; + } +} diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx new file mode 100644 index 0000000000..dab24982f6 --- /dev/null +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -0,0 +1,276 @@ +import type { AgentLogEntry, AgentRole, Task, TaskDetail } from "@fusion/core"; +import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { Loader2, Send } from "lucide-react"; +import { addSteeringComment } from "../api"; +import { useAgentLogs } from "../hooks/useAgentLogs"; +import type { ToastType } from "../hooks/useToast"; +import { getErrorMessage } from "@fusion/core"; +import { linkifyFilePaths } from "../utils/filePathLinkify"; +import { AgentAvatar } from "./AgentAvatar"; +import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; +import { markdownComponents } from "./AgentLogViewer"; +import "./TaskChatTab.css"; + +interface TaskChatTabProps { + task: Task | TaskDetail; + projectId?: string; + active: boolean; + addToast: (msg: string, type?: ToastType) => void; +} + +type AgentLogRole = AgentRole | undefined; + +interface AgentLogGroup { + role: AgentLogRole; + label: string; + entries: AgentLogEntry[]; +} + +const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "merging-fix"]); +const BOTTOM_FOLLOW_THRESHOLD = 48; + +function getRoleLabel(role: AgentLogRole): string { + switch (role) { + case "triage": + return "Planner"; + case "executor": + return "Executor"; + case "reviewer": + return "Reviewer"; + case "merger": + return "Merger"; + default: + return "Agent"; + } +} + +function getRoleIcon(role: AgentLogRole): string | undefined { + switch (role) { + case "triage": + return "🧭"; + case "executor": + return "⚙️"; + case "reviewer": + return "🔎"; + case "merger": + return "🔀"; + default: + return undefined; + } +} + +function getEntryKey(entry: AgentLogEntry, index: number): string { + return [entry.taskId, entry.timestamp, entry.agent ?? "agent", entry.type, index].join(":"); +} + +function groupEntriesByAgent(entries: AgentLogEntry[]): AgentLogGroup[] { + return entries.reduce((groups, entry) => { + const previousGroup = groups[groups.length - 1]; + const role = entry.agent; + if (previousGroup && previousGroup.role === role) { + previousGroup.entries.push(entry); + return groups; + } + groups.push({ role, label: getRoleLabel(role), entries: [entry] }); + return groups; + }, []); +} + +function isActiveAgentSession(task: Task | TaskDetail): boolean { + const hasAssignedAgent = Boolean(task.assignedAgentId || task.checkedOutBy); + const statusAllowsSteering = !task.status || ACTIVE_STATUSES.has(task.status); + return task.column === "in-progress" + && hasAssignedAgent + && statusAllowsSteering + && !task.paused + && !task.userPaused; +} + +function formatEntryLabel(entry: AgentLogEntry): string { + switch (entry.type) { + case "tool": + return "Tool call"; + case "tool_result": + return "Tool result"; + case "tool_error": + return "Tool error"; + case "thinking": + return "Thinking"; + default: + return "Message"; + } +} + +function TaskChatEntry({ entry }: { entry: AgentLogEntry }) { + const isToolEntry = entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; + const className = [ + "task-chat-entry", + `task-chat-entry--${entry.type.replace("_", "-")}`, + isToolEntry ? "task-chat-entry--tool" : "", + ].filter(Boolean).join(" "); + + if (isToolEntry) { + return ( +
    +
    {formatEntryLabel(entry)}
    +
    {entry.text}
    + {entry.detail ?
    {linkifyFilePaths(entry.detail)}
    : null} +
    + ); + } + + return ( +
    + {entry.type === "thinking" ?
    {formatEntryLabel(entry)}
    : null} +
    + + {entry.text} + +
    +
    + ); +} + +export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabProps) { + const { entries, loading } = useAgentLogs(task.id, active, projectId); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + const transcriptRef = useRef(null); + const previousEntryCountRef = useRef(0); + const previousScrollHeightRef = useRef(0); + const textareaRef = useRef(null); + + const groups = useMemo(() => groupEntriesByAgent(entries), [entries]); + const activeSession = isActiveAgentSession(task); + const canSend = activeSession && draft.trim().length > 0 && !sending; + + const resizeComposer = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.style.height = "0"; + const maxHeight = typeof window !== "undefined" && window.matchMedia?.("(max-width: 768px)").matches ? 200 : undefined; + const nextHeight = clampChatInputHeight(textarea.scrollHeight, maxHeight); + textarea.style.height = `${nextHeight}px`; + textarea.style.overflowY = resolveChatInputOverflowY(textarea.scrollHeight, maxHeight); + }, []); + + useLayoutEffect(() => { + resizeComposer(); + }, [draft, resizeComposer]); + + useLayoutEffect(() => { + const container = transcriptRef.current; + if (!container) return; + + const previousCount = previousEntryCountRef.current; + const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight; + if (entries.length > previousCount) { + const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; + if (shouldFollow) { + container.scrollTop = container.scrollHeight; + } + } + + previousEntryCountRef.current = entries.length; + previousScrollHeightRef.current = container.scrollHeight; + }, [entries]); + + const handleTranscriptScroll = useCallback(() => { + const container = transcriptRef.current; + if (!container) return; + previousScrollHeightRef.current = container.scrollHeight; + }, []); + + const handleSubmit = useCallback(async (event?: React.FormEvent) => { + event?.preventDefault(); + const text = draft.trim(); + if (!text || !activeSession || sending) return; + + setSending(true); + try { + await addSteeringComment(task.id, text, projectId); + setDraft(""); + } catch (error) { + addToast(`Unable to send message: ${getErrorMessage(error)}`, "error"); + } finally { + setSending(false); + } + }, [activeSession, addToast, draft, projectId, sending, task.id]); + + const handleKeyDown = useCallback((event: React.KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === "Enter") { + void handleSubmit(); + } + }, [handleSubmit]); + + return ( +
    +
    + {loading && entries.length === 0 ? ( +
    +
    + ) : entries.length === 0 ? ( +
    No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.
    + ) : ( + groups.map((group, groupIndex) => { + const avatarAgent = { + id: group.role ?? "agent", + name: group.label, + icon: getRoleIcon(group.role), + }; + return ( +
    +
    + +
    +
    {group.label}
    +
    {group.entries.length === 1 ? "1 entry" : `${group.entries.length} entries`}
    +
    +
    +
    + {group.entries.map((entry, entryIndex) => ( + + ))} +
    +
    + ); + }) + )} +
    + +
    + {!activeSession ? ( +
    + No active assigned agent session is available. Move the task into progress with an assigned agent to send guidance. +
    + ) : null} +
    +