Skip to content
This repository was archived by the owner on Sep 17, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion src/components/chat/__tests__/chat-activity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,26 @@ describe("ChatActivity", () => {
},
];

// End-user live mode is now a single status line — no step fraction, no
// rows. The classic Processing bar lives on the debug surface.
renderWithProviders(<ChatActivity events={events} isLive={true} totalSteps={5} />);
expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument();
expect(screen.getByText(/Thinking/)).toBeInTheDocument();
expect(screen.queryByText("0/5")).not.toBeInTheDocument();
});

it("debug surface keeps the classic processing bar while live", () => {
const events: PipelineEvent[] = [
{
type: "task_start",
taskType: "ai.labs.rules",
taskId: "1",
index: 0,
timestamp: Date.now(),
},
];

renderWithProviders(<ChatActivity events={events} isLive={true} totalSteps={5} showInternalSteps />);
expect(screen.getByText(/Processing…/)).toBeInTheDocument();
expect(screen.getByText("0/5")).toBeInTheDocument();
});
Expand Down Expand Up @@ -116,6 +135,8 @@ describe("ChatActivity", () => {
});

it("renders details and supports tool tracing, expansion, and copying details", async () => {
// Details are a resting-state affordance in end-user mode now — live shows
// only the status line.
const user = userEvent.setup();
const events: PipelineEvent[] = [
{
Expand All @@ -140,7 +161,7 @@ describe("ChatActivity", () => {
},
];

renderWithProviders(<ChatActivity events={events} isLive={true} />);
renderWithProviders(<ChatActivity events={events} isLive={false} />);

// Since isLive is true, it should start expanded
expect(screen.getByText("llm")).toBeInTheDocument();
Expand Down Expand Up @@ -302,3 +323,72 @@ describe("ChatActivity — summary metrics follow the filtered list", () => {
expect(screen.getByText(/\b2 steps/)).toBeInTheDocument();
});
});

/**
* End-user live mode is one status line: what the agent is doing right now.
* The wall of internal step rows (dozens of identical httpcalls entries, some
* spinning forever when their completion never pairs up) is debug-surface-only.
*/
describe("ChatActivity — end-user live status line", () => {
const start = (taskType: string, index: number): PipelineEvent => ({
type: "task_start",
taskType,
taskId: String(index),
index,
timestamp: Date.now(),
});

it("shows Thinking before any tool call, and never the step rows", () => {
const events: PipelineEvent[] = [
start("ai.labs.httpcalls", 0),
{ ...start("ai.labs.httpcalls", 0), type: "task_complete", durationMs: 1 },
start("ai.labs.httpcalls", 1),
];

renderWithProviders(<ChatActivity events={events} isLive={true} />);

expect(screen.getByTestId("chat-activity-live-status")).toBeInTheDocument();
expect(screen.getByText(/Thinking/)).toBeInTheDocument();
// No row list, no expander — the httpcalls wall must not render live.
expect(screen.queryByTestId("chat-activity-toggle")).not.toBeInTheDocument();
expect(screen.queryByText("httpCalls")).not.toBeInTheDocument();
});

it("names the tool currently in use once a toolTrace event arrives", () => {
const events: PipelineEvent[] = [
start("ai.labs.httpcalls", 0),
{
...start("ai.labs.httpcalls", 0),
type: "task_complete",
durationMs: 2,
toolTrace: [
{ type: "tool_call", tool: "readAgent", arguments: "{}" },
{ type: "tool_result", tool: "readAgent", result: "ok" },
],
},
start("ai.labs.httpcalls", 1),
];

renderWithProviders(<ChatActivity events={events} isLive={true} />);

expect(screen.getByText(/readAgent/)).toBeInTheDocument();
expect(screen.getByText(/1 tool call/)).toBeInTheDocument();
});

it("a failed task breaks through the minimal line to the full view", () => {
const events: PipelineEvent[] = [
start("ai.labs.langchain", 0),
{
...start("ai.labs.langchain", 0),
type: "task_failed",
errorType: "timeout",
errorSummary: "provider timed out",
},
];

renderWithProviders(<ChatActivity events={events} isLive={true} />);

expect(screen.queryByTestId("chat-activity-live-status")).not.toBeInTheDocument();
expect(screen.getByTestId("chat-activity-toggle")).toBeInTheDocument();
});
});
37 changes: 37 additions & 0 deletions src/components/chat/__tests__/chat-drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,40 @@ describe("ChatDrawer — attachments", () => {
await waitFor(() => expect(sendBtn).toBeEnabled());
});
});

describe("ChatDrawer — opens scrolled to the bottom", () => {
it("an existing transcript mounts at the bottom, not the top", async () => {
// jsdom has no scrollTo, so the hook falls back to scrollTop = scrollHeight
// — give every element a height so that assignment is observable.
const proto = window.HTMLElement.prototype;
const original = Object.getOwnPropertyDescriptor(proto, "scrollHeight");
Object.defineProperty(proto, "scrollHeight", { configurable: true, get: () => 500 });
try {
useChatStore.getState().setSelectedAgent("agent-1", "Agent One");
useChatStore.getState().setConversationId("conv-s1");
for (let i = 0; i < 5; i++) {
useChatStore.getState().addMessage({
id: `m-${i}`,
role: i % 2 ? "agent" : "user",
content: `message ${i}`,
timestamp: Date.now(),
});
}
useChatDrawerStore.setState({
isOpen: true,
agentId: "agent-1",
agentName: "Agent One",
step: "ready",
errorMessage: null,
});

const { container } = renderWithProviders(<ChatDrawer />);
const scroller = container.querySelector('[aria-live="polite"]') as HTMLElement;
expect(scroller).toBeTruthy();
await waitFor(() => expect(scroller.scrollTop).toBe(500));
} finally {
if (original) Object.defineProperty(proto, "scrollHeight", original);
else delete (proto as unknown as Record<string, unknown>).scrollHeight;
}
});
});
43 changes: 43 additions & 0 deletions src/components/chat/chat-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,17 @@ export function ChatActivity({ events, isLive, totalSteps, showInternalSteps = f

const shouldPulse = isLive && hasRunning;

// The tool the agent is on right now — best live evidence is the last
// tool_call in the most recent event carrying a toolTrace. Null until the
// first tool call: the turn is purely "thinking" until then.
const currentTool = useMemo(() => {
for (let i = events.length - 1; i >= 0; i--) {
const calls = events[i]?.toolTrace?.filter((e) => e.type === "tool_call");
if (calls?.length) return calls[calls.length - 1]!.tool;
}
return null;
}, [events]);

// Auto-expand if tool calls, cascade steps, or errors are present
useEffect(() => {
if (toolCallCount > 0 || cascadeSteps.length > 0 || rawTasks.some((t) => t.status === "error")) {
Expand All @@ -215,6 +226,38 @@ export function ChatActivity({ events, isLive, totalSteps, showInternalSteps = f
if (!showInternalSteps && !isLive && tasks.length === 0 && cascadeSteps.length === 0) return null;
if (rawTasks.length === 0 && cascadeSteps.length === 0) return null;

// End-user LIVE mode: a scrolling wall of internal step rows (an
// OpenAPI-provisioned agent's pipeline is dozens of identical httpcalls
// rows, and a row whose completion event never pairs up spins forever) says
// nothing a user can act on. Show one honest status line instead — what the
// agent is doing right now — and keep the step list for the debug surface
// and the resting summary. Two things still break through to the full view
// mid-turn, because they ARE actionable: a failed task (its classified
// error must not hide behind a cheerful spinner) and a model-cascade trace
// (escalations are the point of watching one).
const hasErrorTask = rawTasks.some((task) => task.status === "error");
if (!showInternalSteps && isLive && !hasErrorTask && cascadeSteps.length === 0) {
return (
<div className="flex justify-center px-4 py-1" data-testid="chat-activity">
<div className="w-full max-w-[85%] rounded-xl border border-primary/30 bg-primary/5">
<div className="flex items-center gap-2 px-3 py-2 text-xs" data-testid="chat-activity-live-status">
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-primary" />
<span className="font-medium text-primary">
{currentTool
? t("chat.activity.usingTool", "Using {{tool}}…", { tool: currentTool })
: t("chat.thinking", "Thinking...")}
</span>
{toolCallCount > 0 && (
<span className="text-muted-foreground">
{t("chat.activity.toolCallsCount", "{{count}} tool calls", { count: toolCallCount })}
</span>
)}
</div>
</div>
</div>
);
}

return (
<div className="flex justify-center px-4 py-1" data-testid="chat-activity">
<div
Expand Down
50 changes: 42 additions & 8 deletions src/components/chat/chat-drawer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { useRef, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useChatDrawerStore, type ChatDrawerStep } from "@/hooks/use-chat-drawer";
import { useChatStore, useStartConversation, useSendMessage } from "@/hooks/use-chat";
Expand All @@ -15,8 +15,10 @@ import { FileDropOverlay, PendingAttachmentChip } from "./attachment-chip";
import { StreamingToggle } from "./streaming-toggle";
import { DebugDrawer as DebugPanel } from "@/components/debugger/debug-drawer";
import { InputHint } from "@/components/chat/input-hint";
import { useSmartAutoScroll } from "@/hooks/use-smart-auto-scroll";
import { cn } from "@/lib/utils";
import {
ArrowDown,
Bot,
X,
MessageSquarePlus,
Expand Down Expand Up @@ -79,7 +81,6 @@ export function ChatDrawer() {
const isThinking = useChatStore((s) => s.isThinking);

const startConversation = useStartConversation();
const messagesEndRef = useRef<HTMLDivElement>(null);

// Attachment staging shared between the drop zone (drawer body) and the
// input's picker/paste — one staging area, same as the main panel.
Expand All @@ -89,10 +90,22 @@ export function ChatDrawer() {
(files) => void staging.stageFiles(files),
);

// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
// Smart auto-scroll: follows new content only while the user is AT the
// bottom; scrolled up, the view stays put and a centered arrow offers the
// way back down.
const {
scrollRef,
showScrollFab,
hasNewContent,
scrollToBottom,
handleScroll,
} = useSmartAutoScroll<HTMLDivElement>({
// isOpen: the scroll container mounts only when the drawer opens, so
// without it an existing transcript opens scrolled to the TOP with no
// way down but manual scrolling.
deps: [messages, isThinking, isProcessing, isOpen],
bottomThreshold: 80,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const handleNewConversation = useCallback(() => {
if (!agentId) return;
Expand Down Expand Up @@ -206,7 +219,8 @@ export function ChatDrawer() {
{/* Chat messages */}
{(showChat || (step === "idle" && conversationId)) && (
<>
<div className="flex-1 overflow-y-auto" aria-live="polite" aria-relevant="additions">
<div className="relative flex-1 min-h-0">
<div ref={scrollRef} onScroll={handleScroll} className="h-full overflow-y-auto" aria-live="polite" aria-relevant="additions">
{messages.length === 0 ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
Expand All @@ -227,11 +241,31 @@ export function ChatDrawer() {
<span className="italic">{t("chat.thinking")}</span>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>

{/* Centered scroll-to-bottom arrow — only while scrolled up */}
{showScrollFab && (
<button
type="button"
onClick={() => scrollToBottom("smooth")}
className="absolute bottom-3 inset-x-0 mx-auto z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-card shadow-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all animate-in fade-in slide-in-from-bottom-2"
title={t("chat.scrollToBottom", "Scroll to bottom")}
aria-label={t("chat.scrollToBottom", "Scroll to bottom")}
data-testid="drawer-scroll-to-bottom"
>
<ArrowDown className="h-4 w-4" />
{hasNewContent && (
<span className="absolute -top-1 -end-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary opacity-75" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-primary" />
</span>
)}
</button>
)}
</div>

{/* Quick replies */}
<QuickRepliesBar />

Expand Down
4 changes: 2 additions & 2 deletions src/components/chat/chat-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ export function ChatPanel({ embedded = false }: { embedded?: boolean } = {}) {
{showScrollFab && (
<button
onClick={() => scrollToBottom("smooth")}
className="absolute bottom-4 end-4 z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-card shadow-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all animate-in fade-in slide-in-from-bottom-2"
className="absolute bottom-4 inset-x-0 mx-auto z-10 flex h-9 w-9 items-center justify-center rounded-full border border-border bg-card shadow-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-all animate-in fade-in slide-in-from-bottom-2"
title={t("chat.scrollToBottom", "Scroll to bottom")}
data-testid="scroll-to-bottom"
>
Expand Down Expand Up @@ -1061,7 +1061,7 @@ function InlineThinkingIndicator({ events }: { events: PipelineEvent[] }) {
? t("chat.activity.error", "Error occurred")
: activeToolNames.length > 0
? t("chat.usingTools", "Using tools")
: t("chat.thinking", "Thinking…")}
: t("chat.thinking", "Thinking...")}
</span>

{/* Tool names — inline, comma-separated */}
Expand Down
Loading