Files
ThothII/frontend/src/shell/ModelActivityPanel.tsx
T
User c3caba94dd
Publish documentation / publish (push) Successful in 24s
fix(ui): expand session dialogs and repeat confirmation actions
2026-09-14 18:13:53 +02:00

123 lines
4.8 KiB
TypeScript

import { useI18n } from "../i18n";
import { useLayoutEffect, useRef } from "react";
import { Settings2, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { ActivityEntry, ActivityKind } from "../api/types";
import { useSessionStore } from "../store/sessionStore";
import { Button } from "../components/ui/button";
import { isNearBottom } from "./activityScroll";
const VISIBLE_MODEL_ACTIVITY_KINDS: ReadonlySet<ActivityKind> = new Set([
"prompt",
"thinking",
"assistant",
]);
const ACTIVITY_LABELS: Readonly<Partial<Record<ActivityKind, string>>> = {
prompt: "Question",
thinking: "Reasoning",
assistant: "Response",
};
export function isVisibleModelActivity(entry: ActivityEntry): boolean {
return VISIBLE_MODEL_ACTIVITY_KINDS.has(entry.kind);
}
export function formatModelActivity(text: string): string {
return text
.replace(/<think>[\s\S]*?<\/think>/g, "")
.replace(/<\/?think>/g, "")
.replace(/\r\n?/g, "\n")
.replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n")
.replace(/^[\t ]*[•‣–]\s+/gm, "- ")
.replace(/^(\d+)\)\s+/gm, (_, n) => `${n}. `)
.replace(/\n(?=\S)(?![-*+] |\d+\. |>|`|\|)/g, "\n\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function MarkdownBody({ entry }: { entry: ActivityEntry }) {
const tone = entry.kind === "thinking" ? "text-muted-foreground" : "text-foreground";
return (
<div className={`thot-prose thot-activity-prose min-w-0 w-full max-w-none text-sm ${tone}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{formatModelActivity(entry.text)}</ReactMarkdown>
</div>
);
}
function ActivityRow({ entry }: { entry: ActivityEntry }) {
const { t: translate } = useI18n();
return (
<article className="w-full min-w-0 border-b border-border/50 py-2 last:border-b-0">
<div className="mb-1 flex flex-wrap items-center gap-1 text-xs font-semibold text-muted-foreground">
<span className="text-foreground/80">{translate(ACTIVITY_LABELS[entry.kind] ?? entry.kind)}</span>
{entry.phase && <span aria-label={translate("Phase {phase}", { phase: entry.phase })}>{entry.phase}</span>}
</div>
<MarkdownBody entry={entry} />
</article>
);
}
/** Left drawer showing the user-facing projection of the in-memory model activity timeline. */
export function ModelActivityPanel({
desktopSplit = false,
onClose,
onOpenWorkspaceManager,
hidden = false,
}: {
desktopSplit?: boolean;
onClose: () => void;
onOpenWorkspaceManager?: () => void;
hidden?: boolean;
}) {
const { t: translate } = useI18n();
const activityLog = useSessionStore((s) => s.activityLog);
const visibleActivity = activityLog.filter(isVisibleModelActivity);
const visibleCount = visibleActivity.length;
const visibleTail = visibleActivity.at(-1);
const scrollRef = useRef<HTMLDivElement>(null);
const followRef = useRef(true);
useLayoutEffect(() => {
const viewport = scrollRef.current;
if (viewport && followRef.current) viewport.scrollTop = viewport.scrollHeight;
}, [visibleCount, visibleTail]);
return (
<aside aria-hidden={hidden || undefined} hidden={hidden} className={desktopSplit
? "static z-auto flex min-w-0 w-[var(--activity-panel-width)] shrink-0 flex-col border-r-0 bg-sidebar"
: "fixed inset-y-0 left-0 z-30 flex min-w-0 w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar"
}>
<div className="flex items-center justify-between gap-2 border-b border-border/60 px-3 py-2">
<h2 className="font-heading text-sm font-semibold text-foreground">{translate("Model activity")}</h2>
<div className="flex items-center gap-1">
<Button size="sm" variant="ghost" aria-label={translate("To Administration")} onClick={onOpenWorkspaceManager}>
<Settings2 className="size-3.5" />{translate("To Administration")}</Button>
<Button size="icon-sm" variant="ghost" aria-label={translate("Close model activity")} onClick={onClose}>
<X className="size-4" />
</Button>
</div>
</div>
<div
ref={scrollRef}
data-testid="activity-scroll"
tabIndex={0}
aria-label={translate("Model activity timeline")}
className="w-full min-w-0 flex-1 overflow-y-auto px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/40"
onScroll={(event) => {
followRef.current = isNearBottom(event.currentTarget);
}}
>
{visibleActivity.length === 0 ? (
<p className="py-3 text-sm text-muted-foreground">{translate("No activity yet.")}</p>
) : (
visibleActivity.map((entry, index) => (
<ActivityRow key={`${index}-${entry.toolCallId ?? entry.kind}`} entry={entry} />
))
)}
</div>
</aside>
);
}