diff --git a/ui/src/components/IssueChatThread.test.tsx b/ui/src/components/IssueChatThread.test.tsx index 9a85a862e86..bfab4d26320 100644 --- a/ui/src/components/IssueChatThread.test.tsx +++ b/ui/src/components/IssueChatThread.test.tsx @@ -706,7 +706,7 @@ describe("IssueChatThread", () => { }); }); - it("keeps the composer inline with bottom breathing room and a capped editor height", () => { + it("keeps the composer floating with a capped editor height", () => { const root = createRoot(container); act(() => { @@ -724,15 +724,85 @@ describe("IssueChatThread", () => { ); }); + const dock = container.querySelector('[data-testid="issue-chat-composer-dock"]') as HTMLDivElement | null; + expect(dock).not.toBeNull(); + expect(dock?.className).toContain("sticky"); + expect(dock?.className).toContain("bottom-[calc(env(safe-area-inset-bottom)+20px)]"); + expect(dock?.className).toContain("z-20"); + const composer = container.querySelector('[data-testid="issue-chat-composer"]') as HTMLDivElement | null; expect(composer).not.toBeNull(); - expect(composer?.className).not.toContain("sticky"); - expect(composer?.className).not.toContain("bottom-0"); - expect(composer?.className).toContain("pb-[calc(env(safe-area-inset-bottom)+1.5rem)]"); + expect(composer?.className).toContain("rounded-md"); + expect(composer?.className).not.toContain("rounded-lg"); + expect(composer?.className).toContain("p-[15px]"); const editor = container.querySelector('textarea[aria-label="Issue chat editor"]') as HTMLTextAreaElement | null; expect(editor?.dataset.contentClassName).toContain("max-h-[28dvh]"); expect(editor?.dataset.contentClassName).toContain("overflow-y-auto"); + expect(editor?.dataset.contentClassName).not.toContain("min-h-[72px]"); + + act(() => { + root.unmount(); + }); + }); + + it("renders the bottom spacer with zero height until the user has submitted", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const spacer = container.querySelector('[data-testid="issue-chat-bottom-spacer"]') as HTMLDivElement | null; + expect(spacer).not.toBeNull(); + expect(spacer?.style.height).toBe("0px"); + + act(() => { + root.unmount(); + }); + }); + + it("omits the bottom spacer when the composer is hidden", () => { + const root = createRoot(container); + + act(() => { + root.render( + + {}} + showComposer={false} + enableLiveTranscriptPolling={false} + /> + , + ); + }); + + const spacer = container.querySelector('[data-testid="issue-chat-bottom-spacer"]'); + expect(spacer).toBeNull(); act(() => { root.unmount(); diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx index 6c5de990bc0..db89048b7eb 100644 --- a/ui/src/components/IssueChatThread.tsx +++ b/ui/src/components/IssueChatThread.tsx @@ -20,6 +20,7 @@ import { useRef, useState, type ChangeEvent, + type DragEvent as ReactDragEvent, type ErrorInfo, type Ref, type ReactNode, @@ -505,6 +506,11 @@ function IssueChatFallbackThread({ const DRAFT_DEBOUNCE_MS = 800; const COMPOSER_FOCUS_SCROLL_PADDING_PX = 96; +const SUBMIT_SCROLL_RESERVE_VH = 0.4; + +function hasFilePayload(evt: ReactDragEvent) { + return Array.from(evt.dataTransfer?.types ?? []).includes("Files"); +} function toIsoString(value: string | Date | null | undefined): string | null { if (!value) return null; @@ -1921,12 +1927,15 @@ const IssueChatComposer = forwardRef(null); const editorRef = useRef(null); const composerContainerRef = useRef(null); const draftTimer = useRef | null>(null); + const canAcceptFiles = Boolean(onImageUpload || onAttachImage); function queueViewportRestore(snapshot: ReturnType) { if (!snapshot) return; @@ -2026,25 +2035,46 @@ const IssueChatComposer = forwardRef prev ? `${prev}\n\n${markdown}` : markdown); + } else if (onAttachImage) { + await onAttachImage(file); + } + } + async function handleAttachFile(evt: ChangeEvent) { const file = evt.target.files?.[0]; if (!file) return; setAttaching(true); try { - if (onImageUpload) { - const url = await onImageUpload(file); - const safeName = file.name.replace(/[[\]]/g, "\\$&"); - const markdown = `![${safeName}](${url})`; - setBody((prev) => prev ? `${prev}\n\n${markdown}` : markdown); - } else if (onAttachImage) { - await onAttachImage(file); - } + await attachFile(file); } finally { setAttaching(false); if (attachInputRef.current) attachInputRef.current.value = ""; } } + async function handleDroppedFiles(files: FileList | null | undefined) { + if (!files || files.length === 0) return; + setAttaching(true); + try { + for (const file of Array.from(files)) { + await attachFile(file); + } + } finally { + setAttaching(false); + } + } + + function resetDragState() { + dragDepthRef.current = 0; + setIsDragOver(false); + } + const canSubmit = !submitting && !!body.trim(); if (composerDisabledReason) { @@ -2059,7 +2089,32 @@ const IssueChatComposer = forwardRef { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + dragDepthRef.current += 1; + setIsDragOver(true); + }} + onDragOver={(evt) => { + if (!canAcceptFiles || !hasFilePayload(evt)) return; + evt.preventDefault(); + evt.dataTransfer.dropEffect = "copy"; + }} + onDragLeave={() => { + if (!canAcceptFiles) return; + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsDragOver(false); + }} + onDrop={(evt) => { + if (!canAcceptFiles) return; + resetDragState(); + if (evt.defaultPrevented) return; + evt.preventDefault(); + void handleDroppedFiles(evt.dataTransfer?.files); + }} > {composerHint ? ( @@ -2204,6 +2259,11 @@ export function IssueChatThread({ const composerViewportAnchorRef = useRef(null); const composerViewportSnapshotRef = useRef>(null); const preserveComposerViewportRef = useRef(false); + const pendingSubmitScrollRef = useRef(false); + const lastUserMessageIdRef = useRef(null); + const spacerBaselineAnchorRef = useRef(null); + const spacerInitialReserveRef = useRef(0); + const [bottomSpacerHeight, setBottomSpacerHeight] = useState(0); const displayLiveRuns = useMemo(() => { const deduped = new Map(); for (const run of liveRuns) { @@ -2317,10 +2377,58 @@ export function IssueChatThread({ const runtime = usePaperclipIssueRuntime({ messages, isRunning, - onSend: ({ body, reopen, reassignment }) => onAdd(body, reopen, reassignment), + onSend: ({ body, reopen, reassignment }) => { + pendingSubmitScrollRef.current = true; + return onAdd(body, reopen, reassignment); + }, onCancel: onCancelRun, }); + useEffect(() => { + const lastUserMessage = [...messages].reverse().find((m) => m.role === "user"); + const lastUserId = lastUserMessage?.id ?? null; + + if ( + pendingSubmitScrollRef.current + && lastUserId + && lastUserId !== lastUserMessageIdRef.current + ) { + pendingSubmitScrollRef.current = false; + const custom = lastUserMessage?.metadata.custom as { anchorId?: unknown } | undefined; + const anchorId = typeof custom?.anchorId === "string" ? custom.anchorId : null; + if (anchorId) { + const reserve = Math.round(window.innerHeight * SUBMIT_SCROLL_RESERVE_VH); + spacerBaselineAnchorRef.current = anchorId; + spacerInitialReserveRef.current = reserve; + setBottomSpacerHeight(reserve); + requestAnimationFrame(() => { + const el = document.getElementById(anchorId); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + }); + } + } + + lastUserMessageIdRef.current = lastUserId; + }, [messages]); + + useLayoutEffect(() => { + const anchorId = spacerBaselineAnchorRef.current; + if (!anchorId || spacerInitialReserveRef.current <= 0) return; + const userEl = document.getElementById(anchorId); + const bottomEl = bottomAnchorRef.current; + if (!userEl || !bottomEl) return; + const contentBelow = Math.max( + 0, + bottomEl.getBoundingClientRect().top - userEl.getBoundingClientRect().bottom, + ); + const next = Math.max(0, spacerInitialReserveRef.current - contentBelow); + setBottomSpacerHeight((prev) => (prev === next ? prev : next)); + if (next === 0) { + spacerBaselineAnchorRef.current = null; + spacerInitialReserveRef.current = 0; + } + }, [messages]); + useLayoutEffect(() => { const composerElement = composerViewportAnchorRef.current; if (preserveComposerViewportRef.current) { @@ -2460,12 +2568,23 @@ export function IssueChatThread({ }) )}
+ {showComposer ? ( +
+ ) : null}
{showComposer ? ( -
+