Files
vibe-kanban/frontend/src/components/ui/image-upload-section.tsx
Louis Knight-Webb d3317f68ff WYSIWYG editor (#1397)
* Replace follow up section with WYSIWYG (vibe-kanban 55b58b24)

frontend/src/components/tasks/TaskFollowUpSection.tsx
frontend/src/components/ui/wysiwyg.tsx

* Delete all usage of image chip component (vibe-kanban 5c90eac1)

frontend/src/components/ui/wysiwyg/image-chip-markdown.ts
frontend/src/components/ui/wysiwyg/image-chip-node.tsx

* Trigger file / tag picker from WYSIWYG (vibe-kanban 3e73cf53)

LexicalTypeaheadMenuPlugin
frontend/src/components/ui/wysiwyg.tsx
frontend/src/components/ui/file-search-textarea.tsx (old)

* Editor state should be saved as JSON (vibe-kanban 4f9eec74)

Instead of saving markdown, we should save JSON eg `editorState.toJSON();`.

This will enable us to properly serialize custom Elements in the future.

frontend/src/components/ui/wysiwyg.tsx
frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx

* In WYSIWYG, the search dialog can exceed screen (vibe-kanban 25337029)

When searching for tags/files. Sometimes the dialog is cut off the bottom of the screen.

frontend/src/components/ui/wysiwyg.tsx

* Use WYSIWYG for tasks (vibe-kanban 5485d481)

Currently used for follow ups, we should also use for task
frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
frontend/src/components/ui/wysiwyg.tsx

* Keyboard shortcuts when typing in WYSIWYG (vibe-kanban 04bd70bc)

We used to have a callback for:
- CMD+Enter
- Shift+CMD+Enter

In create task dialog:
- CMD+Enter = create and start
- Shift+CMD+Enter = create without start

In follow up:
- CMD+Enter = Follow up
- Shift+CMD+Enter = nothing

frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
frontend/src/components/ui/wysiwyg.tsx
frontend/src/components/dialogs/tasks/TaskFormDialog.tsx

Ideally we can use the relevant Lexical plugin and callbacks, cleaning up the old `@/keyboard` hooks which no longer work.

* Trigger file / tag picker from WYSIWYG (vibe-kanban 3e73cf53)

LexicalTypeaheadMenuPlugin
frontend/src/components/ui/wysiwyg.tsx
frontend/src/components/ui/file-search-textarea.tsx (old)

* Use WYSIWYG for tasks (vibe-kanban 5485d481)

Currently used for follow ups, we should also use for task
frontend/src/components/tasks/follow-up/FollowUpEditorCard.tsx
frontend/src/components/dialogs/tasks/TaskFormDialog.tsx
frontend/src/components/ui/wysiwyg.tsx

* Introduce new user-message table and struct (vibe-kanban 09116513)

{
ID,
message_json: Value,
message_md: String
}

We'll also need some endpoints to CRUD them.

crates/db
crates/server

* Stream individual scratch (vibe-kanban 321b50a1)

crates/server/src/routes/scratch.rs

It should be possible to listen for updates made to a single scratch

* Refactor useScratch (vibe-kanban 51ea2317)

To consolidate the API stuff into frontend/src/lib/api.ts

* Update scratch API (vibe-kanban 878f40c5)

Primary key should come from: ID and scratch type combination

The frontend will provide both.

Scratch IDs should not be generated on the backend.

* Remove all usage of hook from follow up (vibe-kanban 2d691095)

Use of hooks that reside in frontend/src/hooks/follow-up/* should be removed, except for frontend/src/hooks/follow-up/useFollowUpSend.ts

From: frontend/src/components/tasks/TaskFollowUpSection.tsx

* Task follow up should use scratch (vibe-kanban d37d3b18)

The current task attempt ID should be used to save the content of the follow up box as scratch.

frontend/src/components/tasks/TaskFollowUpSection.tsx

* Use just markdown serialization for scratch (vibe-kanban 42f5507f)

frontend/src/hooks/useScratch.ts
crates/server/src/routes/scratch.rs
crates/db/src/models/scratch.rs

We are currently storing JSON + MD, however we should now store just MD and import/export the markdown into lexical.

* Consolidate MarkdownRenderer and WYSIWYG (vibe-kanban f61a7d40)

Currently we have an old implementation of markdown rendering in frontend/src/components/ui/markdown-renderer.tsx

But we have recently introduced the new WYSIWYG editor frontend/src/components/ui/wysiwyg.tsx

wysiwyg takes JSON as input, not raw markdown.

Ideally we could just use a single component and have a read only mode, removing Markdown Renderer and its dependencies and custom styling.

* WYSIWYG images (vibe-kanban 8cc3c0e7)

Create a Lexical plugin for images, with markdown import/export support.

Visually, images should be displayed as a small thumbnail with the path truncated.

Export/import should support standard markdown image format.

* Get image metadata endpoint (vibe-kanban 2c0dfbff)

Task attempt endpoint to get info, given the relative URL of an image.

We will also need an image that acts as a proxy to the file.

Info to return:
- Whether file exists
- Size of image
- Format
- File name
- Path
- URL to get image (the proxy URL)

The images are stored in the `.vibe-images` folder, relative to the task attempt container.

crates/server/src/routes/task_attempts.rs

* Inject relative path not absolute to image (vibe-kanban 007d589b)

Currently when we upload an image, it adds markdown with the full relative path of the image, eg:
/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban-dev/worktrees/2702-testing-images/.vibe-images/b01e6b02-dbd0-464c-aa9f-a42a89f6d67b.png

However, we should change this to be the path relative to the worktree eg .vibe-images/b01e6b02-dbd0-464c-aa9f-a42a89f6d67b.png

* Improve image in WYSIWYG (vibe-kanban 53de9071)

frontend/src/components/ui/wysiwyg/nodes/image-node.tsx

Check if the image comes from `./vibe-images/...`, if so:
Use the API endpoints to get and display metadata.
Use the image proxy to display the thumbnail image.

Do not render non `.vibe-images` images, instead just show the path and show a question icon as a thumbnail.

* rebase fixes

* Add Lexical toolbar (vibe-kanban b8904ad9)

frontend/src/components/ui/wysiwyg.tsx

* Clicking image once should open dialog (vibe-kanban aab2e6f4)

frontend/src/components/ui/wysiwyg/nodes/image-node.tsx

* Style quotes better (vibe-kanban 54718e76)

frontend/src/components/ui/wysiwyg.tsx

* Auto detect multi-line code blocks (vibe-kanban ce33792d)

Currently when I type triple backticks it doesn't create a multi-line code block

frontend/src/components/ui/wysiwyg.tsx

* Update how image upload works on the backend (vibe-kanban 62d97322)

I am only referring to the image upload for sending a follow up message.

Currently we:
- upload an image
- when a follow up is made, send file IDs
- copy the image into container based on those file IDs

We should tweak this so that:
- upload an image
- immediately the image is copied into container
- the image file location is added to the markdown of the follow up message (on the frontend)
- when user makes follow up, the image is already in the container

crates/server/src/routes/images.rs
crates/server/src/routes/task_attempts/images.rs

* Use @lexical/code to render code (vibe-kanban 60605a2c)

frontend/src/components/ui/wysiwyg.tsx

* Save variant in scratch (vibe-kanban 06e1e255)

frontend/src/components/tasks/TaskFollowUpSection.tsx

* prepare db

* Solve follow up loading when empty (vibe-kanban 1991bf3d)

frontend/src/components/tasks/TaskFollowUpSection.tsx
Currently the loader shows when the scratch data is loading, but also when there is no scratch data - which means the user can never see the follow up inputs

* descriptive scratch error

* Triple backtick WYSIWYG not working properly (vibe-kanban 30b0114e)

When I paste in a multi-line code block, eg

```js
var x = 100;
```

It doesn't add a multi-line code block properly, instead it created two multi-line code blocks above and below the code.

frontend/src/components/ui/wysiwyg.tsx

* Safe scratch fail (vibe-kanban c3f99b37)

It's possible to get an error like:

scratch WS closed: Failed to get scratch item: invalid type: string "\\`\\`\\`js\n\nvar x = 100;\n\n\\`\\`\\` \n\n\n", expected struct DraftFollowUpData at line 1 column 49

In this situation the websocket should act in the same way when no scratch exists yet.

* Remove drafts (vibe-kanban 0af2e9aa)

crates/services/src/services/drafts.rs
crates/db/src/models/draft.rs

* Cleanup scratch (vibe-kanban 0baf9b69)

Remove:
- frontend/src/pages/TestScratch.tsx
- frontend/src/components/ScratchEditor.tsx

* Improve styling of WYSIWYG + attachment (vibe-kanban 042a18da)

frontend/src/components/ui/wysiwyg.tsx

The placeholder can overlap the attachment icon

* Introduce queued message service (vibe-kanban 442164ae)

- New service (crates/services/src/services/...) that holds an in memory store
- When the final executor_action finishes, if another follow up prompt (scratch ID) is queued then we can automatically begin executing it (crates/local-deployment/src/container.rs after finalize)
- New endpoint required to modify the queue for a task attempt.
- Scratch should be wiped after the execution process is created
- Scratch can't be edited while queued
- Add button to TaskFollowUpSection to make current scratch queued, or cancel queued item

* prepare db

* Follow up box does not reset after sending message (vibe-kanban c032bc21)

- Type follow up
- Press send
- Expect follow up to be reset, but it is not

frontend/src/components/tasks/TaskFollowUpSection.tsx

* bg

* Fix i18n (vibe-kanban a7ee5604)

i18next::translator: missingKey en-GB tasks followUp.queue Queue

* Reduce re-renders (vibe-kanban 86ec1b47)

frontend/src/components/ui/wysiwyg.tsx
frontend/src/components/tasks/TaskFollowUpSection.tsx

* Speed up button transitions (vibe-kanban be499249)

It takes 0.5-1s for the send button to go from no opacity to full opacity after I start typing

frontend/src/components/tasks/TaskFollowUpSection.tsx

* add icon to variant selection (vibe-kanban 92fca0e6)

frontend/src/components/tasks/TaskFollowUpSection.tsx

Dropdown should have settings-2

* Queued message functionality (vibe-kanban 21c7a725)

Say I have two messages to send:
- I send first
- I queue the second
- I now see "message queued" and the follow up editable text contains the second
- First finishes, second starts, no tasks are queued
- I still see "message queued" box but the follow up editable text gets wiped

frontend/src/components/tasks/TaskFollowUpSection.tsx

* variant width adjust

* Move the attach button (vibe-kanban b7f89e6e)

Attach button should be to the left of of the send button

frontend/src/components/ui/wysiwyg.tsx
frontend/src/components/tasks/TaskFollowUpSection.tsx

* Cleanup WYSIWYG (vibe-kanban 62997d6c)

Props, and upstream logic:
- make placeholder optional:
- remove defaultValue: this seems redundant as value is always controlled, there may also be related cleanups for uncontrolled mode
- remove onFocusChange: toggling states is unnecessary here
- remove enableCopyButton: this is always enabled when the editor is disabled

frontend/src/components/ui/wysiwyg.tsx

* cleanup scratch types

* further scratch cleanup

* Tweak queue (vibe-kanban 642aa7be)

If a task is stopped or fails, the next queued task runs, however this is not the desired behaviour. Instead the queued task should be removed from the queue

* Can't see attach button and queue at the same time (vibe-kanban 75ca5428)

frontend/src/components/tasks/TaskFollowUpSection.tsx

* move follow up hooks

* WYSIWYG code blocks should scroll horizontally (vibe-kanban 6c5dbc99)

frontend/src/components/ui/wysiwyg.tsx

* Refactor useDefaultVariant (vibe-kanban 10ec12ec)

I think we could change this so that it accepts a default variant and then returns what variant is currently selected, based on the user's preferences and if they select one from the dropdown

* Can't retry a task (vibe-kanban dfde6ad8)

It seems to retry functionality was removed fromfrontend/src/components/NormalizedConversation/UserMessage.tsx

* If execution startup is slow, scratch is not reset (vibe-kanban 6e721b8e)

frontend/src/components/tasks/TaskFollowUpSection.tsx

If you write out a follow up and then hit send, if you then navigate away from the page quickly the scratch will still be present when you visit the page, when the expected behaviour is that the previous text would be cleared

* Code highlighting for inline code block (vibe-kanban 956f1d5c)

Currently works for multi-line, can we get it working for multi-line

frontend/src/components/ui/wysiwyg.tsx

* Delete FileSearchTextArea (vibe-kanban 01107879)

Replace with frontend/src/components/ui/wysiwyg.tsx

not frontend/src/components/ui/file-search-textarea.tsx

* Tweak styles in task dialog (vibe-kanban 8dfe95a9)

frontend/src/components/dialogs/tasks/TaskFormDialog.tsx

- Placeholder for WYSIWYG too small, just use default
- Make title same size as WYSIWYG H1

* Refactor retry to use variant hook (vibe-kanban 69c969c9)

frontend/src/hooks/useVariant.ts

frontend/src/components/NormalizedConversation/RetryEditorInline.tsx

frontend/src/contexts/RetryUiContext.tsx

Removing all existing logic related to variant picking

* Refactor approval message styles (vibe-kanban b9a905e1)

Refactor the WYSIWYG implementation in thefrontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx so the styles align with usage infrontend/src/components/tasks/TaskFollowUpSection.tsx

* Fix follow up box font (vibe-kanban 4fa9cd39)

When I start typing, it's a really small font for some reason

frontend/src/components/tasks/TaskFollowUpSection.tsx

* Remove double border for plan approval (vibe-kanban 3f12c591)

frontend/src/components/NormalizedConversation/PendingApprovalEntry.tsx

- Also multi-line code block colour is broken when looking at plans (but not single line strangely...)

* Retry Editor shouldn't call API directly (vibe-kanban 3df9cde5)

Should use hooks frontend/src/components/NormalizedConversation/RetryEditorInline.tsx

* Image metadata for task creation (vibe-kanban 8dd18a28)

We have an endpoint for image metadata in task attempt, but not for task

crates/server/src/routes/images.rs

This means we can't currently render the image (and metadata) in the WYSIWYG editorfrontend/src/components/dialogs/tasks/TaskFormDialog.tsx

* Add file upload to retry (vibe-kanban 8dffeed2)

frontend/src/components/NormalizedConversation/RetryEditorInline.tsx

Similar to:

frontend/src/components/tasks/TaskFollowUpSection.tsx

Infact we should reuse the same component as much as possible

* Remove the client side scratch deletion (vibe-kanban c6b0a613)

frontend/src/components/tasks/TaskFollowUpSection.tsx

This happens now on backend.

Also on backend when queued task is triggered we should also wipe the scratch.

* Queued task style (vibe-kanban 0c9bc110)

frontend/src/components/tasks/TaskFollowUpSection.tsx

When a message is queued it repeats the message under "will execute when current run finishes", however the message is visible anyway in the message box so we can remove that

* WYSIWYG base font size decrease

* Queueing a message change (vibe-kanban 30ee2d4d)

Currently when we queue a message I can see in the logs: Failed to save follow-up draft ApiError: Cannot edit scratch while a message is queued

I think this is because the following is happening:

- User types
- Clicks queue
- Debounce tries to save message
- Can't save message because of queue
2025-12-02 14:52:27 +00:00

353 lines
10 KiB
TypeScript

import {
useState,
useCallback,
useRef,
useImperativeHandle,
useEffect,
forwardRef,
} from 'react';
import {
X,
Image as ImageIcon,
Upload,
ChevronRight,
AlertCircle,
} from 'lucide-react';
import { Button } from './button';
import { Alert, AlertDescription } from './alert';
import { cn, formatFileSize } from '@/lib/utils';
import { imagesApi } from '@/lib/api';
import type { ImageResponse } from 'shared/types';
interface ImageUploadSectionProps {
images: ImageResponse[];
onImagesChange: (images: ImageResponse[]) => void;
onUpload: (file: File) => Promise<ImageResponse>;
onDelete?: (imageId: string) => Promise<void>;
onImageUploaded?: (image: ImageResponse) => void; // Custom callback for upload success
isUploading?: boolean;
disabled?: boolean;
readOnly?: boolean;
collapsible?: boolean;
defaultExpanded?: boolean;
hideDropZone?: boolean; // Hide the drag and drop area
className?: string;
}
export interface ImageUploadSectionHandle {
addFiles: (files: FileList | File[] | null) => Promise<void>;
}
const MAX_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
const VALID_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
];
export const ImageUploadSection = forwardRef<
ImageUploadSectionHandle,
ImageUploadSectionProps
>(
(
{
images,
onImagesChange,
onUpload,
onDelete,
onImageUploaded,
isUploading = false,
disabled = false,
readOnly = false,
collapsible = true,
defaultExpanded = false,
hideDropZone = false,
className,
},
ref
) => {
const [isExpanded, setIsExpanded] = useState(
defaultExpanded || images.length > 0
);
const [isDragging, setIsDragging] = useState(false);
const [uploadingFiles, setUploadingFiles] = useState<Set<string>>(
new Set()
);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const latestImagesRef = useRef(images);
useEffect(() => {
latestImagesRef.current = images;
}, [images]);
useEffect(() => {
if (collapsible && images.length > 0 && !isExpanded) {
setIsExpanded(true);
}
}, [collapsible, images.length, isExpanded]);
const handleFiles = useCallback(
async (filesInput: FileList | File[] | null) => {
if (!filesInput || disabled || readOnly) return;
const files = Array.isArray(filesInput)
? filesInput
: Array.from(filesInput);
setErrorMessage(null);
const invalidFiles: string[] = [];
const oversizedFiles: string[] = [];
const validFiles: File[] = [];
files.forEach((file) => {
if (!VALID_TYPES.includes(file.type.toLowerCase())) {
invalidFiles.push(file.name);
return;
}
if (file.size > MAX_SIZE_BYTES) {
oversizedFiles.push(
`${file.name} (${(file.size / 1048576).toFixed(1)} MB)`
);
return;
}
validFiles.push(file);
});
if (invalidFiles.length > 0 || oversizedFiles.length > 0) {
const errors: string[] = [];
if (invalidFiles.length > 0) {
errors.push(`Unsupported file type: ${invalidFiles.join(', ')}`);
}
if (oversizedFiles.length > 0) {
errors.push(
`Files too large (max 20 MB): ${oversizedFiles.join(', ')}`
);
}
setErrorMessage(errors.join('. '));
}
for (const file of validFiles) {
const tempId = `uploading-${Date.now()}-${file.name}`;
setUploadingFiles((prev) => new Set(prev).add(tempId));
try {
const uploadedImage = await onUpload(file);
// Call custom upload callback if provided, otherwise use default behavior
if (onImageUploaded) {
onImageUploaded(uploadedImage);
} else {
const nextImages = [...latestImagesRef.current, uploadedImage];
latestImagesRef.current = nextImages;
onImagesChange(nextImages);
}
setErrorMessage(null);
} catch (error: unknown) {
console.error('Failed to upload image:', error);
const message =
error instanceof Error
? error.message
: 'Failed to upload image. Please try again.';
setErrorMessage(message);
} finally {
setUploadingFiles((prev) => {
const next = new Set(prev);
next.delete(tempId);
return next;
});
}
}
},
[disabled, readOnly, onUpload, onImageUploaded, onImagesChange]
);
useImperativeHandle(
ref,
() => ({
addFiles: async (files: FileList | File[] | null) => {
await handleFiles(files);
},
}),
[handleFiles]
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
void handleFiles(e.dataTransfer.files);
},
[handleFiles]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleRemoveImage = useCallback(
async (imageId: string) => {
if (onDelete) {
try {
await onDelete(imageId);
} catch (error) {
console.error('Failed to delete image:', error);
}
}
onImagesChange(images.filter((img) => img.id !== imageId));
},
[images, onImagesChange, onDelete]
);
const content = (
<div className={cn('space-y-3', className)}>
{/* Error message */}
{errorMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{errorMessage}</AlertDescription>
</Alert>
)}
{/* Read-only message */}
{readOnly && images.length === 0 && (
<p className="text-sm text-muted-foreground">No images attached</p>
)}
{/* Drop zone - only show when not read-only and not hidden */}
{!readOnly && !hideDropZone && (
<div
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-muted-foreground/50',
disabled && 'opacity-50 cursor-not-allowed'
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
>
<Upload className="h-8 w-8 mx-auto mb-3 text-muted-foreground" />
<p className="text-sm text-muted-foreground mb-1">
Drag and drop images here, or click to select
</p>
<Button
variant="secondary"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={disabled || isUploading}
>
Select Images
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
void handleFiles(e.target.files);
}}
disabled={disabled}
/>
</div>
)}
{/* Image previews */}
{images.length > 0 && (
<div className="grid grid-cols-2 gap-2">
{images.map((image) => (
<div
key={image.id}
className="relative group border rounded-lg p-2 bg-background"
>
<div className="flex items-center gap-2">
<img
src={imagesApi.getImageUrl(image.id)}
alt={image.original_name}
className="h-16 w-16 object-cover rounded"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium truncate">
{image.original_name}
</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(image.size_bytes)}
</p>
</div>
</div>
{!disabled && !readOnly && (
<Button
variant="ghost"
size="icon"
className="absolute top-1 right-1 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleRemoveImage(image.id)}
>
<X className="h-3 w-3" />
</Button>
)}
</div>
))}
</div>
)}
{/* Uploading indicators */}
{uploadingFiles.size > 0 && (
<div className="space-y-1">
{Array.from(uploadingFiles).map((tempId) => (
<div
key={tempId}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<div className="h-3 w-3 border-2 border-primary border-t-transparent rounded-full animate-spin" />
<span>Uploading...</span>
</div>
))}
</div>
)}
</div>
);
if (!collapsible) {
return content;
}
return (
<div className="space-y-2">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={cn(
'h-3 w-3 transition-transform',
isExpanded && 'rotate-90'
)}
/>
<ImageIcon className="h-4 w-4" />
<span>Images {images.length > 0 && `(${images.length})`}</span>
</button>
{isExpanded && content}
</div>
);
}
);
ImageUploadSection.displayName = 'ImageUploadSection';