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 ? (
+
+
+ Loading agent output…
+
+ ) : 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.entries.map((entry, entryIndex) => (
+
+ ))}
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+}
diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx
index 34e165921c..31a25ef7d2 100644
--- a/packages/dashboard/app/components/TaskDetailModal.tsx
+++ b/packages/dashboard/app/components/TaskDetailModal.tsx
@@ -34,6 +34,7 @@ import { ModelSelectorTab } from "./ModelSelectorTab";
import { PrPanel } from "./PrPanel";
import { PrCreateModal } from "./PrCreateModal";
import { TaskComments } from "./TaskComments";
+import { TaskChatTab } from "./TaskChatTab";
import { TaskReviewTab } from "./TaskReviewTab";
import { MergeDetails } from "./MergeDetails";
import { TaskChangesTab } from "./TaskChangesTab";
@@ -283,7 +284,7 @@ function formatDurationCompact(ageMs: number): string {
return `${minutes}m`;
}
-type TabId = "definition" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
+type TabId = "definition" | "chat" | "logs" | "changes" | "review" | "pr" | "comments" | "model" | "workflow" | "documents" | "stats" | "routing" | "retries" | "terminal" | `plugin-${string}`;
// Lazy-load the terminal so xterm + addons stay out of the main bundle (U11).
const LazySessionTerminal = lazy(() =>
@@ -2999,6 +3000,12 @@ export function TaskDetailContent({
>
{t("taskDetail.tabs.definition", "Definition")}
+ setActiveTab("chat")}
+ >
+ {t("taskDetail.tabs.chat", "Chat")}
+
setActiveTab("logs")}
@@ -3114,6 +3121,10 @@ export function TaskDetailContent({
+ ) : activeTab === "chat" ? (
+
+
+
) : activeTab === "logs" ? (
diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx
new file mode 100644
index 0000000000..8ca217d047
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx
@@ -0,0 +1,181 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import { userEvent } from "@testing-library/user-event";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import type { AgentLogEntry, Task } from "@fusion/core";
+import { TaskChatTab } from "../TaskChatTab";
+import { useAgentLogs } from "../../hooks/useAgentLogs";
+import { addSteeringComment } from "../../api";
+
+vi.mock("../../hooks/useAgentLogs", () => ({
+ useAgentLogs: vi.fn(),
+}));
+
+vi.mock("../../api", () => ({
+ addSteeringComment: vi.fn(),
+}));
+
+const mockedUseAgentLogs = vi.mocked(useAgentLogs);
+const mockedAddSteeringComment = vi.mocked(addSteeringComment);
+
+function makeTask(overrides: Partial
= {}): Task {
+ return {
+ id: "FN-001",
+ title: "Task",
+ description: "Task description",
+ column: "in-progress",
+ dependencies: [],
+ steps: [],
+ currentStep: 0,
+ assignedAgentId: "agent-1",
+ status: "executing",
+ ...overrides,
+ } as Task;
+}
+
+function makeEntry(overrides: Partial): AgentLogEntry {
+ return {
+ timestamp: "2026-06-12T00:00:00.000Z",
+ taskId: "FN-001",
+ type: "text",
+ text: "message",
+ ...overrides,
+ } as AgentLogEntry;
+}
+
+function mockLogs(entries: AgentLogEntry[] = [], loading = false) {
+ mockedUseAgentLogs.mockReturnValue({
+ entries,
+ loading,
+ clear: vi.fn(),
+ loadMore: vi.fn(async () => {}),
+ hasMore: false,
+ total: entries.length,
+ loadingMore: false,
+ });
+}
+
+describe("TaskChatTab", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockLogs();
+ });
+
+ it("subscribes to live agent logs only when active", () => {
+ render( );
+ expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1");
+ });
+
+ it("renders empty state when no agent output exists", () => {
+ render( );
+ expect(screen.getByText(/No agent output yet/)).toBeTruthy();
+ });
+
+ it("labels every agent role and the legacy undefined-agent fallback", () => {
+ mockLogs([
+ makeEntry({ agent: "triage", text: "planning output" }),
+ makeEntry({ agent: "executor", text: "executor output" }),
+ makeEntry({ agent: "reviewer", text: "reviewer output" }),
+ makeEntry({ agent: "merger", text: "merger output" }),
+ makeEntry({ text: "legacy output" }),
+ ]);
+
+ render( );
+
+ expect(screen.getByText("Planner")).toBeTruthy();
+ expect(screen.getByText("Executor")).toBeTruthy();
+ expect(screen.getByText("Reviewer")).toBeTruthy();
+ expect(screen.getByText("Merger")).toBeTruthy();
+ expect(screen.getByText("Agent")).toBeTruthy();
+ expect(screen.getByText("legacy output")).toBeTruthy();
+ });
+
+ it("groups consecutive entries by agent role", () => {
+ mockLogs([
+ makeEntry({ agent: "executor", text: "first" }),
+ makeEntry({ agent: "executor", text: "second" }),
+ makeEntry({ agent: "reviewer", text: "third" }),
+ ]);
+
+ render( );
+
+ expect(screen.getByText("2 entries")).toBeTruthy();
+ expect(screen.getByLabelText("Executor messages")).toBeTruthy();
+ expect(screen.getByLabelText("Reviewer messages")).toBeTruthy();
+ });
+
+ it("renders thinking and tool entries legibly", () => {
+ 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" }),
+ ]);
+
+ 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();
+ });
+
+ it("appends newly streamed entries from the hook", () => {
+ 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" })];
+ 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 });
+
+ const { rerender } = render( );
+ expect(screen.getByText("first live chunk")).toBeTruthy();
+
+ rerender( );
+ expect(screen.getByText("second live chunk")).toBeTruthy();
+ });
+
+ it("posts composer text through addSteeringComment and clears on success", async () => {
+ const user = userEvent.setup();
+ mockedAddSteeringComment.mockResolvedValue(makeTask());
+ render( );
+
+ const input = screen.getByLabelText("Message active agent session");
+ await user.type(input, "Please inspect the failing test");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+
+ await waitFor(() => {
+ expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please inspect the failing test", "project-1");
+ });
+ expect(input).toHaveValue("");
+ });
+
+ it("disables the composer and shows a hint when no active assigned session exists", () => {
+ render( );
+
+ expect(screen.getByText(/No active assigned agent session/)).toBeTruthy();
+ expect(screen.getByLabelText("Message active agent session")).toBeDisabled();
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ });
+
+ it("surfaces send failures through addToast", async () => {
+ const user = userEvent.setup();
+ const addToast = vi.fn();
+ mockedAddSteeringComment.mockRejectedValue(new Error("network down"));
+ render( );
+
+ await user.type(screen.getByLabelText("Message active agent session"), "hello");
+ await user.click(screen.getByRole("button", { name: "Send" }));
+
+ await waitFor(() => {
+ expect(addToast).toHaveBeenCalledWith("Unable to send message: network down", "error");
+ });
+ });
+
+ it("keeps mobile breakpoint scaffolding for the transcript and composer", () => {
+ 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");
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx
index 60a5ccf47c..64999e4b08 100644
--- a/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.attachments-and-tabs.test.tsx
@@ -744,26 +744,19 @@ describe("TaskDetailModal", () => {
);
// For an in-progress task (no workflow steps, no merge commit), the
- // top-level tabs are: Definition, Logs, Changes, Review, Comments,
+ // top-level tabs are: Definition, Chat, Logs, Changes, Review, Comments,
// Documents, Model, Workflow, Stats, Routing.
- const tabTexts = ["Definition", "Logs", "Changes", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
+ const tabTexts = ["Definition", "Chat", "Logs", "Changes", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing"];
const tabs = screen.getAllByRole("button").filter((b) =>
tabTexts.includes(b.textContent || "")
);
- expect(tabs.length).toBe(10);
+ expect(tabs.map((tab) => tab.textContent)).toEqual(tabTexts);
expect(tabs[0].textContent).toBe("Definition");
- expect(tabs[1].textContent).toBe("Logs");
- expect(tabs[2].textContent).toBe("Changes");
- expect(tabs[3].textContent).toBe("Review");
- expect(tabs[4].textContent).toBe("Comments");
- expect(tabs[5].textContent).toBe("Documents");
- expect(tabs[6].textContent).toBe("Model");
- expect(tabs[7].textContent).toBe("Workflow");
- expect(tabs[8].textContent).toBe("Stats");
- expect(tabs[9].textContent).toBe("Routing");
+ expect(tabs[1].textContent).toBe("Chat");
+ expect(tabs[2].textContent).toBe("Logs");
// Activity and Agent Log are NOT top-level tabs (they are subviews inside Logs)
- expect(container.querySelectorAll(".detail-tab").length).toBe(10);
+ expect(container.querySelectorAll(".detail-tab").length).toBe(11);
// Workflow tab should always appear even when no workflow steps are configured
expect(screen.getByText("Workflow")).toBeInTheDocument();
// Commits tab should NOT appear for non-done tasks
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx
index 2b49b7b264..04c15203fd 100644
--- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx
@@ -182,20 +182,21 @@ describe("TaskDetailModal", () => {
/>,
);
- // In-progress tasks show exactly 10 tabs:
- // Definition, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing
+ // In-progress tasks show exactly 11 tabs:
+ // Definition, Chat, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing
const tabs = container.querySelectorAll(".detail-tab");
- expect(tabs.length).toBe(10);
+ expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Definition");
- expect(tabs[1].textContent).toBe("Logs");
- expect(tabs[2].textContent).toBe("Changes");
- expect(tabs[3].textContent).toBe("Review");
- expect(tabs[4].textContent).toBe("Comments");
- expect(tabs[5].textContent).toBe("Documents");
- expect(tabs[6].textContent).toBe("Model");
- expect(tabs[7].textContent).toBe("Workflow");
- expect(tabs[8].textContent).toBe("Stats");
- expect(tabs[9].textContent).toBe("Routing");
+ expect(tabs[1].textContent).toBe("Chat");
+ expect(tabs[2].textContent).toBe("Logs");
+ expect(tabs[3].textContent).toBe("Changes");
+ expect(tabs[4].textContent).toBe("Review");
+ expect(tabs[5].textContent).toBe("Comments");
+ expect(tabs[6].textContent).toBe("Documents");
+ expect(tabs[7].textContent).toBe("Model");
+ expect(tabs[8].textContent).toBe("Workflow");
+ expect(tabs[9].textContent).toBe("Stats");
+ expect(tabs[10].textContent).toBe("Routing");
// Commits tab should NOT be present for non-done tasks
expect(screen.queryByText("Commits")).toBeNull();
});
@@ -213,19 +214,20 @@ describe("TaskDetailModal", () => {
/>,
);
- // In-progress task with workflow steps: 10 tabs (Review after Changes, Workflow after Model)
+ // In-progress task with workflow steps: 11 tabs (Review after Changes, Workflow after Model)
const tabs = container.querySelectorAll(".detail-tab");
- expect(tabs.length).toBe(10);
+ expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Definition");
- expect(tabs[1].textContent).toBe("Logs");
- expect(tabs[2].textContent).toBe("Changes");
- expect(tabs[3].textContent).toBe("Review");
- expect(tabs[4].textContent).toBe("Comments");
- expect(tabs[5].textContent).toBe("Documents");
- expect(tabs[6].textContent).toBe("Model");
- expect(tabs[7].textContent).toBe("Workflow");
- expect(tabs[8].textContent).toBe("Stats");
- expect(tabs[9].textContent).toBe("Routing");
+ expect(tabs[1].textContent).toBe("Chat");
+ expect(tabs[2].textContent).toBe("Logs");
+ expect(tabs[3].textContent).toBe("Changes");
+ expect(tabs[4].textContent).toBe("Review");
+ expect(tabs[5].textContent).toBe("Comments");
+ expect(tabs[6].textContent).toBe("Documents");
+ expect(tabs[7].textContent).toBe("Model");
+ expect(tabs[8].textContent).toBe("Workflow");
+ expect(tabs[9].textContent).toBe("Stats");
+ expect(tabs[10].textContent).toBe("Routing");
});
it("does NOT show Commits tab for done task with mergeDetails.commitSha (changes merged into Changes tab)", () => {
@@ -244,24 +246,25 @@ describe("TaskDetailModal", () => {
/>,
);
- // Done task with commit SHA: Definition, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing (10 tabs, no Commits)
+ // Done task with commit SHA: Definition, Chat, Logs, Changes, Review, Comments, Documents, Model, Workflow, Stats, Routing (11 tabs, no Commits)
const tabs = container.querySelectorAll(".detail-tab");
- expect(tabs.length).toBe(10);
+ expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Definition");
- expect(tabs[1].textContent).toBe("Logs");
- expect(tabs[2].textContent).toBe("Changes");
- expect(tabs[3].textContent).toBe("Review");
- expect(tabs[4].textContent).toBe("Comments");
- expect(tabs[5].textContent).toBe("Documents");
- expect(tabs[6].textContent).toBe("Model");
- expect(tabs[7].textContent).toBe("Workflow");
- expect(tabs[8].textContent).toBe("Stats");
- expect(tabs[9].textContent).toBe("Routing");
+ expect(tabs[1].textContent).toBe("Chat");
+ expect(tabs[2].textContent).toBe("Logs");
+ expect(tabs[3].textContent).toBe("Changes");
+ expect(tabs[4].textContent).toBe("Review");
+ expect(tabs[5].textContent).toBe("Comments");
+ expect(tabs[6].textContent).toBe("Documents");
+ expect(tabs[7].textContent).toBe("Model");
+ expect(tabs[8].textContent).toBe("Workflow");
+ expect(tabs[9].textContent).toBe("Stats");
+ expect(tabs[10].textContent).toBe("Routing");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});
- it("shows 10 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => {
+ it("shows 11 tabs for done task with workflow steps and commit SHA (Commits merged into Changes)", () => {
const { container } = render(
{
/>,
);
- // Done task with workflow steps and commit SHA: 10 tabs including Review (no Commits)
+ // Done task with workflow steps and commit SHA: 11 tabs including Review (no Commits)
const tabs = container.querySelectorAll(".detail-tab");
- expect(tabs.length).toBe(10);
+ expect(tabs.length).toBe(11);
expect(tabs[0].textContent).toBe("Definition");
- expect(tabs[1].textContent).toBe("Logs");
- expect(tabs[2].textContent).toBe("Changes");
- expect(tabs[3].textContent).toBe("Review");
- expect(tabs[4].textContent).toBe("Comments");
- expect(tabs[5].textContent).toBe("Documents");
- expect(tabs[6].textContent).toBe("Model");
- expect(tabs[7].textContent).toBe("Workflow");
- expect(tabs[8].textContent).toBe("Stats");
- expect(tabs[9].textContent).toBe("Routing");
+ expect(tabs[1].textContent).toBe("Chat");
+ expect(tabs[2].textContent).toBe("Logs");
+ expect(tabs[3].textContent).toBe("Changes");
+ expect(tabs[4].textContent).toBe("Review");
+ expect(tabs[5].textContent).toBe("Comments");
+ expect(tabs[6].textContent).toBe("Documents");
+ expect(tabs[7].textContent).toBe("Model");
+ expect(tabs[8].textContent).toBe("Workflow");
+ expect(tabs[9].textContent).toBe("Stats");
+ expect(tabs[10].textContent).toBe("Routing");
// Commits tab should NOT be present
expect(screen.queryByText("Commits")).toBeNull();
});
@@ -309,9 +313,9 @@ describe("TaskDetailModal", () => {
);
const triageTabs = triageContainer.querySelectorAll(".detail-tab");
- expect(triageTabs.length).toBe(9); // Definition, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing
+ expect(triageTabs.length).toBe(10); // Definition, Chat, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing
expect(Array.from(triageTabs).map(t => t.textContent)).toEqual([
- "Definition", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
+ "Definition", "Chat", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
]);
const { container: todoContainer } = render(
@@ -327,9 +331,9 @@ describe("TaskDetailModal", () => {
);
const todoTabs = todoContainer.querySelectorAll(".detail-tab");
- expect(todoTabs.length).toBe(9); // Definition, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing
+ expect(todoTabs.length).toBe(10); // Definition, Chat, Logs, Review, Comments, Documents, Model, Workflow, Stats, Routing
expect(Array.from(todoTabs).map(t => t.textContent)).toEqual([
- "Definition", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
+ "Definition", "Chat", "Logs", "Review", "Comments", "Documents", "Model", "Workflow", "Stats", "Routing",
]);
});
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
index 04d46f7f7f..357c0b0664 100644
--- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
@@ -61,7 +61,7 @@ describe("TaskDetailModal", () => {
expect(container.querySelector(".detail-timestamps")).toBeTruthy();
expect(container.querySelectorAll(".detail-timestamp-item").length).toBe(2);
const tabs = container.querySelectorAll(".detail-tab");
- expect(tabs.length).toBe(10);
+ expect(tabs.length).toBe(11);
expect(tabs[0].classList.contains("detail-tab-active")).toBe(true);
expect(Array.from(tabs).slice(1).every((t) => !t.classList.contains("detail-tab-active"))).toBe(true);
// Responsive CSS controls sizing — no inline padding/fontSize/borderBottom leaks
diff --git a/packages/dashboard/app/utils/chatInputAutosize.ts b/packages/dashboard/app/utils/chatInputAutosize.ts
new file mode 100644
index 0000000000..dbdc4ce0c2
--- /dev/null
+++ b/packages/dashboard/app/utils/chatInputAutosize.ts
@@ -0,0 +1,18 @@
+// Keep a generous cap so pasted multi-paragraph text stays visible while
+// still preventing the composer from overtaking the message pane on short viewports.
+export const CHAT_INPUT_MAX_HEIGHT_PX = 640;
+export const TABLET_INPUT_MAX_HEIGHT_PX = 200;
+
+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));
+}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 09e808e1b1..68541b2ad0 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -6100,6 +6100,7 @@
"changes": "Changes",
"comments": "Comments",
"definition": "Definition",
+ "chat": "Chat",
"documents": "Documents",
"logs": "Logs",
"model": "Model",
diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json
index fc7a243e8a..4471ebd341 100644
--- a/packages/i18n/locales/es/app.json
+++ b/packages/i18n/locales/es/app.json
@@ -6049,6 +6049,7 @@
"changes": "Cambios",
"comments": "Comentarios",
"definition": "Definición",
+ "chat": "Chat",
"documents": "Documentos",
"logs": "Registros",
"model": "Modelo",
diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json
index aaca4411c1..c8bfd722e2 100644
--- a/packages/i18n/locales/fr/app.json
+++ b/packages/i18n/locales/fr/app.json
@@ -6049,6 +6049,7 @@
"changes": "Modifications",
"comments": "Commentaires",
"definition": "Définition",
+ "chat": "Chat",
"documents": "Documents",
"logs": "Journaux",
"model": "Modèle",
diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json
index 56646c5d09..8c2d756e26 100644
--- a/packages/i18n/locales/ko/app.json
+++ b/packages/i18n/locales/ko/app.json
@@ -6049,6 +6049,7 @@
"changes": "변경 사항",
"comments": "댓글",
"definition": "정의",
+ "chat": "Chat",
"documents": "문서",
"logs": "로그",
"model": "모델",
diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json
index c7b990b999..1bca616482 100644
--- a/packages/i18n/locales/zh-CN/app.json
+++ b/packages/i18n/locales/zh-CN/app.json
@@ -6049,6 +6049,7 @@
"changes": "变更",
"comments": "评论",
"definition": "定义",
+ "chat": "Chat",
"documents": "文档",
"logs": "日志",
"model": "模型",
diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json
index 27b79bedc2..06d1dfc7d1 100644
--- a/packages/i18n/locales/zh-TW/app.json
+++ b/packages/i18n/locales/zh-TW/app.json
@@ -6049,6 +6049,7 @@
"changes": "變更",
"comments": "評論",
"definition": "定義",
+ "chat": "Chat",
"documents": "文件",
"logs": "日誌",
"model": "模型",