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
+364
-70
@@ -1,12 +1,12 @@
|
||||
import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { useFetch, useModels } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, loadSession, loadSessions, useStore } from "../store";
|
||||
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||
@@ -26,8 +26,10 @@ import { ShellsChip } from "./ShellsPanel";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||
import { MediaGallery } from "./MediaGallery";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
type ToolCall = Extract<Part, { type: "tool_call" }>;
|
||||
|
||||
const EMPTY: Message[] = [];
|
||||
|
||||
@@ -166,10 +168,299 @@ function RefineFeedback({ text }: { text: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Search the whole chat on the server; a hit loads its part of the chat. */
|
||||
function ChatSearch({ sessionId, onPick, onClose }: { sessionId: string; onPick: (messageId: string) => void; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const [q, setQ] = useState("");
|
||||
const [hits, setHits] = useState<{ id: string; role: string; created_at: number; snippet: string }[]>();
|
||||
useEffect(() => {
|
||||
const query = q.trim();
|
||||
if (query.length < 2) return setHits(undefined);
|
||||
let alive = true;
|
||||
const h = setTimeout(() => {
|
||||
get<typeof hits>(`/sessions/${sessionId}/search?q=${encodeURIComponent(query)}`)
|
||||
.then((r) => alive && setHits(r ?? []))
|
||||
.catch(() => alive && setHits([]));
|
||||
}, 250);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearTimeout(h);
|
||||
};
|
||||
}, [q, sessionId]);
|
||||
return (
|
||||
<div className="chat-search" role="search">
|
||||
<div className="row">
|
||||
<Icon name="search" />
|
||||
<input autoFocus value={q} placeholder={t("chat.searchPlaceholder")} onChange={(e) => setQ(e.target.value)} onKeyDown={(e) => e.key === "Escape" && onClose()} aria-label={t("chat.search")} />
|
||||
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{hits && (
|
||||
<div className="chat-search-hits">
|
||||
<div className="hint">{hits.length ? t("chat.searchHits", { n: hits.length }) : t("chat.searchNone")}</div>
|
||||
{hits.map((h) => (
|
||||
<button key={h.id} onClick={() => onPick(h.id)}>
|
||||
<span className="who">
|
||||
{t(h.role === "assistant" ? "chat.agent" : "chat.you")} · {new Date(h.created_at).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||
</span>
|
||||
<span className="snippet">{h.snippet}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A part of the chat: a user message, a summary, or the work of the agent after it. */
|
||||
type Block = { kind: "user" | "summary" | "work"; messages: Message[] };
|
||||
|
||||
const hasUserText = (m: Message) => m.role === "user" && m.parts.some((p) => (p.type === "text" && !isHarnessNote(p.text)) || p.type === "image");
|
||||
|
||||
/** Group the messages into user messages and the agent's work between them. */
|
||||
function blocksOf(messages: Message[]): Block[] {
|
||||
const out: Block[] = [];
|
||||
for (const m of messages) {
|
||||
const kind = m.parts.some((p) => p.type === "summary") ? "summary" : hasUserText(m) ? "user" : "work";
|
||||
const last = out[out.length - 1];
|
||||
if (kind === "work" && last?.kind === "work") last.messages.push(m);
|
||||
else out.push({ kind, messages: [m] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages of a chat. Memoised: typing, streaming and status changes of the
|
||||
* chat view do not touch it – only new messages, tool results or the busy state.
|
||||
*/
|
||||
const MessageList = memo(function MessageList({
|
||||
messages,
|
||||
results,
|
||||
busy,
|
||||
canEdit,
|
||||
canRegenerate,
|
||||
sessionId,
|
||||
model,
|
||||
baseDir,
|
||||
onRewind,
|
||||
highlight,
|
||||
}: {
|
||||
messages: Message[];
|
||||
results: Map<string, ToolResult>;
|
||||
busy: boolean;
|
||||
canEdit: boolean;
|
||||
canRegenerate: boolean;
|
||||
sessionId: string;
|
||||
model: string;
|
||||
baseDir: string;
|
||||
onRewind: (m: Message) => void;
|
||||
highlight?: string;
|
||||
}) {
|
||||
const blocks = useMemo(() => blocksOf(messages), [messages]);
|
||||
const lastWork = blocks.map((b) => b.kind).lastIndexOf("work");
|
||||
const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id;
|
||||
return (
|
||||
<>
|
||||
{blocks.map((b, i) => {
|
||||
// Stable while older messages are loaded above (they may join the first block):
|
||||
// the last message; the block still growing at the end keeps its first one.
|
||||
const key = b.kind === "work" && i !== lastWork ? b.messages[b.messages.length - 1].id : b.messages[0].id;
|
||||
if (b.kind !== "work") return <MessageItem key={key} m={b.messages[0]} canEdit={canEdit} onRewind={onRewind} highlight={highlight} />;
|
||||
const items = b.messages.map((m) => (
|
||||
<AssistantOrNote
|
||||
key={m.id}
|
||||
m={m}
|
||||
results={results}
|
||||
busy={busy}
|
||||
baseDir={baseDir}
|
||||
highlight={highlight}
|
||||
regenerate={canRegenerate && m.id === lastAssistant ? { sessionId, model } : undefined}
|
||||
/>
|
||||
));
|
||||
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||
if (i === lastWork || steps.length < 2) return <div key={key}>{items}</div>;
|
||||
return <WorkBlock key={key} messages={b.messages} steps={steps} open={!!highlight && b.messages.some((m) => m.id === highlight)} baseDir={baseDir}>{items}</WorkBlock>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
/** A finished turn: "42 steps · 3 files" and the answer; the steps on click. */
|
||||
function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messages: Message[]; steps: ToolCall[]; open: boolean; baseDir: string; children: ReactNode }) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const files = new Set(steps.filter((c) => ["write", "edit"].includes(c.name) && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||
// The answer: text of the last assistant message of the turn.
|
||||
const answer = [...messages].reverse().find((m) => m.role === "assistant" && m.parts.some((p) => p.type === "text" && p.text.trim()));
|
||||
const answerText = answer ? (answer.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n") : "";
|
||||
if (open || forced) {
|
||||
return (
|
||||
<div className="work-block open">
|
||||
<button className="work-head" onClick={() => setOpen(false)} aria-expanded>
|
||||
<Icon name="chevron-down" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="msg work-block" data-mid={answer?.id}>
|
||||
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
|
||||
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
|
||||
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
|
||||
</button>
|
||||
{answerText && (
|
||||
<>
|
||||
<Markdown text={answerText} baseDir={baseDir} />
|
||||
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||||
const AssistantOrNote = memo(function AssistantOrNote({
|
||||
m,
|
||||
results,
|
||||
busy,
|
||||
baseDir,
|
||||
highlight,
|
||||
regenerate,
|
||||
}: {
|
||||
m: Message;
|
||||
results: Map<string, ToolResult>;
|
||||
busy: boolean;
|
||||
baseDir: string;
|
||||
highlight?: string;
|
||||
regenerate?: { sessionId: string; model: string };
|
||||
}) {
|
||||
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||
if (m.role === "assistant") {
|
||||
return (
|
||||
<div className={cls} data-mid={m.id}>
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const notes = (m.parts.filter((p) => p.type === "text") as { text: string }[]).filter((p) => isHarnessNote(p.text));
|
||||
if (!notes.length) return null;
|
||||
return (
|
||||
<div className={cls} data-mid={m.id}>
|
||||
{notes.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/** A user message or a summary of the history before it. */
|
||||
const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight }: { m: Message; canEdit: boolean; onRewind: (m: Message) => void; highlight?: string }) {
|
||||
const t = useT();
|
||||
const [summaryOpen, setSummaryOpen] = useState(false);
|
||||
const summary = m.parts.find((p) => p.type === "summary");
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
{summaryOpen && <Markdown text={summary.text} />}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
return (
|
||||
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
|
||||
{background.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{canEdit && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Older messages: loaded automatically when scrolled to the top; the history before a
|
||||
* summary only on request. Keeps the scroll position while the list grows upwards.
|
||||
*/
|
||||
function OlderLoader({ sessionId, afterSummary, scrollRef }: { sessionId: string; afterSummary?: boolean; scrollRef: RefObject<HTMLDivElement | null> }) {
|
||||
const t = useT();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const sentinel = useRef<HTMLDivElement>(null);
|
||||
const load = useCallback(async () => {
|
||||
const el = scrollRef.current;
|
||||
if (loading || !el) return;
|
||||
setLoading(true);
|
||||
const before = { height: el.scrollHeight, top: el.scrollTop };
|
||||
try {
|
||||
// Scrolling stops at a summary; its button loads the history before it.
|
||||
await loadOlder(sessionId, !afterSummary);
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollTop = before.top + (el.scrollHeight - before.height);
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading, sessionId, scrollRef, afterSummary]);
|
||||
useEffect(() => {
|
||||
const node = sentinel.current;
|
||||
if (!node || afterSummary || typeof IntersectionObserver === "undefined") return;
|
||||
const io = new IntersectionObserver((e) => e[0]?.isIntersecting && void load(), { root: scrollRef.current, rootMargin: "300px 0px 0px 0px" });
|
||||
io.observe(node);
|
||||
return () => io.disconnect();
|
||||
}, [afterSummary, load, scrollRef]);
|
||||
return (
|
||||
<div ref={sentinel} className="older-loader">
|
||||
{loading ? (
|
||||
<Spinner small />
|
||||
) : (
|
||||
<button className="small ghost" onClick={() => void load()}>
|
||||
<Icon name="arrow-up" /> {t(afterSummary ? "chat.showBeforeSummary" : "chat.loadOlder")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string; onMenu: () => void; onClosePane?: () => void }) {
|
||||
const t = useT();
|
||||
const session = useStore((s) => s.sessionById[sessionId]);
|
||||
const messages = useStore((s) => s.messages[sessionId] ?? EMPTY);
|
||||
const win = useStore((s) => s.windows[sessionId]);
|
||||
// Message to show (search hit, media): scrolled into view and marked.
|
||||
const [highlight, setHighlight] = useState<string>();
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [gallery, setGallery] = useState(false);
|
||||
const draft = useStore((s) => s.drafts[sessionId]);
|
||||
const busy = useStore((s) => !!s.busy[sessionId]);
|
||||
const errors = useStore((s) => s.errors[sessionId]);
|
||||
@@ -226,11 +517,36 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
return m;
|
||||
}, [messages]);
|
||||
|
||||
// Keep scrolled to the bottom while the user hasn't scrolled up.
|
||||
// Keep scrolled to the bottom while the user hasn't scrolled up (only when the
|
||||
// content changes – reading the height forces a layout).
|
||||
const permCount = permissions.length + questions.length;
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
||||
});
|
||||
}, [messages, draft, busy, waiting, compacting, permCount, errors]);
|
||||
|
||||
// Stable callbacks for the (memoised) message list.
|
||||
const rewindRef = useRef<(m: Message) => void>(() => undefined);
|
||||
const onRewind = useCallback((m: Message) => rewindRef.current(m), []);
|
||||
const showLatest = useCallback(async () => {
|
||||
setHighlight(undefined);
|
||||
await loadSession(sessionId);
|
||||
follow(true);
|
||||
}, [sessionId]);
|
||||
/** Load the part of the chat around a message and scroll to it. */
|
||||
const jumpTo = useCallback(
|
||||
async (messageId: string) => {
|
||||
follow(false);
|
||||
if (!getState().messages[sessionId]?.some((m) => m.id === messageId)) await loadAround(sessionId, messageId);
|
||||
setHighlight(messageId);
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!highlight) return;
|
||||
const h = setTimeout(() => document.querySelector(`[data-mid="${highlight}"]`)?.scrollIntoView({ block: "center" }), 50);
|
||||
return () => clearTimeout(h);
|
||||
}, [highlight, messages]);
|
||||
// … also when the pane gets smaller (on-screen keyboard opens) without a render.
|
||||
const hasSession = !!session;
|
||||
useEffect(() => {
|
||||
@@ -280,9 +596,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
}
|
||||
};
|
||||
|
||||
const lastAssistantIdx = messages.reduce((acc, m, i) => (m.role === "assistant" ? i : acc), -1);
|
||||
|
||||
const [rewinding, setRewinding] = useState<Message>();
|
||||
const rewind = (m: Message) => setRewinding(m);
|
||||
rewindRef.current = rewind;
|
||||
const rewindDone = async () => {
|
||||
const m = rewinding!;
|
||||
setRewinding(undefined);
|
||||
@@ -371,6 +688,9 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
>
|
||||
<Icon name="terminal" />
|
||||
</button>
|
||||
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<Icon name="folder" />
|
||||
</button>
|
||||
@@ -394,6 +714,16 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
>
|
||||
<Icon name="edit" /> {t("instructions.title")}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||
setGallery(true);
|
||||
}}
|
||||
>
|
||||
<Icon name="image" /> {t("media.title")}
|
||||
</a>
|
||||
{telegramPaired && !session.parent_id && (
|
||||
<a
|
||||
href="#"
|
||||
@@ -447,6 +777,17 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{gallery && (
|
||||
<MediaGallery
|
||||
sessionId={sessionId}
|
||||
onClose={() => setGallery(false)}
|
||||
onJump={(id) => {
|
||||
setGallery(false);
|
||||
void jumpTo(id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
|
||||
<ChatTabBar state={tabs} />
|
||||
<ChatTabPanes state={tabs} />
|
||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||
@@ -472,71 +813,24 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
<div className="chat">
|
||||
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
|
||||
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
|
||||
{messages.map((m, idx) => {
|
||||
if (m.role === "assistant") {
|
||||
const lastAnswer = idx === lastAssistantIdx && !busy && !readOnly && !session.parent_id;
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
|
||||
{lastAnswer && <RegenerateButton sessionId={sessionId} messageId={m.id} model={session.model} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const summary = m.parts.find((p) => p.type === "summary");
|
||||
if (summary && summary.type === "summary") {
|
||||
return (
|
||||
<details key={m.id} className="summary-divider">
|
||||
<summary>
|
||||
{summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
<Markdown text={summary.text} />
|
||||
</details>
|
||||
);
|
||||
}
|
||||
const allTexts = m.parts.filter((p) => p.type === "text") as { text: string }[];
|
||||
const background = allTexts.filter((p) => isHarnessNote(p.text));
|
||||
const texts = allTexts.filter((p) => !isHarnessNote(p.text));
|
||||
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
|
||||
if (texts.length === 0 && imgs.length === 0) {
|
||||
return background.length ? (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
return (
|
||||
<div key={m.id} className="msg">
|
||||
{background.map((p, i) => (
|
||||
<HarnessMessage key={i} text={p.text} />
|
||||
))}
|
||||
<div className="msg-user">
|
||||
{!busy && !readOnly && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => rewind(m)} title={t("chat.rewind")}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
<FileFigure key={i} name={`image-${i + 1}.${fileType(undefined, p.image.media_type).toLowerCase()}`} mime={p.image.media_type} size={imageSize(p.image)}>
|
||||
<img src={imageSrc(p.image)} alt="" loading="lazy" decoding="async" />
|
||||
</FileFigure>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{win?.older && <OlderLoader sessionId={sessionId} afterSummary={messages[0]?.parts.some((p) => p.type === "summary")} scrollRef={scrollRef} />}
|
||||
<MessageList
|
||||
messages={messages}
|
||||
results={results}
|
||||
busy={busy}
|
||||
canEdit={!busy && !readOnly}
|
||||
canRegenerate={!busy && !readOnly && !session.parent_id && !win?.newer}
|
||||
sessionId={sessionId}
|
||||
model={session.model}
|
||||
baseDir={session.cwd}
|
||||
onRewind={onRewind}
|
||||
highlight={highlight}
|
||||
/>
|
||||
{win?.newer && (
|
||||
<button className="back-to-latest" onClick={() => void showLatest()}>
|
||||
<Icon name="arrow-down" /> {t("chat.backToLatest")}
|
||||
</button>
|
||||
)}
|
||||
{draft && (
|
||||
<div className="msg">
|
||||
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
|
||||
|
||||
@@ -125,6 +125,12 @@ const P: Record<string, ReactNode> = {
|
||||
<path d="M15 4a3 3 0 0 1 3 3 3 3 0 0 1 2 5 3 3 0 0 1-2 5 3 3 0 0 1-6 1" />
|
||||
</>
|
||||
),
|
||||
search: (
|
||||
<>
|
||||
<circle cx="11" cy="11" r="6.5" />
|
||||
<path d="m16 16 4.5 4.5" />
|
||||
</>
|
||||
),
|
||||
eye: (
|
||||
<>
|
||||
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { FileFigure, imageSize, imageSrc } from "./FileFigure";
|
||||
import { useState } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
@@ -68,6 +69,8 @@ export function TodoList({ todos }: { todos: Todo[] }) {
|
||||
|
||||
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
|
||||
const t = useT();
|
||||
// The body (diff, output) is only built while the card is open – long chats have thousands.
|
||||
const [toggled, setToggled] = useState<boolean | null>(null);
|
||||
const progress = useStore((s) => s.progress[call.id]);
|
||||
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
|
||||
const statusText = result ? <Icon name={result.is_error ? "x-mark" : "check"} /> : running ? "…" : "–";
|
||||
@@ -119,9 +122,13 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
const openByDefault =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image"));
|
||||
const open = toggled ?? openByDefault;
|
||||
|
||||
return (
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={openByDefault}>
|
||||
<details className={`tool ${status === "run" ? "running" : ""}`} open={open} onToggle={(e) => {
|
||||
const now = (e.currentTarget as HTMLDetailsElement).open;
|
||||
if (now !== open) setToggled(now);
|
||||
}}>
|
||||
<summary>
|
||||
<span className="tool-name">{call.name}</span>
|
||||
<span className="tool-title">{toolTitle(call.name, call.input)}</span>
|
||||
@@ -130,6 +137,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
{call.name === "task" && call.input?.background && <span className="chip">{t("tool.background")}</span>}
|
||||
{status === "run" ? <Spinner small /> : <span className={`status ${status}`}>{statusText}</span>}
|
||||
</summary>
|
||||
{open && (
|
||||
<div className="tool-body">
|
||||
{call.name === "bash" && <pre className="mono">$ {call.input?.command}</pre>}
|
||||
{(call.name === "edit" || call.name === "write") && meta?.diff && <Diff diff={meta.diff} />}
|
||||
@@ -186,6 +194,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -338,6 +338,33 @@ export const de: Messages = {
|
||||
"chat.todoReminder": "Weiterarbeiten – {n} offene Todos:",
|
||||
"agents.refineAfterTodos": "Jedes erledigte Todo prüfen und bis zum Ende der Liste arbeiten",
|
||||
"agents.refineAfterTodosHint": "Jedes Todo, das der Agent als erledigt markiert, wird geprüft; bei Befunden wird es wieder geöffnet. Beendet er den Turn mit offenen Todos, arbeitet er weiter (für unbeaufsichtigte Läufe, z. B. über Nacht). Aus: nur ein Review am Ende des Turns.",
|
||||
"chat.workSteps": "{n} Schritte",
|
||||
"chat.workFiles": "{n} Dateien",
|
||||
"chat.loadOlder": "Ältere Nachrichten laden",
|
||||
"chat.showBeforeSummary": "Verlauf vor der Zusammenfassung anzeigen",
|
||||
"chat.backToLatest": "Zu den neuesten Nachrichten",
|
||||
"chat.search": "Im Chat suchen",
|
||||
"chat.searchPlaceholder": "Nachrichten, Tool-Ausgaben … durchsuchen",
|
||||
"chat.searchNone": "Keine Treffer",
|
||||
"chat.searchHits": "{n} Treffer",
|
||||
"chat.you": "Du",
|
||||
"chat.agent": "Agent",
|
||||
"media.title": "Medien",
|
||||
"media.none": "Noch keine Bilder, Videos oder Audiodateien in diesem Chat.",
|
||||
"media.filter.all": "Alle",
|
||||
"media.filter.image": "Bilder",
|
||||
"media.filter.video": "Videos",
|
||||
"media.filter.audio": "Audio",
|
||||
"media.source.generated": "generiert",
|
||||
"media.source.tool": "Tool-Ausgabe",
|
||||
"media.source.written": "vom Agenten geschrieben",
|
||||
"media.source.answer": "in einer Antwort",
|
||||
"media.source.upload": "dein Anhang",
|
||||
"media.showInChat": "Im Chat zeigen",
|
||||
"media.open": "Öffnen",
|
||||
"media.prev": "Vorheriges",
|
||||
"media.next": "Nächstes",
|
||||
"media.back": "Zurück zur Übersicht",
|
||||
"chat.reviewRound": "Review {round}: Änderungen nötig",
|
||||
"agents.refine": "Review-Schleife",
|
||||
"agents.refineRounds": "Review-Runden pro Aufgabe",
|
||||
|
||||
@@ -337,6 +337,33 @@ export const en = {
|
||||
"chat.todoReminder": "Back to work – {n} open todos:",
|
||||
"agents.refineAfterTodos": "Review every completed todo and work until the list is done",
|
||||
"agents.refineAfterTodosHint": "Each todo the agent marks completed is reviewed; with findings it is reopened. Ending the turn with open todos sends the agent back to work (for unattended runs, e.g. overnight). Off: only one review at the end of the turn.",
|
||||
"chat.workSteps": "{n} steps",
|
||||
"chat.workFiles": "{n} files",
|
||||
"chat.loadOlder": "Load older messages",
|
||||
"chat.showBeforeSummary": "Show the history before the summary",
|
||||
"chat.backToLatest": "Back to the latest messages",
|
||||
"chat.search": "Search in this chat",
|
||||
"chat.searchPlaceholder": "Search messages, tool output …",
|
||||
"chat.searchNone": "No matches",
|
||||
"chat.searchHits": "{n} matches",
|
||||
"chat.you": "You",
|
||||
"chat.agent": "Agent",
|
||||
"media.title": "Media",
|
||||
"media.none": "No images, videos or audio in this chat yet.",
|
||||
"media.filter.all": "All",
|
||||
"media.filter.image": "Images",
|
||||
"media.filter.video": "Videos",
|
||||
"media.filter.audio": "Audio",
|
||||
"media.source.generated": "generated",
|
||||
"media.source.tool": "tool output",
|
||||
"media.source.written": "written by the agent",
|
||||
"media.source.answer": "in an answer",
|
||||
"media.source.upload": "your attachment",
|
||||
"media.showInChat": "Show in chat",
|
||||
"media.open": "Open",
|
||||
"media.prev": "Previous",
|
||||
"media.next": "Next",
|
||||
"media.back": "Back to the overview",
|
||||
"chat.reviewRound": "Review {round}: changes requested",
|
||||
"agents.refine": "Review loop",
|
||||
"agents.refineRounds": "Review rounds per task",
|
||||
|
||||
+39
-2
@@ -32,6 +32,8 @@ export interface State {
|
||||
/** Includes subagent sessions loaded on demand. */
|
||||
sessionById: Record<string, Session>;
|
||||
messages: Record<string, Message[]>;
|
||||
/** Long chats are loaded in parts: are there older / newer messages than the loaded ones? */
|
||||
windows: Record<string, { older: boolean; newer: boolean } | undefined>;
|
||||
/** Sessions the current user may only read (shared with them). */
|
||||
readOnly: Record<string, boolean>;
|
||||
drafts: Record<string, Draft | undefined>;
|
||||
@@ -58,6 +60,7 @@ let state: State = {
|
||||
sessions: [],
|
||||
sessionById: {},
|
||||
messages: {},
|
||||
windows: {},
|
||||
readOnly: {},
|
||||
drafts: {},
|
||||
busy: {},
|
||||
@@ -130,7 +133,8 @@ export function applyEvent(ev: ServerEvent) {
|
||||
setState((s) => {
|
||||
const list = s.messages[ev.session_id];
|
||||
const drafts = s.drafts[ev.session_id]?.id === ev.message.id ? { ...s.drafts, [ev.session_id]: undefined } : s.drafts;
|
||||
if (!list) return { drafts };
|
||||
// Viewing an older part of the chat: the new message comes with "back to the end".
|
||||
if (!list || s.windows[ev.session_id]?.newer) return { drafts };
|
||||
if (list.some((m) => m.id === ev.message.id)) return { drafts };
|
||||
return { messages: { ...s.messages, [ev.session_id]: [...list, ev.message] }, drafts };
|
||||
});
|
||||
@@ -231,9 +235,17 @@ export async function loadSessions() {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Messages loaded when a chat opens (from the last summary on, at most this many). */
|
||||
export const PAGE = 200;
|
||||
|
||||
type Page = { messages: Message[]; has_older: boolean; has_newer: boolean };
|
||||
|
||||
export async function loadSession(id: string) {
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`);
|
||||
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any; has_older?: boolean; has_newer?: boolean }>(
|
||||
`/sessions/${id}?limit=${PAGE}`,
|
||||
);
|
||||
setState((s) => ({
|
||||
windows: { ...s.windows, [id]: { older: !!data.has_older, newer: !!data.has_newer } },
|
||||
...upsertSession(s, data.session),
|
||||
sessionById: { ...s.sessionById, [id]: data.session },
|
||||
messages: { ...s.messages, [id]: data.messages },
|
||||
@@ -247,6 +259,31 @@ export async function loadSession(id: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Older messages before the loaded ones (scrolling up). Returns how many came. */
|
||||
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
|
||||
const first = getState().messages[id]?.[0];
|
||||
if (!first) return 0;
|
||||
const page = await get<Page>(`/sessions/${id}/messages?before=${encodeURIComponent(first.id)}&limit=${PAGE}${stopAtSummary ? "&stop_at_summary=true" : ""}`);
|
||||
setState((s) => {
|
||||
const cur = s.messages[id] ?? [];
|
||||
if (cur[0]?.id !== first.id) return {}; // changed meanwhile
|
||||
return {
|
||||
messages: { ...s.messages, [id]: [...page.messages, ...cur] },
|
||||
windows: { ...s.windows, [id]: { older: page.has_older, newer: s.windows[id]?.newer ?? false } },
|
||||
};
|
||||
});
|
||||
return page.messages.length;
|
||||
}
|
||||
|
||||
/** Show the part of the chat around a message (search hit, media). */
|
||||
export async function loadAround(id: string, messageId: string) {
|
||||
const page = await get<Page>(`/sessions/${id}/messages?around=${encodeURIComponent(messageId)}&limit=${PAGE}`);
|
||||
setState((s) => ({
|
||||
messages: { ...s.messages, [id]: page.messages },
|
||||
windows: { ...s.windows, [id]: { older: page.has_older, newer: page.has_newer } },
|
||||
}));
|
||||
}
|
||||
|
||||
/** After reconnects: reload everything that could have changed. */
|
||||
export async function resync() {
|
||||
const isAdmin = getState().me?.role === "admin";
|
||||
|
||||
@@ -4185,3 +4185,216 @@ span.chat-tab > button {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- long chats ------------------------------------------------------------------
|
||||
* Finished turns fold into one line, older messages load on demand, search hits. */
|
||||
.work-block > .work-head {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 0;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.work-block > .work-head:hover {
|
||||
color: var(--fg1);
|
||||
}
|
||||
.work-block.open {
|
||||
border-left: 2px solid var(--bg2);
|
||||
padding-left: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.older-loader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 6px 0 12px;
|
||||
}
|
||||
.back-to-latest {
|
||||
position: sticky;
|
||||
bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 10px auto 0;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.msg.highlight,
|
||||
.summary-divider[data-mid].highlight {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 4px;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.chat-search {
|
||||
border-bottom: 1px solid var(--bg2);
|
||||
padding: 6px 12px;
|
||||
background: var(--bg0);
|
||||
}
|
||||
.chat-search > .row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.chat-search > .row input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-search-hits {
|
||||
max-height: 40vh;
|
||||
overflow-y: auto;
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.chat-search-hits button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.chat-search-hits button:hover {
|
||||
background: var(--bg1);
|
||||
}
|
||||
.chat-search-hits .who {
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.chat-search-hits .snippet {
|
||||
font-size: 13px;
|
||||
color: var(--fg1);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* --- media overview of a chat --------------------------------------------------- */
|
||||
.media-gallery {
|
||||
width: min(1100px, calc(100vw - 24px));
|
||||
height: min(820px, calc(100dvh - 24px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.media-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.media-head h3 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.media-filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
flex: 1;
|
||||
}
|
||||
.media-filters .chip-btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.media-filters small {
|
||||
color: var(--muted);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.media-grid {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-auto-rows: 150px;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-tile {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
background: var(--bg2);
|
||||
border: 1px solid var(--bg3);
|
||||
}
|
||||
.media-tile img,
|
||||
.media-tile video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-tile .media-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
.media-audio {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--fg2);
|
||||
word-break: break-all;
|
||||
}
|
||||
.media-audio .icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.media-viewer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-stage {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.media-stage img,
|
||||
.media-stage video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.media-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.media-bar .grow {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
unreleased: {
|
||||
en: ["Long chats open and react much faster: older messages load when you scroll up, finished turns fold into one line", "Search in a chat (magnifier in the top bar) and ⋯ → Media: all images, videos and audio of a chat"],
|
||||
de: ["Lange Chats öffnen und reagieren viel schneller: ältere Nachrichten laden beim Hochscrollen, abgeschlossene Turns sind eingeklappt", "Suche im Chat (Lupe oben) und ⋯ → Medien: alle Bilder, Videos und Audiodateien eines Chats"],
|
||||
},
|
||||
"202611-00018": {
|
||||
en: [
|
||||
"Modes with a review: every completed todo is reviewed, and the agent keeps working until the todo list is done – for overnight runs",
|
||||
|
||||
Reference in new issue
Block a user