Skip to content
Open
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
90 changes: 90 additions & 0 deletions apps/desktop/src/chat/components/content.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { fireEvent, render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

import { ChatContent } from "./content";

vi.mock("./body", () => ({
ChatBody: () => <div data-testid="chat-body" />,
}));

vi.mock("./context-bar", () => ({
ContextBar: () => <div data-testid="context-bar" />,
}));

vi.mock("./input", () => ({
ChatMessageInput: () => <div data-testid="chat-input" />,
}));

class FakeDataTransfer {
dropEffect = "none";
private readonly values = new Map<string, string>();

get types() {
return Array.from(this.values.keys());
}

getData(type: string) {
return this.values.get(type) ?? "";
}

setData(type: string, value: string) {
this.values.set(type, value);
}
}

const renderContent = (onAddContextEntity = vi.fn()) => {
const { container } = render(
<ChatContent
sessionId="active-session"
messages={[]}
sendMessage={vi.fn()}
regenerate={vi.fn()}
stop={vi.fn()}
status="ready"
model={{} as never}
handleSendMessage={vi.fn()}
contextEntities={[]}
pendingRefs={[]}
onAddContextEntity={onAddContextEntity}
isSystemPromptReady
/>,
);

return container.querySelector("[data-chat-content]");
};

describe("ChatContent", () => {
it("adds dropped session refs to chat context", () => {
const onAddContextEntity = vi.fn();
const container = renderContent(onAddContextEntity);
const dataTransfer = new FakeDataTransfer();

dataTransfer.setData(
"application/x-anarlog-session-context",
JSON.stringify({ sessionId: "session-1" }),
);

fireEvent.dragOver(container!, { dataTransfer });
fireEvent.drop(container!, { dataTransfer });

expect(dataTransfer.dropEffect).toBe("copy");
expect(onAddContextEntity).toHaveBeenCalledWith({
kind: "session",
key: "session:manual:session-1",
source: "manual",
sessionId: "session-1",
});
});

it("ignores non-session drops", () => {
const onAddContextEntity = vi.fn();
const container = renderContent(onAddContextEntity);
const dataTransfer = new FakeDataTransfer();

dataTransfer.setData("text/plain", "Meeting notes");

fireEvent.drop(container!, { dataTransfer });

expect(onAddContextEntity).not.toHaveBeenCalled();
});
});
34 changes: 33 additions & 1 deletion apps/desktop/src/chat/components/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ import { ChatMessageInput } from "./input";

import type { useLanguageModel } from "~/ai/hooks";
import { dedupeByKey, type ContextRef } from "~/chat/context/entities";
import {
hasSessionContextDragData,
readSessionContextDragData,
} from "~/chat/context/session-drag";
import type { DisplayEntity } from "~/chat/context/use-chat-context-pipeline";
import type { HyprUIMessage } from "~/chat/types";

Expand Down Expand Up @@ -53,9 +57,37 @@ export function ChatContent({
const disabled = !isSystemPromptReady;
const mergeContextRefs = (contextRefs?: ContextRef[]) =>
contextRefs ? dedupeByKey([pendingRefs, contextRefs]) : pendingRefs;
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
if (!onAddContextEntity || !hasSessionContextDragData(event.dataTransfer)) {
return;
}

event.preventDefault();
event.dataTransfer.dropEffect = "copy";
};
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
if (!onAddContextEntity) {
return;
}

const contextRef = readSessionContextDragData(event.dataTransfer);

if (!contextRef) {
return;
}

event.preventDefault();
event.stopPropagation();
onAddContextEntity(contextRef);
};

return (
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden"
data-chat-content
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{children ?? (
<ChatBody
messages={messages}
Expand Down
58 changes: 58 additions & 0 deletions apps/desktop/src/chat/context/session-drag.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";

import {
hasSessionContextDragData,
readSessionContextDragData,
writeSessionContextDragData,
} from "./session-drag";

class FakeDataTransfer {
effectAllowed = "all";
private readonly values = new Map<string, string>();

get types() {
return Array.from(this.values.keys());
}

getData(type: string) {
return this.values.get(type) ?? "";
}

setData(type: string, value: string) {
this.values.set(type, value);
}
}

describe("session drag context", () => {
it("writes and reads manual session context refs", () => {
const dataTransfer = new FakeDataTransfer() as unknown as DataTransfer;

writeSessionContextDragData(dataTransfer, "session-1", "Meeting notes");

expect(dataTransfer.effectAllowed).toBe("copy");
expect(hasSessionContextDragData(dataTransfer)).toBe(true);
expect(readSessionContextDragData(dataTransfer)).toEqual({
kind: "session",
key: "session:manual:session-1",
source: "manual",
sessionId: "session-1",
});
});

it("ignores malformed session drag payloads", () => {
const dataTransfer = new FakeDataTransfer() as unknown as DataTransfer;

dataTransfer.setData("application/x-anarlog-session-context", "{");

expect(readSessionContextDragData(dataTransfer)).toBeNull();
});

it("ignores non-session drops", () => {
const dataTransfer = new FakeDataTransfer() as unknown as DataTransfer;

dataTransfer.setData("text/plain", "Meeting notes");

expect(hasSessionContextDragData(dataTransfer)).toBe(false);
expect(readSessionContextDragData(dataTransfer)).toBeNull();
});
});
62 changes: 62 additions & 0 deletions apps/desktop/src/chat/context/session-drag.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { ContextRef } from "./entities";

const SESSION_CONTEXT_DRAG_TYPE = "application/x-anarlog-session-context";

type SessionDragPayload = {
sessionId: string;
};

const createSessionContextRef = (sessionId: string): ContextRef => ({
kind: "session",
key: `session:manual:${sessionId}`,
source: "manual",
sessionId,
});

export const hasSessionContextDragData = (
dataTransfer: Pick<DataTransfer, "types"> | null | undefined,
) => {
if (!dataTransfer) {
return false;
}

return Array.from(dataTransfer.types).includes(SESSION_CONTEXT_DRAG_TYPE);
};

export const writeSessionContextDragData = (
dataTransfer: DataTransfer,
sessionId: string,
fallbackText: string,
) => {
dataTransfer.effectAllowed = "copy";
dataTransfer.setData(
SESSION_CONTEXT_DRAG_TYPE,
JSON.stringify({ sessionId }),
);
dataTransfer.setData("text/plain", fallbackText);
};

export const readSessionContextDragData = (
dataTransfer: Pick<DataTransfer, "getData" | "types"> | null | undefined,
): ContextRef | null => {
if (!dataTransfer || !hasSessionContextDragData(dataTransfer)) {
return null;
}

try {
const payload = JSON.parse(
dataTransfer.getData(SESSION_CONTEXT_DRAG_TYPE),
) as SessionDragPayload;

if (
typeof payload.sessionId !== "string" ||
payload.sessionId.trim().length === 0
) {
return null;
}

return createSessionContextRef(payload.sessionId);
} catch {
return null;
}
};
32 changes: 32 additions & 0 deletions apps/desktop/src/main/top-meeting-timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import {
memo,
type CSSProperties,
type DragEvent,
type MouseEvent,
type MouseEventHandler,
type PointerEvent,
Expand Down Expand Up @@ -47,6 +48,7 @@ import {
type TimelineTranscriptsTable,
} from "./meeting-timeline-recordings";

import { writeSessionContextDragData } from "~/chat/context/session-drag";
import { SessionPreviewCard } from "~/session/components/session-preview-card";
import { useDeleteSession } from "~/session/hooks/useDeleteSession";
import { useIsSessionEnhancing } from "~/session/hooks/useEnhancedNotes";
Expand Down Expand Up @@ -560,6 +562,17 @@ const SessionTimelineBar = memo(
openNew({ id: item.id, type: "sessions" });
}, [item.id, openNew]);

const handleDragStart = useCallback(
(event: DragEvent<HTMLButtonElement>) => {
writeSessionContextDragData(
event.dataTransfer,
item.id,
title || item.title || "Untitled",
);
},
[item.id, item.title, title],
);

const handleDelete = useCallback(() => {
deleteSession(item.id, sessionEvent?.tracking_id);
}, [deleteSession, item.id, sessionEvent]);
Expand Down Expand Up @@ -607,8 +620,10 @@ const SessionTimelineBar = memo(
)}
showSpinner={showSpinner}
onClick={openSession}
onDragStart={handleDragStart}
onStop={stop}
contextMenu={contextMenu}
draggable
/>
</SessionPreviewCard>
</TimelineCarouselCard>
Expand Down Expand Up @@ -831,8 +846,10 @@ function TimelineCardButton({
amplitude,
showSpinner,
onClick,
onDragStart,
onStop,
contextMenu,
draggable,
}: {
item: MeetingTimelineEntry;
title: string;
Expand All @@ -841,8 +858,10 @@ function TimelineCardButton({
amplitude?: number;
showSpinner?: boolean;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
onDragStart?: (event: DragEvent<HTMLButtonElement>) => void;
onStop?: () => void;
contextMenu?: MenuItemDef[];
draggable?: boolean;
}) {
const showContextMenu = useNativeContextMenu(contextMenu ?? []);
const handleContextMenu = useCallback<MouseEventHandler<HTMLButtonElement>>(
Expand All @@ -865,6 +884,16 @@ function TimelineCardButton({
},
[onStop],
);
const handlePointerDown = useCallback(
(event: PointerEvent<HTMLButtonElement>) => {
if (!draggable) {
return;
}

event.stopPropagation();
},
[draggable],
);
const startLabel = formatTimelineStartLabel(item.start, timezone);
const showLiveStop = item.type === "session" && isLive && onStop;
const showSuffixSpinner =
Expand All @@ -876,7 +905,10 @@ function TimelineCardButton({
<button
type="button"
onClick={onClick}
onDragStart={onDragStart}
onContextMenu={handleContextMenu}
onPointerDown={handlePointerDown}
draggable={draggable}
className={cn([
"flex h-10 w-full flex-col justify-center rounded-md border py-0 pl-2 text-left shadow-xs",
showSuffix ? "pr-8" : "pr-2",
Expand Down
Loading
Loading