diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6ea49778c2..f99dc6ca38 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -734,7 +734,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. Consecutive text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. 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; this includes regular engine agents working in a task worktree as well as live CLI sessions. When no active session is available, the composer is disabled with an explanatory hint. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally. +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 text/message chunks inside a role group render as one continuous markdown bubble, while consecutive tool/tool-result/tool-error rows collapse into one expandable, compact tool-call summary that stays collapsed by default; the summary counts tool invocations, lists deduped tool names with overflow, and shows an error count when failures are present, while the expanded body pairs each call with its result or error in dense entry cards. 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. When you scroll away from the bottom of a populated transcript, a sticky **Latest** button appears inside the transcript so you can jump back to the newest message and resume live follow. 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; this includes regular engine agents working in a task worktree as well as live CLI sessions. When no active session is available, the composer is disabled with an explanatory hint. The task-detail Chat tab keeps the composer pinned and visible on mobile and desktop while the transcript scrolls internally. 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 0e8e6cccb2..f1497cb99b 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -31,6 +31,39 @@ text-align: center; } +.task-chat-jump-to-bottom { + position: sticky; + right: var(--space-md); + bottom: var(--space-md); + width: fit-content; + min-inline-size: var(--space-2xl); + min-block-size: var(--space-2xl); + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + color: var(--text-muted); + background: var(--surface); + border: var(--btn-border-width) solid var(--border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-md); + cursor: pointer; + transition: all var(--transition-fast); + z-index: 2; +} + +.task-chat-jump-to-bottom:hover { + background: var(--card-hover); + color: var(--text); +} + +.task-chat-jump-to-bottom:focus-visible { + outline: none; + box-shadow: var(--focus-ring-strong); +} + .task-chat-group { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -293,6 +326,13 @@ padding: var(--space-sm); } + .task-chat-jump-to-bottom { + right: var(--space-sm); + bottom: var(--space-sm); + min-inline-size: calc(var(--space-2xl) + var(--space-sm)); + min-block-size: calc(var(--space-2xl) + var(--space-sm)); + } + .task-chat-group { grid-template-columns: 1fr; } diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 68d6d7f84d..d7e582506c 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -2,7 +2,7 @@ import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from 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 { ChevronDown, Loader2, Send } from "lucide-react"; import { addSteeringComment } from "../api"; import { useAgentLogs } from "../hooks/useAgentLogs"; import type { ToastType } from "../hooks/useToast"; @@ -50,6 +50,10 @@ const STEERING_BLOCKED_STATUSES = new Set([ const REVIEW_STEERABLE_STATUSES = new Set(["reviewing", "merging", "merging-fix", "fixing"]); const BOTTOM_FOLLOW_THRESHOLD = 48; +function isTranscriptNearBottom(container: HTMLElement): boolean { + return container.scrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; +} + function getRoleLabel(role: AgentLogRole): string { switch (role) { case "triage": @@ -408,6 +412,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [optimisticMessages, setOptimisticMessages] = useState([]); + const [isTranscriptAtBottom, setIsTranscriptAtBottom] = useState(true); const transcriptRef = useRef(null); const previousEntryCountRef = useRef(0); const previousScrollHeightRef = useRef(0); @@ -462,6 +467,7 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on container.scrollTop = container.scrollHeight; previousScrollHeightRef.current = container.scrollHeight; + setIsTranscriptAtBottom(true); if (container.scrollHeight === lastScrollHeight) { stableFrames += 1; } else { @@ -513,13 +519,24 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on return; } + if (transcriptItemCount === 0) { + previousEntryCountRef.current = transcriptItemCount; + previousScrollHeightRef.current = container.scrollHeight; + return; + } + const previousCount = previousEntryCountRef.current; const previousScrollHeight = previousScrollHeightRef.current || container.scrollHeight; if (transcriptItemCount > previousCount) { const shouldFollow = previousCount === 0 || previousScrollHeight - (container.scrollTop + container.clientHeight) <= BOTTOM_FOLLOW_THRESHOLD; if (shouldFollow) { container.scrollTop = container.scrollHeight; + setIsTranscriptAtBottom(true); + } else { + setIsTranscriptAtBottom(isTranscriptNearBottom(container)); } + } else { + setIsTranscriptAtBottom(isTranscriptNearBottom(container)); } previousEntryCountRef.current = transcriptItemCount; @@ -530,6 +547,15 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on const container = transcriptRef.current; if (!container) return; previousScrollHeightRef.current = container.scrollHeight; + setIsTranscriptAtBottom(isTranscriptNearBottom(container)); + }, []); + + const scrollTranscriptToBottom = useCallback(() => { + const container = transcriptRef.current; + if (!container) return; + container.scrollTop = container.scrollHeight; + previousScrollHeightRef.current = container.scrollHeight; + setIsTranscriptAtBottom(true); }, []); const handleSubmit = useCallback(async (event?: React.FormEvent) => { @@ -620,6 +646,18 @@ export function TaskChatTab({ task, projectId, active, addToast, sessionLive, on ); }) )} + {transcriptItemCount > 0 && !isTranscriptAtBottom ? ( + + ) : null}
diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index c7ea3ad090..41f71416b2 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -717,6 +717,68 @@ describe("TaskChatTab", () => { expect(metrics.scrollTop).toBe(120); }); + it("does not render the jump-to-bottom button for loading or empty transcripts", () => { + mockLogs([], true); + const loading = render(); + expect(screen.getByText(/Loading agent output/)).toBeVisible(); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + loading.unmount(); + + mockLogs([]); + render(); + expect(screen.getByText(/No agent output yet/)).toBeVisible(); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + }); + + it("renders the jump-to-bottom button only after a populated transcript is scrolled up", () => { + const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "latest output" })]); + + render(); + expect(metrics.scrollTop).toBe(1200); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + + metrics.scrollTop = 920; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + + metrics.scrollTop = 600; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + const jumpButton = screen.getByTestId("task-chat-jump-to-bottom"); + expect(jumpButton).toBeVisible(); + expect(jumpButton).toHaveAccessibleName("Jump to latest message"); + expect(screen.getByRole("button", { name: "Jump to latest message" })).toBe(jumpButton); + }); + + it("clicking the jump-to-bottom button snaps to the latest message and removes the control", async () => { + const user = userEvent.setup(); + const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "latest output" })]); + + render(); + metrics.scrollTop = 120; + fireEvent.scroll(screen.getByTestId("task-chat-transcript")); + + await user.click(screen.getByTestId("task-chat-jump-to-bottom")); + + expect(metrics.scrollTop).toBe(1200); + expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); + }); + + it("keeps the jump-to-bottom affordance available at the mobile breakpoint", () => { + mockMatchMedia(true); + mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 }); + mockLogs([makeEntry({ agent: "executor", text: "mobile output" })]); + + render(); + const transcript = screen.getByTestId("task-chat-transcript"); + transcript.scrollTop = 120; + fireEvent.scroll(transcript); + + expect(screen.getByTestId("task-chat-jump-to-bottom")).toBeVisible(); + expect(screen.getByRole("button", { name: "Jump to latest message" })).toHaveClass("task-chat-jump-to-bottom"); + }); + it("posts composer text through addSteeringComment and clears on success", async () => { const user = userEvent.setup(); mockedAddSteeringComment.mockResolvedValue(makeTask()); @@ -1223,10 +1285,30 @@ describe("TaskChatTab", () => { expect(css).not.toContain("62vh"); }); + it("keeps tokenized sticky styling for the jump-to-bottom control on desktop and mobile", () => { + const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); + const jumpRule = getCssRuleBlock(css, ".task-chat-jump-to-bottom"); + const mobileCss = getCssAfter(css, "@media (max-width: 768px)"); + const mobileJumpRule = getCssRuleBlock(mobileCss, ".task-chat-jump-to-bottom"); + + expect(jumpRule).toContain("position: sticky"); + expect(jumpRule).toContain("bottom: var(--space-md)"); + expect(jumpRule).toContain("right: var(--space-md)"); + expect(jumpRule).toContain("background: var(--surface)"); + expect(jumpRule).toContain("border: var(--btn-border-width) solid var(--border)"); + expect(jumpRule).toContain("box-shadow: var(--shadow-md)"); + expect(jumpRule).toContain("border-radius: var(--radius-md)"); + expect(mobileJumpRule).toContain("bottom: var(--space-sm)"); + expect(mobileJumpRule).toContain("right: var(--space-sm)"); + expect(mobileJumpRule).toContain("min-inline-size"); + expect(mobileJumpRule).toContain("min-block-size"); + }); + 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-jump-to-bottom"); expect(css).toContain(".task-chat-composer-row"); expect(css).toContain(".task-chat-tool-group-summary"); expect(css).toContain(".task-chat-tool-group-names");