123 lines
4.8 KiB
TypeScript
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>
|
|
);
|
|
}
|