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} />
|
||||
|
||||
Reference in new issue
Block a user