diff --git a/application/single_app/config.py b/application/single_app/config.py
index a2faf1883..7ac2402a5 100644
--- a/application/single_app/config.py
+++ b/application/single_app/config.py
@@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
-VERSION = "0.261.261"
+VERSION = "0.261.262"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/v2_ui/src/components/chat/AssistantMarkdown.tsx b/application/v2_ui/src/components/chat/AssistantMarkdown.tsx
index 817631593..ed6a2d790 100644
--- a/application/v2_ui/src/components/chat/AssistantMarkdown.tsx
+++ b/application/v2_ui/src/components/chat/AssistantMarkdown.tsx
@@ -1,6 +1,6 @@
// AssistantMarkdown.tsx
// Markdown rendering for assistant output: prose, citations, masks, maths, diagrams, charts,
-// image proposals, and inline audio, video and image cards.
+// image proposals, inline audio, video and image cards, and galleries of images and clips.
//
// Split out of MessageList so that "what a message looks like" is separable from "how the
// thread is laid out". The renderer carries the whole substitution pipeline and the fence
@@ -35,8 +35,10 @@ import { InlineImageCard } from './InlineImageCard';
import { InlineImageProposal } from './InlineImageProposal';
import { InlineVideoCard } from './InlineVideoCard';
import { MathDisplay, MathInline } from './MathBlock';
+import { MediaGallery, MediaGalleryTile } from './MediaGallery';
import { MermaidDiagram } from './MermaidDiagram';
import { inlineMediaKind, inlineMediaTitle, safeMarkdownHref, safeMediaUrl } from '../../lib/inlineMedia';
+import { readMediaGallery, readMediaTileIndex, rehypeMediaGallery } from '../../lib/mediaGallery';
import {
IMAGE_PROPOSAL_LANGUAGE,
INLINE_CHART_LANGUAGE,
@@ -240,8 +242,12 @@ function Markdown({
// A link to an audio or video file plays in place instead of navigating away;
// every other link renders as before. `node` is the hast element, read for the
- // link's plain text and kept off the DOM element.
+ // link's plain text and kept off the DOM element. A clip in a gallery is a tile.
a: ({ href, children, node, ...props }) => {
+ const tile = readMediaTileIndex(node);
+ if (tile !== null) {
+ return
wrapper markdown puts around it is dropped: leaving it would box
@@ -360,7 +386,7 @@ function Markdown({
>
{content}
diff --git a/application/v2_ui/src/components/chat/ConversationDrawer.tsx b/application/v2_ui/src/components/chat/ConversationDrawer.tsx
index 18d31acc5..d16bb0aff 100644
--- a/application/v2_ui/src/components/chat/ConversationDrawer.tsx
+++ b/application/v2_ui/src/components/chat/ConversationDrawer.tsx
@@ -199,7 +199,7 @@ function DocumentsMode() {
documents={generated.documents}
error={generated.error}
/>
-
+
>
) : null;
@@ -267,13 +267,14 @@ export function ConversationDrawer() {
),
);
- // Close on Escape, matching the dismissal behaviour of the rest of the app.
+ // Close on Escape, matching the dismissal behaviour of the rest of the app. A dialog opened
+ // from the drawer, such as the media viewer or a document preview, takes that Escape itself.
useEffect(() => {
if (!drawerMode) {
return;
}
const onKeyDown = (event: KeyboardEvent) => {
- if (event.key === 'Escape') {
+ if (event.key === 'Escape' && !document.querySelector('[role="dialog"][aria-modal="true"]')) {
setDrawerMode(null);
}
};
diff --git a/application/v2_ui/src/components/chat/DrawerAssets.tsx b/application/v2_ui/src/components/chat/DrawerAssets.tsx
index ce10a9108..56ec7c724 100644
--- a/application/v2_ui/src/components/chat/DrawerAssets.tsx
+++ b/application/v2_ui/src/components/chat/DrawerAssets.tsx
@@ -4,11 +4,12 @@
// Generated lists the documents agents created in a shared conversation, with Download where the
// workspace's own download rules allow it and a preview for Markdown. Media gathers every image,
// video and audio clip the thread shows, including signed links an action fetched from a remote
-// service, which are otherwise hard to find again in a long thread.
+// service, which are otherwise hard to find again in a long thread. Images and clips are tiles,
+// three to a row, that open one viewer stepping through them all; recordings are players, each
+// with Download and a button that scrolls to its message.
-import { useCallback, useEffect, useState } from 'react';
-import { createPortal } from 'react-dom';
-import { Download, Eye, FileText, Film, ImageOff, Loader2, Music } from 'lucide-react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { Download, Eye, FileText, Loader2 } from 'lucide-react';
import { ApiError } from '../../lib/apiClient';
import {
fetchCollaborationGeneratedDocument,
@@ -16,16 +17,15 @@ import {
type GeneratedDocument,
} from '../../lib/collaboration';
import { saveBlob } from '../../lib/endpoints';
-import { resolveImageSource } from '../../lib/images';
import type { ConversationMediaItem } from '../../lib/conversationMedia';
import { useChatStore } from '../../stores/chatStore';
import { toast } from '../../stores/toastStore';
import { Modal } from '../ui/Modal';
import { PlainMarkdown } from '../ui/PlainMarkdown';
import { GlassButton } from '../ui/primitives';
-import { ImageLightbox } from './ImageLightbox';
import { InlineAudioPlayer } from './InlineAudioPlayer';
-import { InlineVideoCard } from './InlineVideoCard';
+import { MediaViewer, type MediaViewerItem } from './MediaViewer';
+import { ImageTile, VideoTile } from './MediaTiles';
/** Largest Markdown file previewed in the app; anything bigger is better read downloaded. */
const PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
@@ -245,82 +245,108 @@ export function GeneratedDocumentsSection({
);
}
-function ImageTile({ item, onOpen }: { item: ConversationMediaItem; onOpen: () => void }) {
- const [failed, setFailed] = useState(false);
+function MediaGroup({ label, count, children }: { label: string; count: number; children: React.ReactNode }) {
return (
-
-
-
+
+
+ {label}
+ {count}
+
+ {children}
+
);
}
-function PlayerDialog({ item, onClose }: { item: ConversationMediaItem; onClose: () => void }) {
- return (
-
- {item.kind === 'video' ? (
-
- ) : (
-
- )}
-
+export function MediaSection({
+ items,
+ onLocate,
+}: {
+ items: ConversationMediaItem[];
+ /** Scrolls the thread to the message an item appears in. */
+ onLocate?: (messageId: string) => void;
+}) {
+ const [viewing, setViewing] = useState(null);
+ const images = useMemo(() => items.filter((item) => item.kind === 'image'), [items]);
+ const videos = useMemo(() => items.filter((item) => item.kind === 'video'), [items]);
+ const recordings = useMemo(() => items.filter((item) => item.kind === 'audio'), [items]);
+ // One viewer steps through the images and then the clips, in the order the tiles show them.
+ const visual = useMemo(
+ () => [...images, ...videos].map(({ kind, src, title, messageId }) => ({
+ kind: kind === 'video' ? 'video' : 'image',
+ src,
+ title,
+ messageId,
+ })),
+ [images, videos],
);
-}
-export function MediaSection({ items }: { items: ConversationMediaItem[] }) {
- const [viewing, setViewing] = useState(null);
if (items.length === 0) {
return null;
}
- const images = items.filter((item) => item.kind === 'image');
- const recordings = items.filter((item) => item.kind !== 'image');
- const imageSource = viewing?.kind === 'image' ? resolveImageSource(viewing.src) : null;
return (
Media
- {images.length > 0 && (
-
- {images.map((item) => (
- setViewing(item)} />
- ))}
-
- )}
- {recordings.length > 0 && (
-
- {recordings.map((item) => (
- -
-
-
- ))}
-
- )}
- {viewing && imageSource && createPortal(
- // Portalled: the drawer's backdrop filter would otherwise confine a fixed overlay to it.
- setViewing(null)} />,
- document.body,
+
+ {images.length > 0 && (
+
+
+ {images.map((item, index) => (
+ -
+
setViewing(index)}
+ className="aspect-square"
+ />
+
+ ))}
+
+
+ )}
+ {videos.length > 0 && (
+
+
+ {videos.map((item, index) => (
+ -
+
setViewing(images.length + index)}
+ className="aspect-square"
+ />
+
+ ))}
+
+
+ )}
+ {recordings.length > 0 && (
+
+
+ {recordings.map((item) => (
+ -
+
onLocate(item.messageId) : undefined}
+ />
+
+ ))}
+
+
+ )}
+
+ {viewing !== null && visual[viewing] && (
+ setViewing(null)}
+ onLocate={onLocate ? (item) => item.messageId && onLocate(item.messageId) : undefined}
+ />
)}
- {viewing && viewing.kind !== 'image' && setViewing(null)} />}
);
}
diff --git a/application/v2_ui/src/components/chat/InlineAudioPlayer.tsx b/application/v2_ui/src/components/chat/InlineAudioPlayer.tsx
index 20bcb8ee0..53a41e85b 100644
--- a/application/v2_ui/src/components/chat/InlineAudioPlayer.tsx
+++ b/application/v2_ui/src/components/chat/InlineAudioPlayer.tsx
@@ -3,24 +3,50 @@
//
// Collapsed, it is a single line: play/pause, the recording's title, elapsed and total time, and
// a thin progress line. Pressing play (or the chevron) expands it to add a seek slider, stop,
-// volume and mute, and playback speed. It stays in the message rather than floating over the
-// thread, so a briefing that cites several recordings keeps each one beside the text about it.
+// volume and mute, playback speed, download and a link to open it in a new tab. It stays in the
+// message rather than floating over the thread, so a briefing that cites several recordings
+// keeps each one beside the text about it. The conversation drawer lists recordings with the
+// same player, adding a button that scrolls to the message the recording is in.
//
// Built from ,
+
+ {onLocate && (
+
+ )}
)}
diff --git a/application/v2_ui/src/components/chat/InlineMediaFallback.tsx b/application/v2_ui/src/components/chat/InlineMediaFallback.tsx
index f9e186d7d..7bf99dafd 100644
--- a/application/v2_ui/src/components/chat/InlineMediaFallback.tsx
+++ b/application/v2_ui/src/components/chat/InlineMediaFallback.tsx
@@ -1,19 +1,25 @@
// InlineMediaFallback.tsx
-// What an inline audio or video player turns into when its media cannot be played here.
+// What an inline audio or video player, or the media viewer, turns into when its media cannot
+// be played or shown here.
//
// A signed link that has expired, a host the page's content security policy does not allow, or
// a format the browser cannot decode all end the same way: the link is still useful, so it is
// shown as a link with a short reason rather than as a broken player.
import { ExternalLink } from 'lucide-react';
-import { safeMediaUrl, type InlineMediaKind } from '../../lib/inlineMedia';
+import { safeMediaUrl } from '../../lib/inlineMedia';
-export function InlineMediaFallback({ kind, src, title }: { kind: InlineMediaKind; src: string; title: string }) {
+const WHAT: Record<'audio' | 'video' | 'image', string> = {
+ audio: 'This recording can\u2019t be played here.',
+ video: 'This video can\u2019t be played here.',
+ image: 'This image can\u2019t be shown here.',
+};
+
+export function InlineMediaFallback({ kind, src, title }: { kind: 'audio' | 'video' | 'image'; src: string; title: string }) {
return (
- This {kind === 'audio' ? 'recording' : 'video'} can't be played here. The link may have expired or its
- host may not be allowed.
+ {WHAT[kind]} The link may have expired or its host may not be allowed.
void;
+}
+
+const GalleryContext = createContext(null);
+
+function describe(items: readonly MediaGalleryItem[]): string {
+ const images = items.filter((item) => item.kind === 'image').length;
+ const clips = items.length - images;
+ const parts = [
+ images > 0 ? `${images} ${images === 1 ? 'image' : 'images'}` : '',
+ clips > 0 ? `${clips} ${clips === 1 ? 'video' : 'videos'}` : '',
+ ].filter(Boolean);
+ return `Gallery: ${parts.join(' and ')}`;
+}
+
+export function MediaGallery({ items, children }: { items: readonly MediaGalleryItem[]; children: ReactNode }) {
+ const [viewing, setViewing] = useState<{ index: number; startAt?: number } | null>(null);
+ const open = useCallback((index: number, startAt?: number) => setViewing({ index, startAt }), []);
+ const state = useMemo(() => ({ items, open }), [items, open]);
+ const viewerItems = useMemo(
+ () => items.map(({ kind, src, title, detail }) => ({ kind, src, title, detail })),
+ [items],
+ );
+
+ return (
+
+
+ {children}
+
+ {viewing && (
+ setViewing({ index })}
+ onClose={() => setViewing(null)}
+ />
+ )}
+
+ );
+}
+
+function GalleryClip({ item, onExpand }: { item: MediaGalleryItem; onExpand: (startAt?: number) => void }) {
+ const videoRef = useRef(null);
+ const [playing, setPlaying] = useState(false);
+ const [failed, setFailed] = useState(false);
+
+ const expand = () => {
+ const video = videoRef.current;
+ const reached = video?.currentTime ?? 0;
+ video?.pause();
+ onExpand(reached > 0 ? reached : undefined);
+ };
+
+ return (
+
+ {failed ? (
+
+
+
+ ) : playing ? (
+
+
+ ) : (
+ setPlaying(true)}
+ className="aspect-[4/3]"
+ />
+ )}
+
+
+ );
+}
+
+/** One tile of the gallery around it, by its position there. */
+export function MediaGalleryTile({ index }: { index: number }) {
+ const gallery = useContext(GalleryContext);
+ const item = gallery?.items[index];
+ if (!gallery || !item) {
+ return null;
+ }
+ if (item.kind === 'image') {
+ return (
+ gallery.open(index)}
+ className="aspect-[4/3]"
+ />
+ );
+ }
+ return gallery.open(index, startAt)} />;
+}
diff --git a/application/v2_ui/src/components/chat/MediaTiles.tsx b/application/v2_ui/src/components/chat/MediaTiles.tsx
new file mode 100644
index 000000000..dde36bd69
--- /dev/null
+++ b/application/v2_ui/src/components/chat/MediaTiles.tsx
@@ -0,0 +1,166 @@
+// MediaTiles.tsx
+// The tiles an image or a video clip shows as, in a gallery in a reply and in the Media section
+// of the conversation drawer.
+//
+// An image tile is the picture, cropped to the tile. A clip's tile is its first frame with its
+// length and a play mark. That frame is fetched only once the tile is on screen, so opening a
+// long thread full of clips does not start loading every one of them. A tile whose image or
+// clip will not load says so, and still opens the viewer, which explains why and links to it.
+
+import { useEffect, useRef, useState, type RefObject } from 'react';
+import { clsx } from 'clsx';
+import { ImageOff, Play, VideoOff } from 'lucide-react';
+import { resolveImageSource } from '../../lib/images';
+import { formatMediaTime, posterFrameUrl, safeMediaUrl } from '../../lib/inlineMedia';
+
+const UNAVAILABLE_HINT = 'This could not be loaded. Its link may have expired, or its host may not be allowed.';
+
+/** True once the element is on screen, and from then on. */
+function useSeen(ref: RefObject): boolean {
+ const [seen, setSeen] = useState(() => typeof IntersectionObserver === 'undefined');
+ useEffect(() => {
+ const element = ref.current;
+ if (seen || !element) {
+ return undefined;
+ }
+ const observer = new IntersectionObserver((entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ setSeen(true);
+ }
+ }, { rootMargin: '200px' });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [ref, seen]);
+ return seen;
+}
+
+/** What a tile shows when its image or clip will not load. */
+export function MediaUnavailable({ kind }: { kind: 'image' | 'video' }) {
+ const Icon = kind === 'image' ? ImageOff : VideoOff;
+ return (
+
+
+ Unavailable
+
+ );
+}
+
+export function ImageTile({
+ src,
+ title,
+ label,
+ onOpen,
+ className,
+}: {
+ src: string;
+ title: string;
+ /** The button's accessible name, which says what pressing it does. */
+ label: string;
+ onOpen: () => void;
+ /** Sizing, such as the tile's aspect ratio. */
+ className?: string;
+}) {
+ const [failed, setFailed] = useState(false);
+ const source = resolveImageSource(src);
+ return (
+
+ );
+}
+
+export function VideoTile({
+ src,
+ title,
+ label,
+ onOpen,
+ className,
+}: {
+ src: string;
+ title: string;
+ label: string;
+ onOpen: () => void;
+ className?: string;
+}) {
+ const ref = useRef(null);
+ const seen = useSeen(ref);
+ const [duration, setDuration] = useState(0);
+ const [failed, setFailed] = useState(false);
+ return (
+
+ );
+}
diff --git a/application/v2_ui/src/components/chat/MediaViewer.tsx b/application/v2_ui/src/components/chat/MediaViewer.tsx
new file mode 100644
index 000000000..f6a2805c2
--- /dev/null
+++ b/application/v2_ui/src/components/chat/MediaViewer.tsx
@@ -0,0 +1,294 @@
+// MediaViewer.tsx
+// A large view of one image or clip from a set, stepping to the others with the arrow keys or
+// the buttons at either side.
+//
+// Opened from a gallery in a reply, where the set is that gallery, and from the Media section of
+// the conversation drawer, where it is every image and clip in the conversation. A clip starts
+// playing when it opens, and stepping away stops it. An image can be viewed at its actual size.
+// Download saves the file when its host lets the page read it and opens it in a new tab when it
+// does not. From the drawer, Show in conversation closes the viewer and scrolls to the message.
+//
+// Built on Modal, so Escape and a click outside close it, Tab stays inside it, and focus returns
+// to the tile that opened it.
+
+import { useCallback, useEffect, useState } from 'react';
+import { clsx } from 'clsx';
+import {
+ ChevronLeft,
+ ChevronRight,
+ Download,
+ ExternalLink,
+ Loader2,
+ LocateFixed,
+ Maximize2,
+ Minimize2,
+ X,
+} from 'lucide-react';
+import { resolveImageSource } from '../../lib/images';
+import { claimPlayback, safeMediaUrl } from '../../lib/inlineMedia';
+import { openMediaInNewTab } from '../../lib/mediaDownload';
+import { toast } from '../../stores/toastStore';
+import { Modal } from '../ui/Modal';
+import { InlineMediaFallback } from './InlineMediaFallback';
+import { useMediaDownload } from './useMediaDownload';
+
+export interface MediaViewerItem {
+ kind: 'image' | 'video';
+ /** An image source as resolveImageSource accepts it, or a clip's URL. */
+ src: string;
+ title: string;
+ /** A second line, such as an image's alt text when a caption gave the title. */
+ detail?: string;
+ /** The message the item appears in, for Show in conversation. */
+ messageId?: string;
+}
+
+const HEADER_BUTTON =
+ 'inline-flex shrink-0 items-center justify-center rounded-lg p-1.5 text-text-3 transition-colors ' +
+ 'hover:bg-surface-2 hover:text-text-1 disabled:cursor-not-allowed disabled:opacity-50';
+const SIDE_BUTTON =
+ 'absolute top-1/2 z-10 flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/55 ' +
+ 'text-white ring-1 ring-white/30 transition-colors hover:bg-black/75 aria-disabled:cursor-default ' +
+ 'aria-disabled:opacity-30 aria-disabled:hover:bg-black/55';
+
+function ViewerStage({
+ item,
+ actualSize,
+ onToggleZoom,
+ startAt,
+}: {
+ item: MediaViewerItem;
+ actualSize: boolean;
+ onToggleZoom: () => void;
+ startAt?: number;
+}) {
+ const [failed, setFailed] = useState(false);
+
+ if (item.kind === 'image') {
+ const source = resolveImageSource(item.src);
+ if (!source || failed) {
+ return ;
+ }
+ return (
+
setFailed(true)}
+ className={clsx(
+ 'rounded-lg',
+ actualSize ? 'max-w-none cursor-zoom-out' : 'max-h-full max-w-full cursor-zoom-in object-contain',
+ )}
+ />
+ );
+ }
+
+ if (failed) {
+ return ;
+ }
+ return (
+