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
135 changes: 135 additions & 0 deletions packages/views/channels/components/channel-composer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { createRef } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@multica/core/i18n/react";
import enCommon from "../../locales/en/common.json";
import enChannels from "../../locales/en/channels.json";
import type { Channel } from "@multica/core/types";

const TEST_RESOURCES = { en: { common: enCommon, channels: enChannels } };

// Capture the composer's drop handler so the test can fire a "real" drop.
const dropHandlers = vi.hoisted(() => ({
onDrop: null as null | ((files: File[]) => void),
}));

vi.mock("../../editor", () => ({
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
dropHandlers.onDrop = onDrop;
return { isDragOver: false, dropZoneProps: { "data-testid": "drop-zone" } };
},
FileDropOverlay: () => null,
ContentEditor: () => <div data-testid="editor" />,
}));

// api.uploadFile is the single upload path shared by the paperclip button
// and (now) drag-and-drop.
const uploadFile = vi.hoisted(() => vi.fn());
vi.mock("@multica/core/api", () => ({
api: { uploadFile },
}));

const sendMutate = vi.hoisted(() => vi.fn());
const inertOptions = (key: string) => ({
queryKey: [key],
queryFn: async () => [],
enabled: false,
});
vi.mock("@multica/core/channels", () => {
const state = {
inputDrafts: {} as Record<string, string>,
setInputDraft: vi.fn(),
clearInputDraft: vi.fn(),
};
const useChannelsStore = (sel: (s: typeof state) => unknown) => sel(state);
return {
useChannelsStore,
useSendChannelMessage: () => ({ mutate: sendMutate, isPending: false }),
channelMembersOptions: () => inertOptions("members"),
};
});

vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-1",
}));

vi.mock("@multica/core/auth", () => ({
useAuthStore: (sel: (s: { user: { id: string } | null }) => unknown) =>
sel({ user: { id: "u-1" } }),
}));

vi.mock("@multica/core/workspace/queries", () => ({
agentTagListOptions: () => inertOptions("agent-tags"),
agentListOptions: () => inertOptions("agents"),
memberListOptions: () => inertOptions("members"),
}));

import { ChannelComposer, type ChannelComposerHandle } from "./channel-composer";

const channel = {
id: "chan-1",
name: "general",
kind: "channel",
} as unknown as Channel;

function renderComposer(props?: {
disabled?: boolean;
ref?: React.Ref<ChannelComposerHandle>;
}) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={qc}>
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChannelComposer
channel={channel}
disabled={props?.disabled}
ref={props?.ref}
/>
</I18nProvider>
</QueryClientProvider>,
);
}

describe("ChannelComposer drag-and-drop", () => {
beforeEach(() => {
dropHandlers.onDrop = null;
uploadFile.mockReset();
uploadFile.mockResolvedValue({ id: "att-1" });
sendMutate.mockReset();
});

it("routes dropped files through api.uploadFile", async () => {
renderComposer();
expect(dropHandlers.onDrop).not.toBeNull();

const file = new File(["x"], "shot.png", { type: "image/png" });
dropHandlers.onDrop?.([file]);

await waitFor(() => expect(uploadFile).toHaveBeenCalledWith(file));
});

it("uploads via the imperative attachFiles handle (Concierge drawer path)", async () => {
const ref = createRef<ChannelComposerHandle>();
renderComposer({ ref });
expect(ref.current).not.toBeNull();

const file = new File(["x"], "drawer-drop.png", { type: "image/png" });
ref.current?.attachFiles([file]);

await waitFor(() => expect(uploadFile).toHaveBeenCalledWith(file));
});

it("uploads every file in a multi-file drop", async () => {
renderComposer();
const a = new File(["a"], "a.png", { type: "image/png" });
const b = new File(["b"], "b.png", { type: "image/png" });
dropHandlers.onDrop?.([a, b]);

await waitFor(() => expect(uploadFile).toHaveBeenCalledTimes(2));
expect(uploadFile).toHaveBeenCalledWith(a);
expect(uploadFile).toHaveBeenCalledWith(b);
});
});
54 changes: 48 additions & 6 deletions packages/views/channels/components/channel-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
"use client";

import { useMemo, useRef, useState } from "react";
import {
forwardRef,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { ContentEditor, type ContentEditorRef } from "../../editor";
import {
ContentEditor,
type ContentEditorRef,
useFileDropZone,
FileDropOverlay,
} from "../../editor";
import { Button } from "@multica/ui/components/ui/button";
import { useChannelsStore, useSendChannelMessage, channelMembersOptions } from "@multica/core/channels";
import { api } from "@multica/core/api";
Expand All @@ -26,6 +37,15 @@ interface ChannelComposerProps {
disabled?: boolean;
}

export interface ChannelComposerHandle {
/** Upload files through the composer's attachment pipeline (the same
* path as the paperclip button and the composer's own drop zone).
* Exposed so an ancestor that provides a wider drop target — e.g. the
* Ship Concierge drawer — can forward files dropped over the whole
* panel, not just the input box. */
attachFiles: (files: File[]) => void;
}

/**
* Build the composer placeholder for a channel. For regular channels
* the format is `Message #<display>`; for DMs the deterministic hash
Expand Down Expand Up @@ -79,7 +99,10 @@ function useComposerPlaceholder(channel: Channel): string {
* Submit is wired to Enter (Shift+Enter for newline) via the editor's
* submitOnEnter prop.
*/
export function ChannelComposer({ channel, disabled }: ChannelComposerProps) {
export const ChannelComposer = forwardRef<
ChannelComposerHandle,
ChannelComposerProps
>(function ChannelComposer({ channel, disabled }, ref) {
const editorRef = useRef<ContentEditorRef>(null);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const inputDraft = useChannelsStore((s) => s.inputDrafts[channel.id] ?? "");
Expand All @@ -99,7 +122,7 @@ export function ChannelComposer({ channel, disabled }: ChannelComposerProps) {
const hasUploading = pending.some((p) => p.status === "uploading");
const hasError = pending.some((p) => p.status === "error");

const handleAttach = async (files: FileList | null) => {
const handleAttach = async (files: FileList | File[] | null) => {
if (!files || files.length === 0) return;
// Each file gets a stable client key + an "uploading" placeholder so
// the chip row appears immediately. Uploads run in parallel.
Expand Down Expand Up @@ -135,6 +158,21 @@ export function ChannelComposer({ channel, disabled }: ChannelComposerProps) {
const removePending = (key: string) =>
setPending((prev) => prev.filter((x) => x.key !== key));

// Drag-and-drop: dropping files anywhere over the composer routes them
// through the same upload path as the paperclip button. Disabled while
// the composer is (e.g. archived channel) so drops can't sneak past the
// pointer-events lock on the editor.
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => void handleAttach(files),
enabled: !disabled,
});

// Let an ancestor (the Concierge drawer) forward files dropped outside
// the composer itself into the same upload pipeline.
useImperativeHandle(ref, () => ({
attachFiles: (files) => void handleAttach(files),
}));

const handleSend = () => {
const content = editorRef.current?.getMarkdown()?.replace(/(\n\s*)+$/, "").trim();
const readyAttachmentIDs = pending
Expand Down Expand Up @@ -170,7 +208,11 @@ export function ChannelComposer({ channel, disabled }: ChannelComposerProps) {
(isEmpty && pending.filter((p) => p.status === "ready").length === 0);

return (
<div className="border-t border-border bg-background px-4 py-3 md:pr-14">
<div
className="relative border-t border-border bg-background px-4 py-3 md:pr-14"
{...(disabled ? {} : dropZoneProps)}
>
{isDragOver && <FileDropOverlay />}
<PendingAttachmentsRow pending={pending} onRemove={removePending} />
<div className="flex items-end gap-2">
<input
Expand Down Expand Up @@ -225,4 +267,4 @@ export function ChannelComposer({ channel, disabled }: ChannelComposerProps) {
</div>
</div>
);
}
});
27 changes: 23 additions & 4 deletions packages/views/ship/components/ship-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Bot, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { usePanelRef } from "react-resizable-panels";
Expand All @@ -14,7 +14,11 @@ import {
ResizableHandle,
} from "@multica/ui/components/ui/resizable";
import { ChannelMessageList } from "../../channels/components/channel-message-list";
import { ChannelComposer } from "../../channels/components/channel-composer";
import {
ChannelComposer,
type ChannelComposerHandle,
} from "../../channels/components/channel-composer";
import { useFileDropZone, FileDropOverlay } from "../../editor";
import { useT } from "../../i18n";
import { ConciergeEmptyState } from "./ship-concierge-panel";

Expand Down Expand Up @@ -52,6 +56,17 @@ export function ShipShell({ children }: { children: React.ReactNode }) {

const panelRef = usePanelRef();

// Drag-and-drop over the whole drawer (conversation + composer), not just
// the input box — in a tall narrow panel you naturally drop onto the
// transcript. Forwarded into the composer's upload pipeline via ref. The
// composer keeps its own inner drop zone; the shared hook's
// defaultPrevented guard stops a drop on the input from double-firing.
const composerRef = useRef<ChannelComposerHandle>(null);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => composerRef.current?.attachFiles(files),
enabled: !!concierge && open,
});

// Store → panel sync. The store is the source of truth (it drives the
// header toggle and the "Ask Pilot" chip); reflect its changes onto the
// imperative panel handle. Guarded by isCollapsed() so we don't fight
Expand Down Expand Up @@ -111,9 +126,13 @@ export function ShipShell({ children }: { children: React.ReactNode }) {
// mounted while the panel was collapsed (0-width) it'd land at the
// top and never re-correct. Gating the mount on `open` guarantees a
// real viewport, so openAtBottom lands on the latest reply.
<div className="flex min-h-0 flex-1 flex-col">
<div
className="relative flex min-h-0 flex-1 flex-col"
{...dropZoneProps}
>
{isDragOver && <FileDropOverlay />}
<ChannelMessageList channelId={concierge.id} enabled openAtBottom />
<ChannelComposer channel={concierge} />
<ChannelComposer channel={concierge} ref={composerRef} />
</div>
) : null}
</div>
Expand Down
Loading