Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
122 changes: 121 additions & 1 deletion src/components/chat/__tests__/chat-activity.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe("ChatActivity", () => {

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

expect(screen.getByText(/2 steps/)).toBeInTheDocument();
expect(screen.getByText(/\b2 steps/)).toBeInTheDocument();
expect(screen.getByText("1.0s")).toBeInTheDocument(); // 150ms + 850ms = 1000ms = 1.0s
expect(screen.getByText(/1 tool calls/)).toBeInTheDocument();
});
Expand Down Expand Up @@ -182,3 +182,123 @@ describe("ChatActivity", () => {
expect(mockWriteText).toHaveBeenCalledWith("4");
});
});

/**
* httpcalls is plumbing, not activity. An OpenAPI-provisioned agent (the
* Platform Operator) carries one httpcalls workflow step per endpoint group, so
* before this filter its every turn opened with dozens of identical unnamed
* rows — "44 steps" for a greeting — burying the one row that mattered.
*/
describe("ChatActivity — httpcalls pipeline steps", () => {
const httpcallsStep = (id: string, extra: Partial<PipelineEvent> = {}): PipelineEvent[] => [
{ type: "task_start", taskType: "ai.labs.httpcalls", taskId: id, index: 0, timestamp: Date.now() },
{ type: "task_complete", taskType: "ai.labs.httpcalls", taskId: id, index: 0, timestamp: Date.now(), ...extra },
];

it("hides bare httpcalls steps in end-user mode", () => {
renderWithProviders(
<ChatActivity
events={[...httpcallsStep("h1"), ...httpcallsStep("h2")]}
isLive={false}
showInternalSteps={false}
/>,
);
expect(screen.queryByText("httpcalls")).not.toBeInTheDocument();
});

it("still shows an httpcalls step that failed", () => {
const events: PipelineEvent[] = [
{ type: "task_start", taskType: "ai.labs.httpcalls", taskId: "h1", index: 0, timestamp: Date.now() },
{ type: "task_failed", taskType: "ai.labs.httpcalls", taskId: "h1", index: 0, timestamp: Date.now() },
];
renderWithProviders(<ChatActivity events={events} isLive={false} showInternalSteps={false} />);
expect(screen.getByText("httpcalls")).toBeInTheDocument();
});

it("keeps them all in debug mode", () => {
renderWithProviders(
<ChatActivity events={httpcallsStep("h1")} isLive={false} showInternalSteps={true} />,
);
expect(screen.getByText("httpcalls")).toBeInTheDocument();
});
});

/**
* "unknown" is the backend classifier's shrug, not a diagnosis. Rendered alone
* as a badge it looked like the error itself — an admin saw "UNKNOWN" and
* nothing else, and had to go to the server log to learn the turn failed.
*/
describe("ChatActivity — failed step detail", () => {
const failed = (extra: Partial<PipelineEvent>): PipelineEvent[] => [
{ type: "task_start", taskType: "ai.labs.langchain", taskId: "l1", index: 0, timestamp: Date.now() },
{ type: "task_failed", taskType: "ai.labs.langchain", taskId: "l1", index: 0, timestamp: Date.now(), ...extra },
];

it("suppresses the meaningless 'unknown' badge but keeps the summary", () => {
renderWithProviders(
<ChatActivity
events={failed({ errorType: "unknown", errorSummary: "temperature is deprecated for this model" })}
isLive={false}
/>,
);
expect(screen.queryByText("unknown")).not.toBeInTheDocument();
expect(screen.getByText(/temperature is deprecated/)).toBeInTheDocument();
});

it("points at the server log when the failure arrives with no detail at all", () => {
renderWithProviders(<ChatActivity events={failed({})} isLive={false} />);
expect(screen.getByTestId("task-error-detail")).toHaveTextContent(/server log has the full error/i);
});

it("still shows a REAL classification as a badge", () => {
renderWithProviders(
<ChatActivity events={failed({ errorType: "timeout", errorSummary: "took too long" })} isLive={false} />,
);
expect(screen.getByText("timeout")).toBeInTheDocument();
});
});

/**
* Filtering the rows while summarising the unfiltered set re-created the exact
* complaint the filter fixed: one visible row under a header still boasting
* "46 steps". The summary must describe what the user can see — except the
* duration, which reports the TURN's real latency and deliberately includes
* hidden plumbing time.
*/
describe("ChatActivity — summary metrics follow the filtered list", () => {
it("hidden httpcalls steps do not inflate the step count", () => {
const events: PipelineEvent[] = [];
for (let i = 0; i < 45; i++) {
events.push(
{ type: "task_start", taskType: "ai.labs.httpcalls", taskId: `h${i}`, index: i, timestamp: Date.now() },
{ type: "task_complete", taskType: "ai.labs.httpcalls", taskId: `h${i}`, index: i, timestamp: Date.now(), durationMs: 2 },
);
}
events.push(
{ type: "task_start", taskType: "ai.labs.langchain", taskId: "l1", index: 45, timestamp: Date.now() },
{
type: "task_complete", taskType: "ai.labs.langchain", taskId: "l1", index: 45, timestamp: Date.now(),
durationMs: 900,
toolTrace: [{ type: "tool_call", tool: "readAgentDescriptors" }],
},
);

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

expect(screen.getByText(/\b1 steps/)).toBeInTheDocument();
expect(screen.queryByText(/\b46 steps/)).not.toBeInTheDocument();
// The duration is the turn's, not the visible row's: 45×2ms + 900ms.
expect(screen.getByText("990ms")).toBeInTheDocument();
});

it("debug mode still reports every step", () => {
const events: PipelineEvent[] = [
{ type: "task_start", taskType: "ai.labs.httpcalls", taskId: "h1", index: 0, timestamp: Date.now() },
{ type: "task_complete", taskType: "ai.labs.httpcalls", taskId: "h1", index: 0, timestamp: Date.now() },
{ type: "task_start", taskType: "ai.labs.parser", taskId: "p1", index: 1, timestamp: Date.now() },
{ type: "task_complete", taskType: "ai.labs.parser", taskId: "p1", index: 1, timestamp: Date.now() },
];
renderWithProviders(<ChatActivity events={events} isLive={false} showInternalSteps={true} />);
expect(screen.getByText(/\b2 steps/)).toBeInTheDocument();
});
});
78 changes: 78 additions & 0 deletions src/components/chat/__tests__/chat-drawer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -250,3 +250,81 @@ describe("ChatDrawer", () => {
});
});
});

describe("ChatDrawer — attachments", () => {
beforeEach(() => {
window.HTMLElement.prototype.scrollIntoView = vi.fn();
useChatDrawerStore.setState({
isOpen: true,
agentId: "agent-1",
agentName: "Agent One",
step: "ready",
errorMessage: null,
});
useChatStore.getState().reset();
useChatStore.getState().setSelectedAgent("agent-1", "Agent One");
useChatStore.getState().setConversationId("conv-d1");
useChatStore.setState({ streamingEnabled: false });
server.use(
http.post("*/conversations/conv-d1/attachments", () =>
HttpResponse.json(
{
storageRef: "drawer-ref-1",
fileName: "notes.txt",
mimeType: "text/plain",
sizeBytes: 5,
forwardableInline: true,
},
{ status: 201 },
),
),
);
});

it("stages a picked file and forwards it as attachment_* context on send", async () => {
const user = userEvent.setup();
let sentBody:
| { input?: string; context?: Record<string, { value?: { storageRef?: string } }> }
| null = null;
server.use(
http.post("*/agents/conv-d1", async ({ request }) => {
sentBody = (await request.json()) as typeof sentBody;
return HttpResponse.json({ conversationOutputs: [] });
}),
);

renderWithProviders(<ChatDrawer />);

await user.upload(
screen.getByTestId("drawer-file-input"),
new File(["hello"], "notes.txt", { type: "text/plain" }),
);
await screen.findByTestId("attachment-chip");

await user.type(screen.getByTestId("drawer-chat-input"), "see file");
await user.click(screen.getByTestId("drawer-chat-send"));

await waitFor(() => {
expect(sentBody?.context?.attachment_0?.value?.storageRef).toBe("drawer-ref-1");
});
expect(screen.queryByTestId("attachment-chip")).not.toBeInTheDocument();
});

it("allows an attachment-only send once the upload is ready", async () => {
const user = userEvent.setup();
server.use(
http.post("*/agents/conv-d1", async () => HttpResponse.json({ conversationOutputs: [] })),
);
renderWithProviders(<ChatDrawer />);

const sendBtn = screen.getByTestId("drawer-chat-send");
expect(sendBtn).toBeDisabled();

await user.upload(
screen.getByTestId("drawer-file-input"),
new File(["hello"], "notes.txt", { type: "text/plain" }),
);
await screen.findByTestId("attachment-chip");
await waitFor(() => expect(sendBtn).toBeEnabled());
});
});
28 changes: 27 additions & 1 deletion src/components/chat/__tests__/chat-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, beforeAll, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { screen, waitFor, fireEvent } from "@testing-library/react";
import { renderWithProviders, userEvent } from "@/test/test-utils";
import { ChatPanel } from "../chat-panel";
import { useChatStore } from "@/hooks/use-chat";
Expand Down Expand Up @@ -246,6 +246,32 @@ describe("ChatPanel", () => {
expect(await screen.findByText("●●●●●●●●")).toBeInTheDocument();
});

it("dropping a file on the chat area stages it as an attachment", async () => {
useChatStore.getState().setSelectedAgent("agent1", "Test Agent");
useChatStore.getState().setConversationId("conv1");
server.use(
http.post("*/conversations/conv1/attachments", () =>
HttpResponse.json(
{ storageRef: "drop-ref-1", fileName: "dropped.txt", mimeType: "text/plain", sizeBytes: 3, forwardableInline: true },
{ status: 201 },
),
),
);
renderWithProviders(<ChatPanel />);

const zone = screen.getByTestId("chat-input").closest(".relative")!;
const dataTransfer = {
files: [new File(["abc"], "dropped.txt", { type: "text/plain" })],
types: ["Files"],
};
fireEvent.dragEnter(zone, { dataTransfer });
expect(screen.getByTestId("file-drop-overlay")).toBeInTheDocument();
fireEvent.drop(zone, { dataTransfer });

expect(await screen.findByTestId("attachment-chip")).toBeInTheDocument();
expect(screen.queryByTestId("file-drop-overlay")).not.toBeInTheDocument();
});

it("blocks attachments in secret mode — disables attach, drops staged files, never forwards", async () => {
const user = userEvent.setup();
useChatStore.getState().setSelectedAgent("agent1", "Test Agent");
Expand Down
106 changes: 106 additions & 0 deletions src/components/chat/attachment-chip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* A single pending-attachment chip: thumbnail/icon, name, size, status +
* remove. Shared between the chat panel and the operator chat — moved out of
* chat-panel when the operator input gained attachments.
*/
import { useTranslation } from "react-i18next";
import { AlertTriangle, FileText, Loader2, Paperclip, X } from "lucide-react";
import { formatBytes, isImageMime } from "@/lib/api/attachments";
import type { PendingAttachment } from "@/hooks/use-attachment-staging";
import { cn } from "@/lib/utils";

/**
* Full-container overlay shown while a file drag hovers a chat drop zone.
* The container must be `position: relative`; pointer events pass through so
* the drop lands on the container's own handlers.
*/
export function FileDropOverlay() {
const { t } = useTranslation();
return (
<div
className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-xl border-2 border-dashed border-primary bg-background/80"
data-testid="file-drop-overlay"
// Purely visual drag affordance for a pointer interaction — announcing
// it would only interrupt the adjacent aria-live transcript.
aria-hidden="true"
>
<div className="flex items-center gap-2 text-sm font-medium text-primary">
<Paperclip className="h-5 w-5" />
{t("chat.dropToAttach", "Drop files to attach")}
</div>
</div>
);
}

export function PendingAttachmentChip({
att,
onRemove,
}: {
att: PendingAttachment;
onRemove: () => void;
}) {
const { t } = useTranslation();
const isImage = isImageMime(att.file.type) && att.previewUrl;
const isError = att.status === "error";

return (
<div
className={cn(
"group relative flex items-center gap-2 rounded-lg border bg-card px-2 py-1.5 pe-7 text-xs",
isError ? "border-destructive/40" : "border-border"
)}
title={att.error ?? att.file.name}
data-testid="attachment-chip"
>
{/* Thumbnail / icon */}
{isImage ? (
<img
src={att.previewUrl}
alt=""
className="h-8 w-8 shrink-0 rounded object-cover"
onError={(e) => {
(e.target as HTMLElement).style.display = "none";
}}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded bg-muted">
{isError ? (
<AlertTriangle className="h-4 w-4 text-destructive" />
) : (
<FileText className="h-4 w-4 text-muted-foreground" />
)}
</div>
)}

{/* Name + size / status */}
<div className="flex min-w-0 flex-col">
<span className="max-w-[140px] truncate font-medium text-foreground">
{att.file.name}
</span>
<span className={cn("truncate", isError ? "text-destructive" : "text-muted-foreground")}>
{att.status === "uploading"
? t("chat.attachUploading", "Uploading...")
: isError
? (att.error ?? t("chat.attachError", "Failed to upload file"))
: formatBytes(att.result?.sizeBytes ?? att.file.size)}
</span>
</div>

{/* Uploading spinner overlays the remove slot */}
{att.status === "uploading" ? (
<Loader2 className="absolute inset-e-1.5 top-1.5 h-4 w-4 animate-spin text-muted-foreground" />
) : (
<button
type="button"
onClick={onRemove}
className="absolute inset-e-1 top-1 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground/60 hover:bg-muted hover:text-foreground"
title={t("common.remove", "Remove")}
aria-label={`${t("common.remove", "Remove")} ${att.file.name}`}
data-testid="attachment-remove"
>
<X className="h-3 w-3" />
</button>
)}
</div>
);
}
Loading