Performance (measured with 5,900 messages: open 9 s -> 0.7 s, typing 440 ms -> 25 ms)
- GET /sessions/{id}?limit=N loads the end of a chat (from the last summary
on, at most N); /sessions/{id}/messages?before|after|around loads more
(scrolling up stops at a summary unless asked). Without parameters the
full history as before (terminal client).
- Chat view: memoised message list (typing, streaming and status changes no
longer re-render the history), finished turns fold into "N steps · M files"
with the answer visible, tool card bodies and summaries are built only when
opened, scroll-to-bottom only when content changes.
Search: /sessions/{id}/search over the whole chat; a hit loads its part
(around) and marks the message, "back to the latest messages" returns.
Media: /sessions/{id}/media lists images, videos and audio of a chat and its
subagents (generated images, tool output, files written, media in answers,
attachments); ⋯ -> Media shows them with filters, a large view and a jump to
the message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
126 lines
5.6 KiB
TypeScript
126 lines
5.6 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { get } from "../api";
|
|
import { useT, type Key } from "../i18n";
|
|
import { Spinner } from "./Activity";
|
|
import { Icon } from "./Icon";
|
|
|
|
interface MediaItem {
|
|
kind: "image" | "video" | "audio";
|
|
url: string;
|
|
name: string;
|
|
source: "generated" | "tool" | "written" | "answer" | "upload";
|
|
created_at: number;
|
|
message_id?: string;
|
|
}
|
|
|
|
type Filter = "all" | MediaItem["kind"];
|
|
|
|
/**
|
|
* All images, videos and audio files of a chat (with its subagents), newest first.
|
|
* An item opens large; "show in chat" jumps to the message it came with.
|
|
*/
|
|
export function MediaGallery({ sessionId, onClose, onJump }: { sessionId: string; onClose: () => void; onJump: (messageId: string) => void }) {
|
|
const t = useT();
|
|
const [items, setItems] = useState<MediaItem[]>();
|
|
const [error, setError] = useState<string>();
|
|
const [filter, setFilter] = useState<Filter>("all");
|
|
const [open, setOpen] = useState<number>();
|
|
|
|
useEffect(() => {
|
|
get<MediaItem[]>(`/sessions/${sessionId}/media`)
|
|
.then(setItems)
|
|
.catch((e) => setError(e.message));
|
|
}, [sessionId]);
|
|
|
|
const shown = (items ?? []).filter((i) => filter === "all" || i.kind === filter);
|
|
const count = (k: Filter) => (items ?? []).filter((i) => k === "all" || i.kind === k).length;
|
|
const current = open !== undefined ? shown[open] : undefined;
|
|
|
|
// Keyboard: Esc closes, arrows step through the open item.
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") return current ? setOpen(undefined) : onClose();
|
|
if (current && e.key === "ArrowRight") setOpen((o) => Math.min((o ?? 0) + 1, shown.length - 1));
|
|
if (current && e.key === "ArrowLeft") setOpen((o) => Math.max((o ?? 0) - 1, 0));
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => window.removeEventListener("keydown", onKey);
|
|
}, [current, shown.length, onClose]);
|
|
|
|
return (
|
|
<div className="modal-backdrop" onClick={onClose}>
|
|
<div className="modal media-gallery" role="dialog" aria-label={t("media.title")} onClick={(e) => e.stopPropagation()}>
|
|
<div className="media-head">
|
|
<h3>
|
|
<Icon name="image" /> {t("media.title")}
|
|
</h3>
|
|
<div className="media-filters" role="radiogroup">
|
|
{(["all", "image", "video", "audio"] as Filter[]).map((k) => (
|
|
<button key={k} role="radio" aria-checked={filter === k} className={`chip-btn ${filter === k ? "active" : ""}`} onClick={() => (setFilter(k), setOpen(undefined))} disabled={!!items && count(k) === 0 && k !== "all"}>
|
|
{t(`media.filter.${k}` as Key)} {items && <small>{count(k)}</small>}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
{error && <div className="notice err">{error}</div>}
|
|
{!items && !error && (
|
|
<div className="empty">
|
|
<Spinner />
|
|
</div>
|
|
)}
|
|
{items && shown.length === 0 && <div className="empty">{t("media.none")}</div>}
|
|
{current ? (
|
|
<div className="media-viewer">
|
|
<div className="media-stage">
|
|
{current.kind === "image" && <img src={current.url} alt={current.name} />}
|
|
{current.kind === "video" && <video src={current.url} controls autoPlay />}
|
|
{current.kind === "audio" && <audio src={current.url} controls autoPlay />}
|
|
</div>
|
|
<div className="media-bar">
|
|
<button className="small ghost" disabled={open === 0} onClick={() => setOpen((o) => (o ?? 1) - 1)} aria-label={t("media.prev")}>
|
|
<Icon name="arrow-left" />
|
|
</button>
|
|
<span className="grow">
|
|
{current.name} · {t(`media.source.${current.source}` as Key)} · {new Date(current.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
|
</span>
|
|
{current.message_id && (
|
|
<button className="small" onClick={() => onJump(current.message_id!)}>
|
|
{t("media.showInChat")}
|
|
</button>
|
|
)}
|
|
<a className="small button" href={current.url} target="_blank" rel="noreferrer">
|
|
<Icon name="external" /> {t("media.open")}
|
|
</a>
|
|
<button className="small ghost" disabled={open === shown.length - 1} onClick={() => setOpen((o) => (o ?? 0) + 1)} aria-label={t("media.next")}>
|
|
<Icon name="arrow-right" />
|
|
</button>
|
|
<button className="small ghost" onClick={() => setOpen(undefined)} aria-label={t("media.back")}>
|
|
<Icon name="close" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="media-grid">
|
|
{shown.map((m, i) => (
|
|
<button key={m.url} className={`media-tile ${m.kind}`} onClick={() => setOpen(i)} title={`${m.name} · ${t(`media.source.${m.source}` as Key)}`}>
|
|
{m.kind === "image" && <img src={m.url} alt="" loading="lazy" decoding="async" />}
|
|
{m.kind === "video" && <video src={`${m.url}#t=0.1`} preload="metadata" muted />}
|
|
{m.kind === "audio" && (
|
|
<span className="media-audio">
|
|
<Icon name="music" />
|
|
<span>{m.name}</span>
|
|
</span>
|
|
)}
|
|
{m.kind === "video" && <Icon name="play" className="media-play" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|