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:
@@ -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.
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
193
packages/dashboard/app/components/TaskChatTab.css
Normal file
193
packages/dashboard/app/components/TaskChatTab.css
Normal 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;
|
||||
}
|
||||
}
|
||||
276
packages/dashboard/app/components/TaskChatTab.tsx
Normal file
276
packages/dashboard/app/components/TaskChatTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
181
packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx
Normal file
181
packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
18
packages/dashboard/app/utils/chatInputAutosize.ts
Normal file
18
packages/dashboard/app/utils/chatInputAutosize.ts
Normal 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));
|
||||
}
|
||||
@@ -6100,6 +6100,7 @@
|
||||
"changes": "Changes",
|
||||
"comments": "Comments",
|
||||
"definition": "Definition",
|
||||
"chat": "Chat",
|
||||
"documents": "Documents",
|
||||
"logs": "Logs",
|
||||
"model": "Model",
|
||||
|
||||
@@ -6049,6 +6049,7 @@
|
||||
"changes": "Cambios",
|
||||
"comments": "Comentarios",
|
||||
"definition": "Definición",
|
||||
"chat": "Chat",
|
||||
"documents": "Documentos",
|
||||
"logs": "Registros",
|
||||
"model": "Modelo",
|
||||
|
||||
@@ -6049,6 +6049,7 @@
|
||||
"changes": "Modifications",
|
||||
"comments": "Commentaires",
|
||||
"definition": "Définition",
|
||||
"chat": "Chat",
|
||||
"documents": "Documents",
|
||||
"logs": "Journaux",
|
||||
"model": "Modèle",
|
||||
|
||||
@@ -6049,6 +6049,7 @@
|
||||
"changes": "변경 사항",
|
||||
"comments": "댓글",
|
||||
"definition": "정의",
|
||||
"chat": "Chat",
|
||||
"documents": "문서",
|
||||
"logs": "로그",
|
||||
"model": "모델",
|
||||
|
||||
@@ -6049,6 +6049,7 @@
|
||||
"changes": "变更",
|
||||
"comments": "评论",
|
||||
"definition": "定义",
|
||||
"chat": "Chat",
|
||||
"documents": "文档",
|
||||
"logs": "日志",
|
||||
"model": "模型",
|
||||
|
||||
@@ -6049,6 +6049,7 @@
|
||||
"changes": "變更",
|
||||
"comments": "評論",
|
||||
"definition": "定義",
|
||||
"chat": "Chat",
|
||||
"documents": "文件",
|
||||
"logs": "日誌",
|
||||
"model": "模型",
|
||||
|
||||
Reference in New Issue
Block a user