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 1 commit
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();
});
});
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
47 changes: 39 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,19 @@ 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>({
deps: [messages, isThinking, isProcessing],
bottomThreshold: 80,
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

const handleNewConversation = useCallback(() => {
if (!agentId) return;
Expand Down Expand Up @@ -206,7 +216,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 +238,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
51 changes: 42 additions & 9 deletions src/components/operator/operator-chat.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useState, useRef, useEffect, type ReactNode } from "react";
import { useState, useRef, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { formatMarkdownText } from "@/components/groups/group-utils";
import { Send, Square, RotateCcw, AlertTriangle, Bot, User, PauseCircle, Loader2, Paperclip } from "lucide-react";
import { Send, Square, RotateCcw, AlertTriangle, ArrowDown, Bot, User, PauseCircle, Loader2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ChatActivity } from "@/components/chat/chat-activity";
import { InputHint } from "@/components/chat/input-hint";
Expand All @@ -17,6 +17,7 @@ import {
useFileDrop,
type ReadyAttachment,
} from "@/hooks/use-attachment-staging";
import { useSmartAutoScroll } from "@/hooks/use-smart-auto-scroll";
import { OPERATOR_STARTER_PROMPTS } from "@/lib/operator/system-prompt";
import type { ChatMessage } from "@/lib/api/chat";
import type { SentAttachment } from "@/hooks/use-chat";
Expand Down Expand Up @@ -130,10 +131,23 @@ export function OperatorChat({
}: OperatorChatProps) {
const { t } = useTranslation();
const [input, setInput] = useState("");
const endRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);

// Smart auto-scroll: follows new content only while the user is AT the
// bottom. Scrolled up (reading something mid-stream), the view stays put and
// a centered arrow offers the way back down.
const {
scrollRef,
showScrollFab,
hasNewContent,
scrollToBottom,
handleScroll,
} = useSmartAutoScroll<HTMLDivElement>({
deps: [messages, events.length, isStreaming],
bottomThreshold: 80,
});

// Same staging as the main chat panel — picker, paste, chips, per-turn cap.
const {
pendingAttachments,
Expand All @@ -149,10 +163,6 @@ export function OperatorChat({
void stageFiles(files);
});

useEffect(() => {
endRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, events]);

/** Grow with content up to the same 120px ceiling as chat-drawer. */
function autoResizeInput() {
const el = inputRef.current;
Expand Down Expand Up @@ -183,8 +193,11 @@ export function OperatorChat({
return (
<div className="relative flex h-full min-h-0 flex-col rounded-xl border border-border" {...dropHandlers}>
{isDragOver && <FileDropOverlay />}
<div className="relative flex-1 min-h-0">
<div
className="flex-1 space-y-4 overflow-y-auto p-4"
ref={scrollRef}
onScroll={handleScroll}
className="h-full space-y-4 overflow-y-auto p-4"
data-testid="operator-messages"
role="log"
aria-live="polite"
Expand Down Expand Up @@ -347,7 +360,27 @@ export function OperatorChat({
</div>
)}

<div ref={endRef} />
</div>

{/* Centered scroll-to-bottom arrow — shown 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="operator-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>

{/* Pending attachment chips (staged, uploading, errored) */}
Expand Down
3 changes: 2 additions & 1 deletion src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,8 @@
"toolCall": "استدعاء أداة",
"toolResult": "النتيجة",
"error": "حدث خطأ",
"toolCallsCount": "{{count}} من استدعاءات الأدوات"
"toolCallsCount": "{{count}} من استدعاءات الأدوات",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
"usingTool": "جارٍ استخدام {{tool}}…"
},
"context": {
"conversationId": "معرف المحادثة",
Expand Down
Loading
Loading