Files
ARTEX/web/src/components/side-question-workspace.tsx
T
BaaRai e233c02314 Translate all Chinese text to English
Comments, doc comments, UI labels, log and error messages, LLM prompt
strings, markdown docs, test data and assertions. Identifiers, enum
keys, routes, placeholders and code structure are unchanged.

Notes:
- docs/漏洞流量证据.md renamed to docs/vulnerability-traffic-evidence.md
- tests that exercised CJK specific handling (GBK zip names, Chinese
  full text search) now use ASCII equivalents; go build passes and the
  test suite matches the pre translation baseline (remaining failures
  need a PostgreSQL DSN and fail identically on the original)
- frontend mention syntax now uses English kinds (finding, asset, ...)
  matching the backend, chat mentions tests pass
2026-10-08 23:14:34 +02:00

266 lines
11 KiB
TypeScript

"use client";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { ArrowUpIcon, MessageCircleQuestionIcon, SquareIcon, Trash2Icon, XIcon } from "lucide-react";
import { Markdown } from "@/components/markdown";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { Skeleton } from "@/components/ui/skeleton";
import { useIsMobile } from "@/hooks/use-mobile";
import type { SideQuestions } from "@/hooks/use-side-questions";
import { cn } from "@/lib/utils";
type ComposerLayout = "inline" | "stacked";
const preparationLabels = {
preparing: "Preparing context…",
summarizing_history: "Summarizing earlier side Q&A…",
compressing_snapshot: "Compressing the side-question context copy…",
retrying: "Model context exceeded, shrinking and retrying…",
answering: "Answering…",
};
export function SideQuestionButton({ side }: { side: SideQuestions }) {
if (!side.enabled) return null;
return (
<Button variant="outline" size="sm" onClick={() => side.setOpen(true)} title="/btw side question">
<MessageCircleQuestionIcon data-icon="inline-start" />
Side question
</Button>
);
}
function SidePanel({
side,
label,
composerLayout,
}: {
side: SideQuestions;
label: string;
composerLayout: ComposerLayout;
}) {
const inlineComposer = composerLayout === "inline";
const [confirm, setConfirm] = useState(false);
const viewport = useRef<HTMLDivElement>(null);
const pinned = useRef(true);
const tail = side.items.at(-1);
// biome-ignore lint/correctness/useExhaustiveDependencies: New cumulative text scrolls only readers who remain at the bottom.
useEffect(() => {
if (pinned.current && viewport.current) viewport.current.scrollTop = viewport.current.scrollHeight;
}, [tail?.answer, tail?.id]);
const status = { running: "Answering", completed: "Completed", failed: "Failed", cancelled: "Stopped", interrupted: "Interrupted" };
return (
<section className="flex h-full min-h-0 flex-col bg-background" aria-label="Side question panel">
<div className="flex items-center gap-2 border-b p-3">
<div className="min-w-0 flex-1">
<p className="font-medium">
Side question <span className="text-muted-foreground">/btw</span>
</p>
<p className="truncate text-muted-foreground text-xs">{label}</p>
</div>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setConfirm(true)}
disabled={!side.items.length || side.busy}
aria-label="Clear side-question history"
>
<Trash2Icon />
</Button>
<Button variant="ghost" size="icon-sm" onClick={() => side.setOpen(false)} aria-label="Close side-question panel">
<XIcon />
</Button>
</div>
<div className="border-b px-3 py-2 text-muted-foreground text-xs">
{side.snapshot ? (
<>
<p>{side.snapshot.model.model}</p>
<p>Context updated {new Date(side.snapshot.captured_at).toLocaleString()}</p>
</>
) : (
"Ask anytime after the main agent's first run"
)}
</div>
<div
ref={viewport}
onScroll={(event) => {
const el = event.currentTarget;
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
}}
className="min-h-0 flex-1 overflow-y-auto p-3"
>
{side.nextCursor > 0 && (
<Button variant="ghost" size="sm" onClick={() => void side.load(side.nextCursor)}>
Load earlier side Q&A
</Button>
)}
{side.loading && <Skeleton className="h-16 w-full" />}
{!side.loading && side.items.length === 0 && (
<Empty>
<EmptyHeader>
<EmptyTitle>Ask a question anytime</EmptyTitle>
<EmptyDescription>Answered from the current agent's context while the main task keeps running.</EmptyDescription>
</EmptyHeader>
</Empty>
)}
<div className="flex flex-col gap-5">
{side.items.map((item) => (
<article key={item.id} className="flex min-w-0 flex-col gap-2">
<div className="whitespace-pre-wrap break-words rounded-lg bg-muted p-3 text-sm">{item.question}</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-xs">
<Badge variant="secondary">{status[item.status]}</Badge>
<span className="truncate">{item.model.model}</span>
<time dateTime={item.snapshot_at} title={new Date(item.snapshot_at).toLocaleString()}>
Context {new Date(item.snapshot_at).toLocaleTimeString()}
</time>
</div>
{item.context?.estimated_input_tokens != null && (
<p className="text-muted-foreground text-xs">
Last {item.context.recent_exchanges} Q&A exchanges verbatim
{item.context.history_summarized && " · earlier Q&A summarized"}
{item.context.snapshot_summarized && " · uses the main-context digest"}
</p>
)}
{item.answer && <Markdown text={item.answer} />}
{!item.answer && item.status === "running" && (
<p role="status" className="text-muted-foreground text-sm">
{preparationLabels[item.context?.phase ?? "answering"]}
</p>
)}
{item.error && (
<Alert variant="destructive">
<AlertDescription>{item.error}</AlertDescription>
</Alert>
)}
</article>
))}
</div>
</div>
<div className="shrink-0 border-t p-3">
{(side.error || side.snapshot?.reason) && (
<Alert variant="destructive" className="mb-2">
<AlertDescription>{side.error || side.snapshot?.reason}</AlertDescription>
</Alert>
)}
<InputGroup className={inlineComposer ? "min-h-10" : "min-h-9"}>
<InputGroupTextarea
rows={1}
className={cn("overflow-y-auto", inlineComposer ? "max-h-40 min-h-0" : "max-h-36 min-h-9")}
aria-label="Side question"
placeholder="Ask about the current context…"
value={side.draft}
maxLength={4000}
disabled={side.busy}
onChange={(event) => side.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
void side.ask(side.draft);
}
}}
/>
<InputGroupAddon align={inlineComposer ? "inline-end" : "block-end"}>
{!inlineComposer && <span className="text-muted-foreground text-xs">Standalone Q&A · No tool execution</span>}
{side.running ? (
<InputGroupButton
className="ml-auto"
variant="destructive"
size="icon-xs"
onClick={() => void side.stop()}
aria-label="Stop the side answer"
>
<SquareIcon />
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
variant="default"
size="icon-xs"
onClick={() => void side.ask(side.draft)}
disabled={side.busy || !side.draft.trim() || !side.snapshot?.available}
aria-label="Send side question"
>
<ArrowUpIcon />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
{inlineComposer && (
<div className="shrink-0 truncate px-3 pt-0.5 pb-1 text-muted-foreground text-xs">Standalone Q&A · No tool execution</div>
)}
<AlertDialog open={confirm} onOpenChange={setConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Clear side-question history?</AlertDialogTitle>
<AlertDialogDescription>
Deletes this agent's side Q&A and stops any answer still being generated. The main session and context snapshot are kept.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => void side.clear()}>Clear history</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
export function SideQuestionWorkspace({
side,
label,
children,
composerLayout = "stacked",
}: {
side: SideQuestions;
label: string;
children: ReactNode;
composerLayout?: ComposerLayout;
}) {
const mobile = useIsMobile();
return (
<>
<ResizablePanelGroup orientation="horizontal" className="min-h-0 min-w-0 flex-1">
<ResizablePanel id="main-conversation" minSize="35%" className="flex min-h-0 min-w-0 flex-col">
{children}
</ResizablePanel>
{side.open && side.enabled && !mobile && (
<>
<ResizableHandle withHandle />
<ResizablePanel id="side-question" defaultSize="38%" minSize="280px" maxSize="65%">
<SidePanel side={side} label={label} composerLayout={composerLayout} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
<Drawer open={mobile && side.open && side.enabled} onOpenChange={side.setOpen}>
<DrawerContent className="h-[85svh]">
<DrawerHeader className="sr-only">
<DrawerTitle>Side question</DrawerTitle>
<DrawerDescription>Standalone Q&A for {label}</DrawerDescription>
</DrawerHeader>
<SidePanel side={side} label={label} composerLayout={composerLayout} />
</DrawerContent>
</Drawer>
</>
);
}