Files
openchamber/packages/ui/src/components/chat/QueuedMessageChips.tsx
T
Bohdan Triapitsyn 844562749d feat(ui): enable drag-and-drop attachments and image previews in chat (#390)
* feat(BottomTerminalDock): add close button next to the fullscreen toggle in the dock

* style: replace hardcoded gradient with theme value in shine text variant

* fix(header): adapt instance button for desktop only

* refactor(chat): polish sticky turn UX and message action rows for better readability

Switch to stable sticky-only turn behavior and redesign user/assistant action controls (placement, hover rules, ordering, spacing, selection-safe clamp) to reduce visual noise and improve interaction flow.

* feat(chat/message): refactor buttons in messages footer

* feat: enhance image preview functionality in chat messages

- Added a new ImagePreviewDialog component to handle image previews with navigation support.
- Updated ToolOutputDialog to utilize the new ImagePreviewDialog for displaying images.
- Modified the ToolPopupContent type to include a gallery of images and an index for the current image.
- Removed the old inline image display logic from ToolOutputDialog.
- Improved file handling in the file store, including better MIME type guessing and handling of server paths.
- Introduced a new API endpoint for handling large session message payloads, allowing for better management of multi-file attachments.
- Updated the VSCode bridge to support session message requests with appropriate headers and body handling.

* feat(proxy): implement SSE forwarding and enhance generic API request handling

* feat(chat): support submitting only queued messages

* feat: add image preview transition state

* fix: default VSCode view to draft and fixed sessions list regression
2026-02-11 19:28:22 +02:00

117 lines
4.4 KiB
TypeScript

import React, { memo } from 'react';
import { RiCloseLine, RiMessage2Line } from '@remixicon/react';
import { useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useFileStore } from '@/stores/fileStore';
interface QueuedMessageChipProps {
message: QueuedMessage;
sessionId: string;
onEdit: (message: QueuedMessage) => void;
}
const QueuedMessageChip = memo(({ message, sessionId, onEdit }: QueuedMessageChipProps) => {
const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue);
// Get first line of message, truncated
const firstLine = React.useMemo(() => {
const lines = message.content.split('\n');
const first = lines[0] || '';
const maxLength = 50;
if (first.length > maxLength) {
return first.substring(0, maxLength) + '...';
}
return first + (lines.length > 1 ? '...' : '');
}, [message.content]);
const attachmentCount = message.attachments?.length ?? 0;
return (
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-muted/30 border border-border/30 rounded-xl typography-meta group">
<RiMessage2Line className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<button
type="button"
onClick={() => onEdit(message)}
className="flex items-center gap-1.5 hover:text-foreground transition-colors text-left"
title="Click to edit"
>
<span className="truncate max-w-[200px]">
{firstLine || '(empty)'}
</span>
{attachmentCount > 0 && (
<span className="text-muted-foreground flex-shrink-0">
+{attachmentCount} file{attachmentCount > 1 ? 's' : ''}
</span>
)}
</button>
<button
type="button"
onClick={() => removeFromQueue(sessionId, message.id)}
className="ml-1 hover:text-destructive p-0.5 opacity-60 group-hover:opacity-100 transition-opacity"
title="Remove from queue"
>
<RiCloseLine className="h-3 w-3" />
</button>
</div>
);
});
QueuedMessageChip.displayName = 'QueuedMessageChip';
interface QueuedMessageChipsProps {
onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void;
}
const EMPTY_QUEUE: QueuedMessage[] = [];
export const QueuedMessageChips = memo(({ onEditMessage }: QueuedMessageChipsProps) => {
const currentSessionId = useSessionStore((state) => state.currentSessionId);
const queuedMessages = useMessageQueueStore(
React.useCallback(
(state) => {
if (!currentSessionId) return EMPTY_QUEUE;
return state.queuedMessages[currentSessionId] ?? EMPTY_QUEUE;
},
[currentSessionId]
)
);
const popToInput = useMessageQueueStore((state) => state.popToInput);
const handleEdit = React.useCallback((message: QueuedMessage) => {
if (!currentSessionId) return;
const popped = popToInput(currentSessionId, message.id);
if (popped) {
// Restore attachments to file store if any
if (popped.attachments && popped.attachments.length > 0) {
const currentAttachments = useFileStore.getState().attachedFiles;
useFileStore.setState({
attachedFiles: [...currentAttachments, ...popped.attachments]
});
}
onEditMessage(popped.content, popped.attachments);
}
}, [currentSessionId, popToInput, onEditMessage]);
if (queuedMessages.length === 0 || !currentSessionId) {
return null;
}
return (
<div className="pb-2">
<div className="flex items-center flex-wrap gap-2 px-3 py-2 bg-muted/30 rounded-xl border border-border/30">
{queuedMessages.map((message) => (
<QueuedMessageChip
key={message.id}
message={message}
sessionId={currentSessionId}
onEdit={handleEdit}
/>
))}
</div>
</div>
);
});
QueuedMessageChips.displayName = 'QueuedMessageChips';