FN-6300: add live task chat tab

Add a live chat-style task detail tab for following and steering active agent work.

- Add TaskChatTab with grouped agent log bubbles, live-follow scrolling, and steering message composer.
- Share chat input autosize helpers between ChatView and task chat.
- Wire the Chat tab into task detail navigation with localized labels and regression coverage.

Files changed:
 docs/dashboard-guide.md                            |   2 +
 .../dashboard/app/components/AgentLogViewer.tsx    |   2 +-
 packages/dashboard/app/components/ChatView.tsx     |  25 +-
 packages/dashboard/app/components/TaskChatTab.css  | 193 ++++++++++++++
 packages/dashboard/app/components/TaskChatTab.tsx  | 276 +++++++++++++++++++++
 .../dashboard/app/components/TaskDetailModal.tsx   |  13 +-
 .../app/components/__tests__/TaskChatTab.test.tsx  | 181 ++++++++++++++
 .../TaskDetailModal.attachments-and-tabs.test.tsx  |  19 +-
 .../TaskDetailModal.definition-actions.test.tsx    | 104 ++++----
 ...etailModal.responsive-and-dependencies.test.tsx |   2 +-
 packages/dashboard/app/utils/chatInputAutosize.ts  |  18 ++
 packages/i18n/locales/en/app.json                  |   1 +
 packages/i18n/locales/es/app.json                  |   1 +
 packages/i18n/locales/fr/app.json                  |   1 +
 packages/i18n/locales/ko/app.json                  |   1 +
 packages/i18n/locales/zh-CN/app.json               |   1 +
 packages/i18n/locales/zh-TW/app.json               |   1 +
 17 files changed, 757 insertions(+), 84 deletions(-)

Fusion-Task-Id: FN-6300
Fusion-Task-Lineage: 1eb8a17b-3ca5-4cba-81c6-ac7ccce0a113
This commit is contained in:
gsxdsm
2026-06-12 10:00:08 -07:00
parent dcb049587f
commit 4bd6024ef7
17 changed files with 757 additions and 84 deletions

View File

@@ -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.

View File

@@ -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 }) => <p {...props}>{linkifyReactChildren(children)}</p>,
li: ({ children, ...props }) => <li {...props}>{linkifyReactChildren(children)}</li>,
code: ({ children, ...props }) => {

View File

@@ -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<string, boolean>;
}
// 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();

View File

@@ -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;
}
}

View File

@@ -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<AgentLogGroup[]>((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 (
<article className={className} data-testid={`task-chat-entry-${entry.type}`}>
<div className="task-chat-entry-kicker">{formatEntryLabel(entry)}</div>
<div className="task-chat-entry-text">{entry.text}</div>
{entry.detail ? <pre className="task-chat-tool-detail">{linkifyFilePaths(entry.detail)}</pre> : null}
</article>
);
}
return (
<article className={className} data-testid={`task-chat-entry-${entry.type}`}>
{entry.type === "thinking" ? <div className="task-chat-entry-kicker">{formatEntryLabel(entry)}</div> : null}
<div className="markdown-body task-chat-markdown">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{entry.text}
</ReactMarkdown>
</div>
</article>
);
}
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<HTMLDivElement>(null);
const previousEntryCountRef = useRef(0);
const previousScrollHeightRef = useRef(0);
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
void handleSubmit();
}
}, [handleSubmit]);
return (
<div className="task-chat-tab" data-testid="task-chat-tab">
<div
className="task-chat-transcript"
ref={transcriptRef}
onScroll={handleTranscriptScroll}
aria-live="polite"
>
{loading && entries.length === 0 ? (
<div className="task-chat-empty" role="status">
<Loader2 className="animate-spin" aria-hidden="true" />
<span>Loading agent output…</span>
</div>
) : entries.length === 0 ? (
<div className="task-chat-empty">No agent output yet. Live messages from Planner, Executor, Reviewer, and Merger agents will appear here.</div>
) : (
groups.map((group, groupIndex) => {
const avatarAgent = {
id: group.role ?? "agent",
name: group.label,
icon: getRoleIcon(group.role),
};
return (
<section className="task-chat-group" key={`${group.role ?? "agent"}-${groupIndex}`} aria-label={`${group.label} messages`}>
<header className="task-chat-group-header">
<AgentAvatar agent={avatarAgent} className="task-chat-avatar" />
<div>
<div className="task-chat-role-label">{group.label}</div>
<div className="task-chat-group-meta">{group.entries.length === 1 ? "1 entry" : `${group.entries.length} entries`}</div>
</div>
</header>
<div className="task-chat-group-bubbles">
{group.entries.map((entry, entryIndex) => (
<TaskChatEntry key={getEntryKey(entry, entryIndex)} entry={entry} />
))}
</div>
</section>
);
})
)}
</div>
<form className="task-chat-composer card" onSubmit={handleSubmit}>
{!activeSession ? (
<div className="task-chat-session-hint" role="status">
No active assigned agent session is available. Move the task into progress with an assigned agent to send guidance.
</div>
) : null}
<div className="task-chat-composer-row">
<textarea
ref={textareaRef}
className="input task-chat-input"
value={draft}
placeholder={activeSession ? "Message the active agent session…" : "No active session available"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={handleKeyDown}
disabled={!activeSession || sending}
aria-label="Message active agent session"
rows={1}
/>
<button type="submit" className="btn btn-primary task-chat-send" disabled={!canSend}>
{sending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Send aria-hidden="true" />}
<span>{sending ? "Sending" : "Send"}</span>
</button>
</div>
</form>
</div>
);
}

View File

@@ -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")}
</button>
<button
className={`detail-tab${activeTab === "chat" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("chat")}
>
{t("taskDetail.tabs.chat", "Chat")}
</button>
<button
className={`detail-tab${activeTab === "logs" ? " detail-tab-active" : ""}`}
onClick={() => setActiveTab("logs")}
@@ -3114,6 +3121,10 @@ export function TaskDetailContent({
<div className="detail-section">
<ModelSelectorTab task={task} addToast={addToast} onTaskUpdated={onTaskUpdated} settings={settings} />
</div>
) : activeTab === "chat" ? (
<div className="detail-section">
<TaskChatTab task={task} projectId={projectId} active={activeTab === "chat"} addToast={addToast} />
</div>
) : activeTab === "logs" ? (
<div className={`detail-section${logSubview === "agent-log" ? " detail-section--agent-log" : ""}`}>
<div className="log-subview-toggle">

View File

@@ -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> = {}): 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>): 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(<TaskChatTab task={makeTask()} active={false} projectId="project-1" addToast={vi.fn()} />);
expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1");
});
it("renders empty state when no agent output exists", () => {
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
expect(screen.getByText("first live chunk")).toBeTruthy();
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask({ column: "todo", assignedAgentId: undefined, status: undefined })} active addToast={vi.fn()} />);
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(<TaskChatTab task={makeTask()} active addToast={addToast} />);
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");
});
});

View File

@@ -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

View File

@@ -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(
<TaskDetailModal
task={makeTask({
@@ -278,19 +281,20 @@ describe("TaskDetailModal", () => {
/>,
);
// 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",
]);
});

View File

@@ -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

View File

@@ -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));
}

View File

@@ -6100,6 +6100,7 @@
"changes": "Changes",
"comments": "Comments",
"definition": "Definition",
"chat": "Chat",
"documents": "Documents",
"logs": "Logs",
"model": "Model",

View File

@@ -6049,6 +6049,7 @@
"changes": "Cambios",
"comments": "Comentarios",
"definition": "Definición",
"chat": "Chat",
"documents": "Documentos",
"logs": "Registros",
"model": "Modelo",

View File

@@ -6049,6 +6049,7 @@
"changes": "Modifications",
"comments": "Commentaires",
"definition": "Définition",
"chat": "Chat",
"documents": "Documents",
"logs": "Journaux",
"model": "Modèle",

View File

@@ -6049,6 +6049,7 @@
"changes": "변경 사항",
"comments": "댓글",
"definition": "정의",
"chat": "Chat",
"documents": "문서",
"logs": "로그",
"model": "모델",

View File

@@ -6049,6 +6049,7 @@
"changes": "变更",
"comments": "评论",
"definition": "定义",
"chat": "Chat",
"documents": "文档",
"logs": "日志",
"model": "模型",

View File

@@ -6049,6 +6049,7 @@
"changes": "變更",
"comments": "評論",
"definition": "定義",
"chat": "Chat",
"documents": "文件",
"logs": "日誌",
"model": "模型",