diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 6a2fe076c8932..5dea294e4077f 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -81,7 +81,9 @@ describe("CommandPalette open intents", () => { document.dispatchEvent(pointer("pointerup", 50, window.innerHeight - 30)); }); expect(onDrop).toHaveBeenCalledOnce(); - expect((onDrop.mock.calls[0]![0] as CustomEvent).detail).toEqual([{ environmentId, threadId }]); + expect((onDrop.mock.calls[0]![0] as CustomEvent).detail).toEqual([ + { environmentId, threadId, title: "Drag this thread" }, + ]); expect(screen.router?.state.location.pathname).toBe(pathname); }); diff --git a/apps/web/src/components/CommandPalette.drag.ts b/apps/web/src/components/CommandPalette.drag.ts index 3dec40e02b0f1..090bab6efbd02 100644 --- a/apps/web/src/components/CommandPalette.drag.ts +++ b/apps/web/src/components/CommandPalette.drag.ts @@ -22,7 +22,7 @@ export function startPaletteThreadDrag( distance: 6, onAttach: () => {}, onFinish: endThreadContextDrag, - onDrop: (point) => dropThreadContext(point, [thread]), + onDrop: (point) => dropThreadContext(point, [{ ...thread, title }]), }, onStart: () => { flushSync(() => dispatchCommandPalette({ _tag: "SetOpen", open: false })); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2110bf65cf06d..1165bb095ec31 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -189,6 +189,7 @@ import { } from "~/lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; +import { droppedThreadContextRecord } from "./threadContextDrop.logic"; import { readThreadShell } from "~/state/entities"; import { useLinkedEnvironments } from "~/state/peerLinks"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; @@ -2877,8 +2878,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return []; } - const shell = readThreadShell(ref); - return shell ? [threadContextRecord(ref, shell.title)] : []; + const record = droppedThreadContextRecord(ref, readThreadShell(ref)); + return record ? [record] : []; }); if (records.length === 0) return; if (questionAttachmentTarget !== null) { diff --git a/apps/web/src/components/chat/threadContextDrag.ts b/apps/web/src/components/chat/threadContextDrag.ts index 66e89060c231b..8c16191657e4c 100644 --- a/apps/web/src/components/chat/threadContextDrag.ts +++ b/apps/web/src/components/chat/threadContextDrag.ts @@ -1,13 +1,15 @@ -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { type RefObject, useEffect, useEffectEvent, useSyncExternalStore } from "react"; +import type { ThreadContextDropRef } from "./threadContextDrop.logic"; + export const THREAD_CONTEXT_DROP_EVENT = "t3-thread-context-drop"; export function useThreadContextDrop( targetRef: RefObject, environmentId: EnvironmentId, targetKey: string, - onDrop: (refs: ReadonlyArray, isActive: () => boolean) => void, + onDrop: (refs: ReadonlyArray, isActive: () => boolean) => void, ) { const handleDrop = useEffectEvent(onDrop); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes the target element to thread-context drop events and marks old drops inactive @@ -16,7 +18,7 @@ export function useThreadContextDrop( if (!target) return; let active = true; const listener = (event: Event) => - handleDrop((event as CustomEvent>).detail, () => active); + handleDrop((event as CustomEvent>).detail, () => active); target.addEventListener(THREAD_CONTEXT_DROP_EVENT, listener); return () => { active = false; @@ -91,7 +93,7 @@ export function endThreadContextDrag() { export function dropThreadContext( point: { x: number; y: number }, - threads: ReadonlyArray, + threads: ReadonlyArray, ): boolean { const target = findDropTarget(point); if (!target || threads.length === 0) return false; diff --git a/apps/web/src/components/chat/threadContextDrop.logic.test.ts b/apps/web/src/components/chat/threadContextDrop.logic.test.ts new file mode 100644 index 0000000000000..8d37a0e86de67 --- /dev/null +++ b/apps/web/src/components/chat/threadContextDrop.logic.test.ts @@ -0,0 +1,29 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { describe, expect, it } from "vite-plus/test"; +import { droppedThreadContextRecord } from "./threadContextDrop.logic"; + +describe("droppedThreadContextRecord", () => { + it.each([null, { title: "Cached title" }])( + "uses the search result title even when the sidebar shell is %s", + (shell) => { + const ref = { + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("search-result"), + title: "Search result", + }; + expect(droppedThreadContextRecord(ref, shell)).toEqual(threadContextRecord(ref, ref.title)); + }, + ); + + it("uses the sidebar title for a drag without search metadata", () => { + const ref = { + environmentId: EnvironmentId.make("local"), + threadId: ThreadId.make("sidebar-thread"), + }; + expect(droppedThreadContextRecord(ref, { title: "Sidebar thread" })).toEqual( + threadContextRecord(ref, "Sidebar thread"), + ); + expect(droppedThreadContextRecord(ref, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/threadContextDrop.logic.ts b/apps/web/src/components/chat/threadContextDrop.logic.ts new file mode 100644 index 0000000000000..ee875c2e5ccaf --- /dev/null +++ b/apps/web/src/components/chat/threadContextDrop.logic.ts @@ -0,0 +1,12 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; + +export type ThreadContextDropRef = ScopedThreadRef & { readonly title?: string }; + +export function droppedThreadContextRecord( + ref: ThreadContextDropRef, + shell: { readonly title: string } | null, +) { + const title = ref.title ?? shell?.title; + return title === undefined ? null : threadContextRecord(ref, title); +}