Long chats stay fast; search in a chat; media overview
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>
This commit is contained in:
1 parent
6e9eda28b5
commit
3b0764450a
14 files changed
+1278
-99
No files matched your search
@@ -0,0 +1,125 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user