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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { ChatComposer } from "@/components/app/chat/chat-composer";
import type { AgentPin } from "@/components/app/types";

import {
isLongPaste,
Expand All @@ -37,18 +36,6 @@ beforeEach(() => {
});
});

const pins: AgentPin[] = [
{
id: "pin-1",
label: "Dev URL",
value: "http://localhost:5173",
type: "url",
},
{ id: "pin-2", label: "Branch", value: "feat/x", type: "string" },
{ label: "No id", value: "x", type: "string" },
{ id: "pin-3", label: "Run it", value: "go", type: "shortcut" },
];

function renderComposer(
props: Partial<Parameters<typeof ChatComposer>[0]> = {}
) {
Expand All @@ -63,7 +50,6 @@ function renderComposer(
agentId={null}
onSend={onSend}
uploadFile={uploadFile}
pins={pins}
disabledReason={null}
{...props}
/>
Expand Down Expand Up @@ -157,25 +143,19 @@ describe("ChatComposer attachments", () => {
]);
pasteText(input, "https://example.com/x");
pasteText(input, "line\n".repeat(90));
fireEvent.click(screen.getByTestId("chat-composer-pin-button"));
fireEvent.click(
(await screen.findAllByTestId("chat-composer-pin-option"))[0]!
);
fireEvent.change(input, { target: { value: "everything" } });
expect(screen.getAllByTestId("context-file-item")).toHaveLength(2);
expect(screen.getByTestId("chat-attachment-chip-pasted")).toBeTruthy();
expect(screen.getByTestId("context-link-item")).toBeTruthy();
expect(screen.getByTestId("chat-attachment-chip-pin")).toBeTruthy();

fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0]![1]).toHaveLength(5);
expect(onSend.mock.calls[0]![1]).toHaveLength(4);
await waitFor(() => expect(input.value).toBe(""));
expect(screen.queryByTestId("chat-composer-attachments")).toBeNull();
expect(screen.queryAllByTestId("context-file-item")).toHaveLength(0);
expect(screen.queryByTestId("chat-attachment-chip-pasted")).toBeNull();
expect(screen.queryByTestId("context-link-item")).toBeNull();
expect(screen.queryByTestId("chat-attachment-chip-pin")).toBeNull();
expect(screen.queryByTestId("chat-attachment-chip-placeholder")).toBeNull();
expect(screen.queryByTestId("chat-composer-error")).toBeNull();
// The image's object URL went with its chip.
Expand Down Expand Up @@ -276,28 +256,16 @@ describe("ChatComposer attachments", () => {
).toBe(true);
});

it("attaches a pin from the picker, skipping pins without ids and shortcuts", async () => {
it("offers no way to attach a pin: files and links are the user's kinds", () => {
renderComposer();
fireEvent.click(screen.getByTestId("chat-composer-pin-button"));
const options = await screen.findAllByTestId("chat-composer-pin-option");
expect(options.map((o) => o.getAttribute("data-pin-id"))).toEqual([
"pin-1",
"pin-2",
]);
fireEvent.click(options[0]!);
const chip = screen.getByTestId("chat-attachment-chip-pin");
expect(chip.textContent).toContain("Dev URL");
expect(chip.textContent).toContain("http://localhost:5173");
expect(screen.queryByTestId("chat-composer-pin-button")).toBeNull();
expect(screen.queryByLabelText("Attach a pin")).toBeNull();
});

it("uploads files on send and sends every attachment kind", async () => {
const { onSend, uploadFile, input } = renderComposer();
pasteFiles(input, [new File(["png"], "shot.png", { type: "image/png" })]);
pasteText(input, "https://example.com/x");
fireEvent.click(screen.getByTestId("chat-composer-pin-button"));
fireEvent.click(
(await screen.findAllByTestId("chat-composer-pin-option"))[1]!
);
fireEvent.change(input, { target: { value: "look at these" } });
fireEvent.keyDown(input, { key: "Enter" });

Expand All @@ -307,7 +275,6 @@ describe("ChatComposer attachments", () => {
expect(onSend).toHaveBeenCalledWith("look at these", [
{ type: "file", mediaId: "shot.png".length },
{ type: "link", url: "https://example.com/x" },
{ type: "pin", pinId: "pin-2" },
]);
await waitFor(() => expect(input.value).toBe(""));
expect(screen.queryByTestId("chat-composer-attachments")).toBeNull();
Expand Down Expand Up @@ -419,15 +386,11 @@ describe("ChatComposer attachments", () => {
).toBe("drop.md");
});

it("disables the attach buttons with the composer", () => {
it("disables the attach button with the composer", () => {
renderComposer({ disabledReason: "The agent is not running." });
expect(
(screen.getByTestId("chat-composer-attach-button") as HTMLButtonElement)
.disabled
).toBe(true);
expect(
(screen.getByTestId("chat-composer-pin-button") as HTMLButtonElement)
.disabled
).toBe(true);
});
});
118 changes: 1 addition & 117 deletions apps/web/src/components/app/chat/chat-composer-attachments.tsx
Original file line number Diff line number Diff line change
@@ -1,42 +1,9 @@
import { useState } from "react";
import { FileText, Paperclip, Pin } from "lucide-react";
import { FileText, Paperclip } from "lucide-react";

import { ContextChip } from "@/components/app/context-picker-items";
import { type AgentPin } from "@/components/app/types";
import { type ChatDraftFile } from "@/lib/chat-draft";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";

/** Pins the composer can attach: addressable by id, and not a button. */
export function attachablePins(pins: AgentPin[]): AgentPin[] {
return pins.filter((pin) => !!pin.id && pin.type !== "shortcut");
}

export function PinChip({
pin,
onRemove,
}: {
pin: AgentPin;
onRemove: () => void;
}): JSX.Element {
return (
<ContextChip
icon={<Pin />}
title={pin.label}
subtitle={pin.value}
onRemove={onRemove}
removeLabel={`Remove pin ${pin.label}`}
tooltip={`${pin.label}: ${pin.value}`}
testId="chat-attachment-chip-pin"
/>
);
}

/** The `pasted.txt` chip: a long paste turned into a file, with a way back. */
export function PastedTextChip({
file,
Expand Down Expand Up @@ -116,86 +83,3 @@ export function DraftPlaceholderChip({
/>
);
}

/**
* The paperclip's sibling: a popover listing the agent's pins so one can ride
* along with the message. Already-attached pins stay listed but inert.
*/
export function PinPickerButton({
pins,
attachedIds,
disabled,
onPick,
}: {
pins: AgentPin[];
attachedIds: ReadonlySet<string>;
disabled: boolean;
onPick: (pin: AgentPin) => void;
}): JSX.Element {
const [open, setOpen] = useState(false);
const candidates = attachablePins(pins);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
size="icon"
variant="ghost"
disabled={disabled}
title="Attach a pin"
aria-label="Attach a pin"
data-testid="chat-composer-pin-button"
className="h-7 w-7 shrink-0 text-muted-foreground pointer-coarse:h-11 pointer-coarse:min-h-11 pointer-coarse:w-11 pointer-coarse:min-w-11"
>
<Pin className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
side="top"
className="w-72 p-1"
data-testid="chat-composer-pin-picker"
>
{candidates.length === 0 ? (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No pins to attach yet.
</div>
) : (
<div className="flex max-h-64 flex-col overflow-y-auto">
{candidates.map((pin) => {
const attached = attachedIds.has(pin.id!);
return (
<button
key={pin.id}
type="button"
disabled={attached}
onClick={() => {
onPick(pin);
setOpen(false);
}}
className="flex w-full flex-col items-start gap-0.5 rounded px-2 py-1.5 text-left hover:bg-white/[0.1] disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent"
data-testid="chat-composer-pin-option"
data-pin-id={pin.id}
>
<span className="flex w-full items-center gap-1.5">
<span className="truncate text-[10px] uppercase tracking-wide text-muted-foreground/80">
{pin.label}
</span>
{attached ? (
<span className="ml-auto shrink-0 text-[10px] text-muted-foreground">
attached
</span>
) : null}
</span>
<span className="w-full truncate text-xs text-foreground">
{pin.value}
</span>
</button>
);
})}
</div>
)}
</PopoverContent>
</Popover>
);
}
Loading
Loading