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
This commit is contained in:
Bohdan Triapitsyn
2026-02-11 19:28:22 +02:00
committed by GitHub
parent 39e625d8ec
commit 844562749d
25 changed files with 1621 additions and 489 deletions
+112 -19
View File
@@ -8,6 +8,7 @@ import type { PermissionRequest } from '@/types/permission';
import type { QuestionRequest } from '@/types/question';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatScrollManager';
import { filterSyntheticParts } from '@/lib/messages/synthetic';
import { detectTurns, type Turn } from './hooks/useTurnGrouping';
import { TurnGroupingProvider, useMessageNeighbors, useTurnGroupingContextForMessage, useTurnGroupingContextStatic, useLastTurnMessageIds } from './contexts/TurnGroupingContext';
interface ChatMessageEntry {
@@ -87,31 +88,103 @@ const DynamicMessageRow = React.memo<MessageRowProps>(({
DynamicMessageRow.displayName = 'DynamicMessageRow';
// Inner component that renders messages with access to context hooks
const MessageListContent: React.FC<{
displayMessages: ChatMessageEntry[];
interface TurnBlockProps {
turn: Turn;
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ displayMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
}
const TurnBlock: React.FC<TurnBlockProps> = ({
turn,
onMessageContentChange,
getAnimationHandlers,
scrollToBottom,
}) => {
const lastTurnMessageIds = useLastTurnMessageIds();
const renderMessage = React.useCallback(
(message: ChatMessageEntry) => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
},
[getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom]
);
return (
<section className="relative w-full" data-turn-id={turn.turnId}>
<div className="sticky top-0 z-20 relative bg-[var(--surface-background)] [overflow-anchor:none]">
<div className="relative z-10">
{renderMessage(turn.userMessage)}
</div>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 top-full z-0 h-8 bg-gradient-to-b from-[var(--surface-background)] to-transparent"
/>
</div>
<div className="relative z-0">
{turn.assistantMessages.map((message) => renderMessage(message))}
</div>
</section>
);
};
TurnBlock.displayName = 'TurnBlock';
// Inner component that renders messages with access to context hooks
const MessageListContent: React.FC<{
turns: Turn[];
ungroupedMessages: ChatMessageEntry[];
onMessageContentChange: (reason?: ContentChangeReason) => void;
getAnimationHandlers: (messageId: string) => AnimationHandlers;
scrollToBottom?: (options?: { instant?: boolean; force?: boolean }) => void;
}> = ({ turns, ungroupedMessages, onMessageContentChange, getAnimationHandlers, scrollToBottom }) => {
const lastTurnMessageIds = useLastTurnMessageIds();
const renderUngroupedMessage = React.useCallback(
(message: ChatMessageEntry) => {
const role = (message.info as { clientRole?: string | null | undefined }).clientRole ?? message.info.role;
const isInLastTurn = role !== 'user' && lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
},
[getAnimationHandlers, lastTurnMessageIds, onMessageContentChange, scrollToBottom]
);
return (
<>
{displayMessages.map((message) => {
const isInLastTurn = lastTurnMessageIds.has(message.info.id);
const RowComponent = isInLastTurn ? DynamicMessageRow : StaticMessageRow;
return (
<RowComponent
key={message.info.id}
message={message}
onContentChange={onMessageContentChange}
animationHandlers={getAnimationHandlers(message.info.id)}
scrollToBottom={scrollToBottom}
/>
);
})}
{ungroupedMessages.map((message) => renderUngroupedMessage(message))}
{turns.map((turn) => (
<TurnBlock
key={turn.turnId}
turn={turn}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}
/>
))}
</>
);
};
@@ -161,6 +234,25 @@ const MessageList: React.FC<MessageListProps> = ({
});
}, [messages]);
const { turns, ungroupedMessages } = React.useMemo(() => {
const groupedTurns = detectTurns(displayMessages);
const groupedMessageIds = new Set<string>();
groupedTurns.forEach((turn) => {
groupedMessageIds.add(turn.userMessage.info.id);
turn.assistantMessages.forEach((message) => {
groupedMessageIds.add(message.info.id);
});
});
const ungrouped = displayMessages.filter((message) => !groupedMessageIds.has(message.info.id));
return {
turns: groupedTurns,
ungroupedMessages: ungrouped,
};
}, [displayMessages]);
return (
<TurnGroupingProvider messages={displayMessages}>
<div>
@@ -183,7 +275,8 @@ const MessageList: React.FC<MessageListProps> = ({
)}
<MessageListContent
displayMessages={displayMessages}
turns={turns}
ungroupedMessages={ungroupedMessages}
onMessageContentChange={onMessageContentChange}
getAnimationHandlers={getAnimationHandlers}
scrollToBottom={scrollToBottom}