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
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@
"titleBar.inactiveForeground": "#e7e7e799"
},
"peacock.color": "#38b"
}
}
125 changes: 111 additions & 14 deletions vibes.diy/pkg/app/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import React, {
} from "react";
import type { ChatState } from "../types/chat.js";
import ModelPicker, { type ModelOption } from "./ModelPicker.js";
import ImagePreview from "./ImagePreview.js";
import { preloadLlmsText } from "../prompts.js";

interface ChatInputProps {
Expand Down Expand Up @@ -45,6 +46,8 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(

// State for responsive behavior
const [isCompact, setIsCompact] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);

// Expose the click function to parent components
useImperativeHandle(
Expand Down Expand Up @@ -102,9 +105,64 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
};
}, []);

const handleFilesSelected = useCallback(
async (files: FileList | null) => {
if (!files || !chatState.attachImages) return;
await chatState.attachImages(files);
},
[chatState.attachImages],
);

const onDropFiles = useCallback(
async (e: React.DragEvent<HTMLElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = e.dataTransfer?.files;
if (files && chatState.attachImages) {
await chatState.attachImages(files);
}
},
[chatState.attachImages],
);

return (
<div ref={containerRef} className="px-4 py-2">
<div className="space-y-1">
<div
ref={containerRef}
className="px-4 py-2"
onDragEnter={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragOver={(e) => {
// Prevent default to avoid the browser opening the file
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
// Prevent navigation and route valid file drops to the shared handler
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (e.dataTransfer?.files?.length) {
void onDropFiles(e);
}
}}
>
<div className="space-y-2">
{/* Attached images preview */}
{Array.isArray(chatState.attachedImages) &&
chatState.attachedImages.length > 0 && (
<ImagePreview
images={chatState.attachedImages}
onRemove={(id) => chatState.removeAttachedImage?.(id)}
/>
)}

<textarea
ref={chatState.inputRef}
value={chatState.input}
Expand All @@ -113,6 +171,19 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
chatState.setInput(e.target.value);
}
}}
onDragEnter={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={onDropFiles}
Comment on lines +174 to +186
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drag-and-drop is only handled on the textarea. Dropping files just outside the textarea can trigger the browser’s default navigation (opening the image), which risks losing the current chat state. It’s safer to prevent default dragover/drop on the container (or window) to avoid accidental navigations outside the intended drop zone.

Suggestion

Add container-level (or window-level) dragover/drop preventDefault handlers so drops outside the textarea don’t navigate away. For example:

// In the outer div
<div
  ref={containerRef}
  className="px-4 py-2"
  onDragOver={(e) => e.preventDefault()}
  onDrop={(e) => {
    // If not dropping files, just prevent navigation
    if (!e.dataTransfer?.files?.length) e.preventDefault();
  }}
></div>

Alternatively, add a useEffect to register window-level dragover/drop listeners that preventDefault while the component is mounted.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CharlieHelps yes please container-level

Copy link
Contributor

@charliecreates charliecreates bot Aug 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expand this to see my work.
  • Inspected ChatInput.tsx to confirm drag-and-drop and onDropFiles were previously scoped only to the textarea.
  • Revised onDropFiles type from React.DragEvent<HTMLTextAreaElement> to React.DragEvent<HTMLElement>, supporting shared use by both container and textarea event handlers.
  • Added onDragEnter, onDragOver, onDragLeave, and onDrop handlers on the container <div>, activating preventDefault and using setIsDragging for visual state across the entire chat input area.
  • Ensured container-level onDrop calls preventDefault, routes valid file drops to onDropFiles, and reliably resets setIsDragging, with no window/global listeners introduced.
  • Validated that new handlers align with PR review acceptance and preserve existing textarea logic unless centralizing is more consistent.
  • Checked for type completeness and compat in shared handler logic after updates.
  • Ran pnpm check—all tests, type checks, and formatting passed without errors related to drag-and-drop or ChatInput.tsx.
  • Committed changes as a303fcc with detailed message summarizing fixes for container-level drop prevention and forwarding.
  • Pushed the commit to jchris/chat-images branch on origin without errors.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed container-level drag-and-drop handling to prevent navigation and route file drops to the existing flow.

  • Commit: a303fcc
  • File: vibes.diy/pkg/app/components/ChatInput.tsx
Changes
  • Added container-level handlers on the outer ChatInput wrapper:
    • onDragEnter/onDragOver/onDragLeave: preventDefault and keep isDragging in sync for the entire container.
    • onDrop: preventDefault + stopPropagation to avoid browser navigation; forwards file drops to the shared onDropFiles handler; resets isDragging.
  • Updated onDropFiles signature to accept React.DragEvent so it can be called from both the textarea and the container without type errors.
  • Left the existing textarea-level handlers in place; container now reliably manages isDragging across the full input area. No window-level listeners added and no changes to text input/focus behavior.

Key snippet (container wrapper):

<div
  ref={containerRef}
  className="px-4 py-2"
  onDragEnter={(e) => { e.preventDefault(); setIsDragging(true); }}
  onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
  onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }}
  onDrop={(e) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
    if (e.dataTransfer?.files?.length) {
      void onDropFiles(e); // shared handler
    }
  }}
>

And the shared drop handler type adjustment:

const onDropFiles = useCallback(async (e: React.DragEvent<HTMLElement>) => {
  e.preventDefault();
  e.stopPropagation();
  setIsDragging(false);
  const files = e.dataTransfer?.files;
  if (files && chatState.attachImages) {
    await chatState.attachImages(files);
  }
}, [chatState.attachImages]);
Verification
# Format + Build + Tests
$ pnpm -s check

# Results
- Build: success
- Vitest (multi-project):
  • Test files: 102 passed | 17 skipped
  • Tests:      571 passed | 85 skipped

Notes:

  • No global/window listeners were introduced, per the review request.
  • Drops anywhere inside the ChatInput container no longer trigger browser navigation and correctly invoke the existing image attachment flow.

Re-requested review and assigned the PR to @jchris.

onFocus={() => {
// Fire and forget: warm the LLMs text cache using raw imports
void preloadLlmsText();
Expand All @@ -123,7 +194,7 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
handleSendMessage();
}
}}
className="border-light-decorative-00 dark:border-dark-decorative-00 text-light-primary dark:text-dark-primary bg-light-background-01 dark:bg-dark-background-01 focus:ring-accent-01-light dark:focus:ring-accent-01-dark max-h-[200px] min-h-[90px] w-full resize-y rounded-lg border p-2.5 text-sm focus:border-transparent focus:ring-2 focus:outline-none"
className={`border-light-decorative-00 dark:border-dark-decorative-00 text-light-primary dark:text-dark-primary bg-light-background-01 dark:bg-dark-background-01 focus:ring-accent-01-light dark:focus:ring-accent-01-dark max-h-[200px] min-h-[90px] w-full resize-y rounded-lg border p-2.5 text-sm focus:border-transparent focus:ring-2 focus:outline-none ${isDragging ? "ring-2 ring-blue-500" : ""}`}
placeholder={
chatState.docs.length || chatState.isStreaming
? "Continue coding..."
Expand All @@ -144,18 +215,44 @@ const ChatInput = forwardRef<ChatInputRef, ChatInputProps>(
compact={isCompact}
/>
) : (
<span aria-hidden="true" />
<span className="flex items-center gap-2" aria-hidden="true" />
)}
<button
ref={submitButtonRef}
type="button"
onClick={handleSendMessage}
disabled={chatState.isStreaming}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
aria-label={chatState.isStreaming ? "Generating" : "Send message"}
>
{chatState.isStreaming ? "•••" : "Code"}
</button>
<div className="flex items-center gap-2">
{/* Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void handleFilesSelected(e.target.files);
// Allow selecting the same file(s) consecutively
e.currentTarget.value = "";
}}
/>
<button
type="button"
aria-label="Attach images"
className="border-light-decorative-00 text-light-primary hover:bg-light-decorative-00/40 dark:border-dark-decorative-00 dark:text-dark-primary dark:hover:bg-dark-decorative-00/40 rounded-md border px-2 py-1 text-sm transition-colors"
onClick={() => fileInputRef.current?.click()}
disabled={chatState.isStreaming}
>
+
</button>
<button
ref={submitButtonRef}
type="button"
onClick={handleSendMessage}
disabled={chatState.isStreaming}
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-600"
aria-label={
chatState.isStreaming ? "Generating" : "Send message"
}
>
{chatState.isStreaming ? "•••" : "Code"}
</button>
</div>
</div>
</div>
</div>
Expand Down
45 changes: 45 additions & 0 deletions vibes.diy/pkg/app/components/ImagePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React from "react";

interface ImagePreviewItem {
id: string;
previewUrl: string;
mimeType: string;
}

export function ImagePreview({
images,
onRemove,
}: {
images: ImagePreviewItem[];
onRemove: (id: string) => void | Promise<void>;
}) {
if (!images || images.length === 0) return null;

return (
<div className="flex flex-wrap gap-2">
{images.map((img) => (
<div
key={img.id}
className="border-light-decorative-00 dark:border-dark-decorative-00 relative h-16 w-16 overflow-hidden rounded border"
>
<img
src={img.previewUrl}
alt="attachment"
className="h-full w-full object-cover"
draggable={false}
/>
<button
type="button"
onClick={() => onRemove(img.id)}
aria-label="Remove image"
className="absolute top-0 right-0 m-0.5 rounded bg-black/60 px-1 text-[10px] leading-4 font-semibold text-white hover:bg-black/80"
>
×
</button>
</div>
))}
</div>
);
}

export default ImagePreview;
86 changes: 85 additions & 1 deletion vibes.diy/pkg/app/components/Message.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import React, { memo } from "react";
import React, { memo, useEffect, useState } from "react";
import ReactMarkdown from "react-markdown";
import StructuredMessage from "./StructuredMessage.js";
import type {
ChatMessageDocument,
AiChatMessageDocument,
SystemChatMessageDocument,
UserChatMessageDocument,
ViewType,
} from "../types/chat.js";
import { parseContent } from "../utils/segmentParser.js";
import { useFireproof, type DocFileMeta } from "use-fireproof";
import { getSessionDatabaseName } from "../utils/databaseManager.js";
import { ImgFile } from "./SessionSidebar/ImgFile.js";

interface MessageProps {
message: ChatMessageDocument;
Expand Down Expand Up @@ -106,10 +110,90 @@
},
);

const UserImages = ({
sessionId,
imageIds,
}: {
sessionId: string;
imageIds: string[];
}) => {
const dbName = getSessionDatabaseName(sessionId);
const { database } = useFireproof(dbName);
const [files, setFiles] = useState<DocFileMeta[]>([]);

useEffect(() => {
let cancelled = false;
async function load() {
const result: DocFileMeta[] = [];
for (const id of imageIds) {
try {
const doc = await database.get(id);
const fpFiles = doc?._files || {};
const key = "image" in fpFiles ? "image" : Object.keys(fpFiles)[0];
const ref = key ? fpFiles[key] : undefined;
if (ref) {
const anyRef: any = ref as any;

Check failure on line 135 in vibes.diy/pkg/app/components/Message.tsx

View workflow job for this annotation

GitHub Actions / compile_test

Unexpected any. Specify a different type

Check failure on line 135 in vibes.diy/pkg/app/components/Message.tsx

View workflow job for this annotation

GitHub Actions / compile_test

Unexpected any. Specify a different type
if (typeof anyRef.file === "function") {
// Fireproof DocFileMeta
result.push(anyRef as DocFileMeta);
} else if (ref instanceof File) {
// Raw File stored in _files (dev/testing scenarios)
const wrapped: DocFileMeta = {
file: async () => ref,
type: ref.type || "application/octet-stream",
size: ref.size,
cid: undefined as any,
};
result.push(wrapped);
}
}
} catch (err) {
console.warn(`Failed to load image ${id}:`, err);
}
}
if (!cancelled) {
setFiles(result);
}
}
void load();
return () => {
cancelled = true;
};
}, [database, imageIds]);

if (files.length === 0) return null;

return (
<div className="flex flex-wrap gap-2 mb-2">
{files.map((file, index) => (
<div
key={`${sessionId}-${imageIds[index]}`}
className="border-light-decorative-00 dark:border-dark-decorative-00 overflow-hidden rounded border"
>
<ImgFile
file={file}
alt="User attachment"
className="max-w-xs"
maxHeight="12rem"
/>
</div>
))}
</div>
);
};
Comment on lines +124 to +183
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rendering attached images currently uses files.map((file, index) => … key={${sessionId}-${imageIds[index]})), which assumes a 1:1 alignment between files and imageIds by index. Because the loader skips missing/invalid images, index-based keys can become misaligned, causing duplicate or incorrect keys and coupling the wrong image to the wrong ID. Additionally, the loader performs network/DB fetches sequentially and relies on any for file refs, both of which are avoidable.

Recommend: (1) load images concurrently, (2) normalize Fireproof refs without any, and (3) keep the loaded results paired with their source IDs so the render key and file stay in sync.

Suggestion

Consider refactoring UserImages like this to ensure stable ID↔file pairing, avoid any, and parallelize loads:

  • Store pairs of { id, meta } in state instead of a bare DocFileMeta[].
  • Use a small helper to normalize Fireproof file refs without any.
  • Use Promise.all to fetch/normalize concurrently and filter out failures, preserving order by ID.

Example:

const toDocFileMeta = async (ref: unknown): Promise<DocFileMeta | null> => {
  if (!ref) return null;
  if (typeof (ref as any).file === "function") {
    return ref as DocFileMeta;
  }
  if (ref instanceof File) {
    const f = ref;
    return {
      file: async () => f,
      type: f.type || "application/octet-stream",
      size: f.size,
      cid: undefined as any,
    } satisfies DocFileMeta;
  }
  return null;
};

const [files, setFiles] = useState<Array<{ id: string; meta: DocFileMeta }>>([]);

useEffect(() => {
  let cancelled = false;
  (async () => {
    const pairs = await Promise.all(
      imageIds.map(async (id) => {
        try {
          const doc = await database.get(id);
          const fpFiles = doc?._files || {};
          const key = "image" in fpFiles ? "image" : Object.keys(fpFiles)[0];
          const meta = await toDocFileMeta(key ? fpFiles[key] : undefined);
          return meta ? { id, meta } : null;
        } catch (err) {
          console.warn(`Failed to load image ${id}:`, err);
          return null;
        }
      }),
    );
    if (!cancelled) setFiles(pairs.filter(Boolean) as Array<{ id: string; meta: DocFileMeta }>);
  })();
  return () => {
    cancelled = true;
  };
}, [database, imageIds]);

// Render
<div className="flex flex-wrap gap-2 mb-2">
  {files.map(({ id, meta }) => (
    <div key={`${sessionId}-${id}`} className="border-light-decorative-00 dark:border-dark-decorative-00 overflow-hidden rounded border">
      <ImgFile file={meta} alt="User attachment" className="max-w-xs" maxHeight="12rem" />
    </div>
  ))}
</div>

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this refactor.


const UserMessage = memo(({ message }: { message: ChatMessageDocument }) => {
const userMessage = message as UserChatMessageDocument;
const hasImages = userMessage.images && userMessage.images.length > 0;
return (
<div className="mb-4 flex flex-row justify-end px-4">
<div className="bg-light-background-02 dark:bg-dark-decorative-00 text-light-primary dark:text-dark-primary max-w-[85%] rounded-xl px-5 py-3 shadow-md">
{hasImages && (
<UserImages
sessionId={userMessage.session_id}
imageIds={userMessage.images!}
/>
)}
<div className="prose prose-sm dark:prose-invert prose-ul:pl-5 prose-ul:list-disc prose-ol:pl-5 prose-ol:list-decimal prose-li:my-0 max-w-none">
<ReactMarkdown>{message.text}</ReactMarkdown>
</div>
Expand Down
Loading
Loading