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 = ``;
- 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 ? (
-