diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5c02ae9896..02c82c2576 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -728,7 +728,7 @@ 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 opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, 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 **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. Consecutive tool/tool-result/tool-error rows inside a role group collapse into one expandable tool-call summary that stays collapsed by default, while thinking entries render in a collapsible block that starts expanded. The transcript opens at the latest output whenever the tab loads or becomes active, then follows new live output when you are already near the bottom while preserving your scroll position when you review older messages. For active, assigned, non-paused agent sessions in `in-progress` or `in-review` (reviewing/merging/fixing) tasks, 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: diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index 41fb5ada7a..dd86df1d1e 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -75,18 +75,103 @@ overflow-wrap: anywhere; } -.task-chat-entry--thinking { +.task-chat-tool-group, +.task-chat-thinking { + min-width: 0; + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + color: var(--text); + overflow-wrap: anywhere; +} + +.task-chat-tool-group { + background: var(--bg-tertiary); +} + +.task-chat-tool-group-summary, +.task-chat-thinking-summary { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + cursor: pointer; + list-style: none; +} + +.task-chat-tool-group-summary::-webkit-details-marker, +.task-chat-thinking-summary::-webkit-details-marker { + display: none; +} + +.task-chat-tool-group-summary::marker, +.task-chat-thinking-summary::marker { + content: ""; +} + +.task-chat-tool-group-summary:focus-visible, +.task-chat-thinking-summary:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + +.task-chat-tool-group-count { + flex: 0 0 auto; + font-weight: 600; +} + +.task-chat-tool-group-status { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-xs); + color: var(--text-muted); + font-size: var(--space-md); +} + +.task-chat-tool-group-status-part--call { + color: var(--color-warning); +} + +.task-chat-tool-group-status-part--error { + color: var(--color-error); +} + +.task-chat-tool-group-entries, +.task-chat-thinking-body { + display: flex; + flex-direction: column; + gap: var(--space-sm); + padding: 0 var(--space-md) var(--space-md); +} + +.task-chat-tool-entry { + min-width: 0; + padding: var(--space-sm) var(--space-md); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--text); + overflow-wrap: anywhere; +} + +.task-chat-tool-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-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-thinking-summary { + color: var(--color-warning); + font-weight: 600; } -.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-thinking-markdown + .task-chat-thinking-markdown { + padding-top: var(--space-sm); + border-top: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 24%, var(--border)); } .task-chat-entry-kicker { @@ -98,11 +183,7 @@ 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 { +.task-chat-tool-entry--tool-error .task-chat-entry-kicker { color: var(--color-error); } @@ -178,6 +259,25 @@ min-width: 0; } + .task-chat-tool-group-summary, + .task-chat-thinking-summary { + align-items: flex-start; + flex-direction: column; + } + + .task-chat-tool-group-status { + width: 100%; + } + + .task-chat-tool-group-entries, + .task-chat-thinking-body { + padding-inline: var(--space-sm); + } + + .task-chat-tool-entry { + padding: var(--space-sm); + } + .task-chat-composer { padding: var(--space-sm); } diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index a57e7e5c8a..3d953c02c9 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -28,6 +28,11 @@ interface AgentLogGroup { entries: AgentLogEntry[]; } +type TaskChatSegment = + | { kind: "tool"; entries: AgentLogEntry[]; startIndex: number } + | { kind: "thinking"; entries: AgentLogEntry[]; startIndex: number } + | { kind: "text"; entry: AgentLogEntry; index: number }; + const STEERING_BLOCKED_STATUSES = new Set([ "paused", "awaiting-user-input", @@ -99,6 +104,10 @@ function isActiveAgentSession(task: Task | TaskDetail): boolean { && !task.userPaused; } +function isToolLikeEntry(entry: AgentLogEntry): boolean { + return entry.type === "tool" || entry.type === "tool_result" || entry.type === "tool_error"; +} + function formatEntryLabel(entry: AgentLogEntry): string { switch (entry.type) { case "tool": @@ -114,27 +123,51 @@ function formatEntryLabel(entry: AgentLogEntry): string { } } -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(" "); +function formatToolCallCount(count: number): string { + return count === 1 ? "1 tool call" : `${count} tool calls`; +} - if (isToolEntry) { - return ( -
-
{formatEntryLabel(entry)}
-
{entry.text}
- {entry.detail ?
{linkifyFilePaths(entry.detail)}
: null} -
- ); +function segmentGroupEntries(entries: AgentLogEntry[]): TaskChatSegment[] { + const segments: TaskChatSegment[] = []; + let index = 0; + + while (index < entries.length) { + const entry = entries[index]; + if (isToolLikeEntry(entry)) { + const startIndex = index; + const toolEntries: AgentLogEntry[] = []; + while (index < entries.length && isToolLikeEntry(entries[index])) { + toolEntries.push(entries[index]); + index += 1; + } + segments.push({ kind: "tool", entries: toolEntries, startIndex }); + continue; + } + + if (entry.type === "thinking") { + const startIndex = index; + const thinkingEntries: AgentLogEntry[] = []; + while (index < entries.length && entries[index].type === "thinking") { + thinkingEntries.push(entries[index]); + index += 1; + } + segments.push({ kind: "thinking", entries: thinkingEntries, startIndex }); + continue; + } + + segments.push({ kind: "text", entry, index }); + index += 1; } + return segments; +} + +function TaskChatTextEntry({ entry }: { entry: AgentLogEntry }) { return ( -
- {entry.type === "thinking" ?
{formatEntryLabel(entry)}
: null} +
{entry.text} @@ -144,6 +177,88 @@ function TaskChatEntry({ entry }: { entry: AgentLogEntry }) { ); } +function TaskChatToolEntry({ entry }: { entry: AgentLogEntry }) { + return ( +
+
{formatEntryLabel(entry)}
+
{entry.text}
+ {entry.detail ?
{linkifyFilePaths(entry.detail)}
: null} +
+ ); +} + +function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) { + const callCount = entries.filter((entry) => entry.type === "tool").length; + const resultCount = entries.filter((entry) => entry.type === "tool_result").length; + const errorCount = entries.filter((entry) => entry.type === "tool_error").length; + + return ( +
+ + {formatToolCallCount(entries.length)} + + {callCount > 0 ? ( + + {callCount === 1 ? "1 call" : `${callCount} calls`} + + ) : null} + {resultCount > 0 ? ( + + {resultCount === 1 ? "1 result" : `${resultCount} results`} + + ) : null} + {errorCount > 0 ? ( + + {errorCount === 1 ? "1 error" : `${errorCount} errors`} + + ) : null} + + +
+ {entries.map((entry, entryIndex) => ( + + ))} +
+
+ ); +} + +function TaskChatThinking({ entries }: { entries: AgentLogEntry[] }) { + return ( +
+ + {entries.length === 1 ? "Thinking" : `${entries.length} thinking entries`} + +
+ {entries.map((entry, entryIndex) => ( +
+ + {entry.text} + +
+ ))} +
+
+ ); +} + +function TaskChatSegmentView({ segment }: { segment: TaskChatSegment }) { + if (segment.kind === "tool") { + return ; + } + if (segment.kind === "thinking") { + return ; + } + return ; +} + export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabProps) { const { entries, loading } = useAgentLogs(task.id, active, projectId); const [draft, setDraft] = useState(""); @@ -261,6 +376,7 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr name: group.label, icon: getRoleIcon(group.role), }; + const segments = segmentGroupEntries(group.entries); return (
@@ -271,9 +387,12 @@ export function TaskChatTab({ task, projectId, active, addToast }: TaskChatTabPr
- {group.entries.map((entry, entryIndex) => ( - - ))} + {segments.map((segment) => { + const segmentKey = segment.kind === "text" + ? `text-${getEntryKey(segment.entry, segment.index)}` + : `${segment.kind}-${segment.startIndex}-${segment.entries.length}`; + return ; + })}
); diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index 1f36049eae..a4c38da446 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { userEvent } from "@testing-library/user-event"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; @@ -189,9 +189,9 @@ describe("TaskChatTab", () => { expect(screen.getByLabelText("Reviewer messages")).toBeTruthy(); }); - it("renders thinking and tool entries legibly", () => { + it("collapses consecutive tool entries into one expandable summary", async () => { + const user = userEvent.setup(); mockLogs([ - makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }), makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test" }), makeEntry({ agent: "executor", type: "tool_result", text: "done", detail: "ok" }), makeEntry({ agent: "executor", type: "tool_error", text: "failed", detail: "stderr" }), @@ -199,24 +199,94 @@ describe("TaskChatTab", () => { render(); - expect(screen.getByText("Thinking")).toBeTruthy(); - expect(screen.getByText("Tool call")).toBeTruthy(); - expect(screen.getByText("Tool result")).toBeTruthy(); - expect(screen.getByText("Tool error")).toBeTruthy(); - expect(screen.getByText("stderr")).toBeTruthy(); + const toolGroup = screen.getByTestId("task-chat-tool-group"); + expect(toolGroup).not.toHaveAttribute("open"); + expect(screen.getByText("3 tool calls")).toBeVisible(); + expect(screen.getByText("1 call")).toBeVisible(); + expect(screen.getByText("1 result")).toBeVisible(); + expect(screen.getByText("1 error")).toBeVisible(); + expect(screen.getByText("stderr")).not.toBeVisible(); + + await user.click(within(toolGroup).getByText("3 tool calls")); + + expect(toolGroup).toHaveAttribute("open"); + expect(screen.getByText("Tool call")).toBeVisible(); + expect(screen.getByText("Tool result")).toBeVisible(); + expect(screen.getByText("Tool error")).toBeVisible(); + expect(screen.getByText("stderr")).toBeVisible(); }); - it("appends newly streamed entries from the hook", () => { + it("renders a single tool entry as one collapsed group and tolerates missing detail", () => { + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "bash", detail: undefined }), + ]); + + render(); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + expect(toolGroup).not.toHaveAttribute("open"); + expect(screen.getByText("1 tool call")).toBeVisible(); + expect(screen.getByText("bash")).not.toBeVisible(); + }); + + it("renders thinking in an expanded-by-default collapsible block", async () => { + const user = userEvent.setup(); + mockLogs([ + makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }), + ]); + + render(); + + const thinking = screen.getByTestId("task-chat-thinking"); + expect(thinking).toHaveAttribute("open"); + expect(screen.getByText("Thinking")).toBeVisible(); + expect(screen.getByText("I am considering options")).toBeVisible(); + + await user.click(within(thinking).getByText("Thinking")); + + expect(thinking).not.toHaveAttribute("open"); + expect(screen.getByText("I am considering options")).not.toBeVisible(); + }); + + it("creates distinct tool segments when text or thinking entries are interleaved", () => { + mockLogs([ + makeEntry({ agent: "executor", type: "tool", text: "first tool", detail: "first detail" }), + makeEntry({ agent: "executor", text: "plain response" }), + makeEntry({ agent: "executor", type: "thinking", text: "thinking between tools" }), + makeEntry({ agent: "executor", type: "tool_result", text: "second tool", detail: "second detail" }), + ]); + + render(); + + const toolGroups = screen.getAllByTestId("task-chat-tool-group"); + expect(toolGroups).toHaveLength(2); + expect(toolGroups[0]).not.toHaveAttribute("open"); + expect(toolGroups[1]).not.toHaveAttribute("open"); + expect(screen.getAllByText("1 tool call")).toHaveLength(2); + expect(screen.getByText("plain response")).toBeVisible(); + expect(screen.getByText("thinking between tools")).toBeVisible(); + }); + + it("appends newly streamed entries from the hook without auto-opening tool groups", () => { const firstEntries = [makeEntry({ agent: "executor", text: "first live chunk" })]; - const secondEntries = [...firstEntries, makeEntry({ agent: "executor", text: "second live chunk", timestamp: "2026-06-12T00:00:01.000Z" })]; + const secondEntries = [ + ...firstEntries, + makeEntry({ agent: "executor", type: "tool", text: "streamed tool", detail: "streamed detail", timestamp: "2026-06-12T00:00:01.000Z" }), + makeEntry({ agent: "executor", text: "second live chunk", timestamp: "2026-06-12T00:00:02.000Z" }), + ]; mockedUseAgentLogs.mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false }); - mockedUseAgentLogs.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 2, loadingMore: false }); + mockedUseAgentLogs.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 3, loadingMore: false }); const { rerender } = render(); - expect(screen.getByText("first live chunk")).toBeTruthy(); + expect(screen.getByText("first live chunk")).toBeVisible(); rerender(); - expect(screen.getByText("second live chunk")).toBeTruthy(); + + const toolGroup = screen.getByTestId("task-chat-tool-group"); + expect(toolGroup).not.toHaveAttribute("open"); + expect(screen.getByText("1 tool call")).toBeVisible(); + expect(screen.getByText("streamed detail")).not.toBeVisible(); + expect(screen.getByText("second live chunk")).toBeVisible(); }); it.each([ @@ -478,10 +548,12 @@ describe("TaskChatTab", () => { }); }); - it("keeps mobile breakpoint scaffolding for the transcript and composer", () => { + it("keeps mobile breakpoint scaffolding for the transcript, composer, and collapsible groups", () => { const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); expect(css).toContain("@media (max-width: 768px)"); expect(css).toContain(".task-chat-transcript"); expect(css).toContain(".task-chat-composer-row"); + expect(css).toContain(".task-chat-tool-group-summary"); + expect(css).toContain(".task-chat-thinking-summary"); }); });