fix(dashboard): v3.8.8 screen fixes — agent-bridge SSR + audit/logs/memory/playground (#2944)

Integrated into release/v3.8.8
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-05-30 21:18:25 -03:00
committed by GitHub
parent 7a0e803c01
commit 2fb5979118
32 changed files with 1204 additions and 382 deletions

View File

@@ -182,7 +182,7 @@ export default function A2aAuditTab() {
<span
className={`rounded-full border px-2 py-1 text-xs font-medium ${STATE_STYLES[task.state]}`}
>
{task.state}
{t(`a2aState${task.state.charAt(0).toUpperCase()}${task.state.slice(1)}`)}
</span>
</td>
<td className="px-4 py-3 text-text-muted">{taskDuration(task)}</td>

View File

@@ -330,7 +330,7 @@ export default function ComplianceTab() {
</td>
<td className="px-4 py-3">
<span className="rounded-md border border-border bg-surface px-2 py-1 font-mono text-xs text-text-main">
{entry.action}
{t.has(`eventTypes.${entry.action}`) ? t(`eventTypes.${entry.action}`) : entry.action}
</span>
</td>
<td className="px-4 py-3">

View File

@@ -24,8 +24,9 @@ export interface PoolCardProps {
}
function computeStatus(usage: PoolUsageSnapshot | null): "green" | "amber" | "red" {
if (!usage || usage.dimensions.length === 0) return "green";
const utilizations = usage.dimensions.map((d) =>
const dims = usage?.dimensions ?? [];
if (dims.length === 0) return "green";
const utilizations = dims.map((d) =>
d.limit > 0 ? (d.consumedTotal / d.limit) * 100 : 0
);
const avg = utilizations.reduce((s, u) => s + u, 0) / utilizations.length;
@@ -54,7 +55,7 @@ export default function PoolCard({
const { icon: statusIcon, cls: statusCls } = STATUS_ICONS[status];
// Check for plan dimensions from usage
const hasDimensions = usage && usage.dimensions.length > 0;
const hasDimensions = !!usage?.dimensions?.length;
return (
<Card padding="md">
@@ -103,10 +104,10 @@ export default function PoolCard({
<div
className="grid gap-3 mb-3"
style={{
gridTemplateColumns: `repeat(${Math.min(usage.dimensions.length, 3)}, 1fr)`,
gridTemplateColumns: `repeat(${Math.min(usage?.dimensions?.length ?? 0, 3)}, 1fr)`,
}}
>
{usage.dimensions.map((dim, i) => (
{(usage?.dimensions ?? []).map((dim, i) => (
<DimensionBar
key={`${dim.unit}-${dim.window}-${i}`}
dimension={{ unit: dim.unit, window: dim.window, limit: dim.limit }}

View File

@@ -40,12 +40,12 @@ export function usePoolsUsageAggregate(pools: QuotaPool[]): PoolsUsageAggregate
let utilCount = 0;
let borrowing = 0;
for (const { usage } of valid) {
for (const dim of usage.dimensions) {
for (const dim of usage.dimensions ?? []) {
if (dim.limit > 0) {
totalUtil += (dim.consumedTotal / dim.limit) * 100;
utilCount += 1;
}
for (const key of dim.perKey) {
for (const key of dim.perKey ?? []) {
if (key.borrowing) borrowing += 1;
}
}

View File

@@ -1,9 +1,7 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { ConfirmModal, RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
import { ConfirmModal, RequestLoggerV2 } from "@/shared/components";
import EmailPrivacyToggle from "@/shared/components/EmailPrivacyToggle";
import ActiveRequestsPanel from "@/shared/components/ActiveRequestsPanel";
import { useTranslations } from "next-intl";
@@ -15,18 +13,7 @@ const TIME_RANGES = [
{ label: "24h", hours: 24 },
];
const TAB_TO_LOG_TYPE: Record<string, string> = {
"request-logs": "request-logs",
"proxy-logs": "proxy-logs",
console: "call-logs",
};
export default function LogsPage() {
const searchParams = useSearchParams();
const requestedTab = searchParams.get("tab");
const [activeTab, setActiveTab] = useState(
requestedTab && TAB_TO_LOG_TYPE[requestedTab] ? requestedTab : "request-logs"
);
const [showExport, setShowExport] = useState(false);
const [exporting, setExporting] = useState(false);
const [showCleanHistory, setShowCleanHistory] = useState(false);
@@ -36,12 +23,6 @@ export default function LogsPage() {
const dropdownRef = useRef<HTMLDivElement>(null);
const t = useTranslations("logs");
useEffect(() => {
if (requestedTab && TAB_TO_LOG_TYPE[requestedTab] && requestedTab !== activeTab) {
setActiveTab(requestedTab);
}
}, [activeTab, requestedTab]);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
@@ -56,7 +37,7 @@ export default function LogsPage() {
setExporting(true);
setShowExport(false);
try {
const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs";
const logType = "request-logs";
const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`);
if (!res.ok) throw new Error(t("exportFailed"));
const blob = await res.blob();
@@ -108,15 +89,7 @@ export default function LogsPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<SegmentedControl
options={[
{ value: "request-logs", label: t("requestLogs") },
{ value: "proxy-logs", label: t("proxyLogs") },
{ value: "console", label: t("console") },
]}
value={activeTab}
onChange={setActiveTab}
/>
<h2 className="text-lg font-semibold text-text-main">{t("requestLogs")}</h2>
<div className="flex items-center gap-2">
<EmailPrivacyToggle size="md" />
@@ -211,14 +184,10 @@ export default function LogsPage() {
</div>
)}
{activeTab === "request-logs" && (
<div className="flex flex-col gap-6">
<ActiveRequestsPanel />
<RequestLoggerV2 key={requestLogKey} />
</div>
)}
{activeTab === "proxy-logs" && <ProxyLogger />}
{activeTab === "console" && <ConsoleLogViewer />}
<div className="flex flex-col gap-6">
<ActiveRequestsPanel />
<RequestLoggerV2 key={requestLogKey} />
</div>
<ConfirmModal
isOpen={showCleanHistory}

View File

@@ -85,7 +85,12 @@ export default function MemoryEngineStatus({ status, onConfigure }: Props) {
: "red",
reason: status.vectorStore.reason,
cta:
status.vectorStore.needsReindex > 0 ? (
status.vectorStore.backend === "none" ? (
<span className="text-xs text-amber-400 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">terminal</span>
{t("engine.vectorStoreInstallHint")}
</span>
) : status.vectorStore.needsReindex > 0 ? (
<span className="text-xs text-amber-400 flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">warning</span>
{t("engine.needsReindex", { count: status.vectorStore.needsReindex })}

View File

@@ -236,6 +236,16 @@ export default function MemoriesTab() {
}
};
// Auto-run health check on mount + poll every 30s, so the indicator reflects
// engine health without requiring a manual click.
useEffect(() => {
void checkHealth();
const id = setInterval(() => {
void checkHealth();
}, 30_000);
return () => clearInterval(id);
}, []);
const openEdit = (m: Memory) => {
setEditTarget(m);
setEditOpen(true);

View File

@@ -7,15 +7,18 @@ import MemoryConceptCard from "./components/MemoryConceptCard";
import MemoriesTab from "./components/tabs/MemoriesTab";
import PlaygroundTab from "./components/tabs/PlaygroundTab";
import EngineTab from "./components/tabs/EngineTab";
import { useMemorySettings } from "./hooks/useMemorySettings";
type TabId = "memories" | "playground" | "engine";
const TABS: TabId[] = ["memories", "playground", "engine"];
const TABS: TabId[] = ["memories", "engine", "playground"];
function MemoryPageContent() {
const t = useTranslations("memory");
const searchParams = useSearchParams();
const router = useRouter();
const { settings, save } = useMemorySettings();
const memoryEnabled = settings?.enabled ?? true;
const rawTab = searchParams.get("tab") ?? "";
const activeTab: TabId = TABS.includes(rawTab as TabId) ? (rawTab as TabId) : "memories";
@@ -31,23 +34,44 @@ function MemoryPageContent() {
{/* Concept card */}
<MemoryConceptCard />
{/* Tab navigation */}
<div className="flex gap-1 p-1 rounded-lg bg-surface/50 border border-border/60 w-fit">
{TABS.map((tab) => (
{/* Tab navigation + memory enable toggle */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex gap-1 p-1 rounded-lg bg-surface/50 border border-border/60 w-fit">
{TABS.map((tab) => (
<button
key={tab}
type="button"
data-testid={`tab-${tab}`}
onClick={() => setTab(tab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
activeTab === tab
? "bg-bg text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
}`}
>
{t(`tabs.${tab}`)}
</button>
))}
</div>
<label className="flex items-center gap-2 cursor-pointer select-none">
<span className="text-sm font-medium text-text-muted">{t("memoryEnabled")}</span>
<button
key={tab}
type="button"
data-testid={`tab-${tab}`}
onClick={() => setTab(tab)}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
activeTab === tab
? "bg-bg text-text-main shadow-sm"
: "text-text-muted hover:text-text-main"
role="switch"
aria-checked={memoryEnabled}
data-testid="memory-enabled-toggle"
onClick={() => void save({ enabled: !memoryEnabled })}
className={`relative w-11 h-6 rounded-full transition-colors ${
memoryEnabled ? "bg-violet-500" : "bg-border"
}`}
>
{t(`tabs.${tab}`)}
<span
className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform ${
memoryEnabled ? "translate-x-5" : "translate-x-0"
}`}
/>
</button>
))}
</label>
</div>
{/* Tab content */}

View File

@@ -8,11 +8,14 @@ import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
import { endpointToPath } from "@/lib/playground/codeExport";
import PresetPicker from "./PresetPicker";
import ImprovePromptButton from "./ImprovePromptButton";
import { useProviderOptions } from "@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions";
import { useAvailableModels } from "@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels";
export interface ConfigState {
endpoint: PlaygroundEndpoint;
baseUrl: string;
model: string;
provider?: string;
systemPrompt: string;
params: PlaygroundParams;
}
@@ -46,6 +49,10 @@ const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
*/
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
const [collapsed, setCollapsed] = useState(false);
const { provider, setProvider, providerOptions, loading: loadingProviders } = useProviderOptions(
configState.provider ?? ""
);
const { availableModels, loading: loadingModels } = useAvailableModels();
function update<K extends keyof ConfigState>(key: K, value: ConfigState[K]) {
setConfigState({ ...configState, [key]: value });
@@ -108,18 +115,56 @@ export default function StudioConfigPane({ configState, setConfigState }: Studio
</select>
</div>
{/* Provider */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
Provider
</label>
<select
value={provider}
onChange={(e) => {
setProvider(e.target.value);
update("provider", e.target.value);
}}
disabled={loadingProviders}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
>
<option value="">Auto</option>
{providerOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
{/* Model */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
Model
</label>
<input
type="text"
value={configState.model}
onChange={(e) => update("model", e.target.value)}
placeholder="e.g. openai/gpt-4o"
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
{availableModels.length > 0 ? (
<select
value={configState.model}
onChange={(e) => update("model", e.target.value)}
disabled={loadingModels}
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
>
{availableModels.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input
type="text"
value={configState.model}
onChange={(e) => update("model", e.target.value)}
placeholder="e.g. openai/gpt-4o"
className="w-full text-xs bg-surface border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main"
/>
)}
</div>
{/* System prompt */}

View File

@@ -6,9 +6,8 @@ import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { useToolsBuilder } from "../../hooks/useToolsBuilder";
import { useStructuredOutput } from "../../hooks/useStructuredOutput";
import ToolsBuilder from "../ToolsBuilder";
import StructuredOutputEditor from "../StructuredOutputEditor";
import MarkdownMessage from "../MarkdownMessage";
import BuildWizard from "./build/BuildWizard";
import type { ConfigState } from "../StudioConfigPane";
interface BuildTabProps {
@@ -225,177 +224,104 @@ export default function BuildTab({ configState }: BuildTabProps) {
await runRequest(newMessages);
}
function clearConversation() {
setMessages([]);
setToolCalls([]);
setToolResultDrafts([]);
setValidationResult(null);
setPrompt("");
}
return (
<div className="flex h-full overflow-hidden">
{/* Left panel: conversation + run */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
<button
onClick={() => void handleRun()}
disabled={running || (!prompt.trim() && messages.length === 0)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
// Result area: conversation history + tool-call UI + validation badge
const resultArea = (
<div className="space-y-3">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary text-white"
: msg.role === "tool"
? "bg-yellow-500/10 border border-yellow-500/30 text-text-main"
: "bg-bg-alt border border-border text-text-main"
}`}
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
{running ? t("running") : t("runLabel")}
</button>
{messages.length > 0 && (
<button
onClick={clearConversation}
className="text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
{t("clearAll")}
</button>
)}
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-muted">
{toolsBuilder.tools.length > 0 && (
<span className="px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{toolsBuilder.tools.length} tool{toolsBuilder.tools.length !== 1 ? "s" : ""}
</span>
)}
{structuredOutput.enabled && (
<span className="px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">
JSON mode
</span>
{msg.role === "user" ? (
<span className="whitespace-pre-wrap">{msg.content}</span>
) : (
<MarkdownMessage content={msg.content} />
)}
</div>
</div>
))}
{/* Conversation history */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{messages.map((msg, idx) => (
<div key={idx} className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}>
{/* Tool call UI */}
{toolCalls.length > 0 && (
<div className="space-y-2">
{toolCalls.map((tc) => {
const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id);
return (
<div
className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${
msg.role === "user"
? "bg-primary text-white"
: msg.role === "tool"
? "bg-yellow-500/10 border border-yellow-500/30 text-text-main"
: "bg-bg-alt border border-border text-text-main"
}`}
key={tc.id}
className="border border-amber-500/40 rounded-lg p-3 bg-amber-500/5"
>
{msg.role === "user" ? (
<span className="whitespace-pre-wrap">{msg.content}</span>
) : (
<MarkdownMessage content={msg.content} />
)}
</div>
</div>
))}
{/* Tool call UI */}
{toolCalls.length > 0 && (
<div className="space-y-2">
{toolCalls.map((tc) => {
const draft = toolResultDrafts.find((d) => d.toolCallId === tc.id);
return (
<div
key={tc.id}
className="border border-amber-500/40 rounded-lg p-3 bg-amber-500/5"
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[14px] text-amber-500">
function
</span>
<code className="text-xs font-mono text-text-main">
{tc.function.name}
</code>
</div>
<pre className="text-[11px] font-mono text-text-muted bg-bg-alt rounded p-2 overflow-x-auto mb-2 whitespace-pre-wrap break-all">
{tc.function.arguments}
</pre>
<div className="flex flex-col gap-2">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
Tool result
</label>
<textarea
value={draft?.draft ?? ""}
onChange={(e) =>
setToolResultDrafts((prev) =>
prev.map((d) =>
d.toolCallId === tc.id ? { ...d, draft: e.target.value } : d,
),
)
}
rows={3}
placeholder={t("enterToolResult")}
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
/>
<button
onClick={() => void sendToolResult(tc.id)}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
>
<div className="flex items-center gap-2 mb-2">
<span className="material-symbols-outlined text-[14px] text-amber-500">
function
</span>
<code className="text-xs font-mono text-text-main">
{tc.function.name}
</code>
</div>
<pre className="text-[11px] font-mono text-text-muted bg-bg-alt rounded p-2 overflow-x-auto mb-2 whitespace-pre-wrap break-all">
{tc.function.arguments}
</pre>
<div className="flex flex-col gap-2">
<label className="text-[10px] text-text-muted uppercase tracking-wider">
Tool result
</label>
<textarea
value={draft?.draft ?? ""}
onChange={(e) =>
setToolResultDrafts((prev) =>
prev.map((d) =>
d.toolCallId === tc.id ? { ...d, draft: e.target.value } : d,
),
)
}
rows={3}
placeholder="Enter tool result…"
className="text-xs font-mono bg-bg-alt border border-border rounded px-2 py-1.5 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
/>
<button
onClick={() => void sendToolResult(tc.id)}
className="text-xs px-2.5 py-1 rounded bg-primary text-white hover:bg-primary/90 transition-colors self-start"
>
Send tool result
</button>
</div>
</div>
);
})}
</div>
)}
{/* Structured output validation */}
{validationResult != null && (
<div
className={`text-xs rounded-lg px-3 py-2 border ${
validationResult.valid
? "border-green-500/40 bg-green-500/5 text-green-600 dark:text-green-400"
: "border-destructive/40 bg-destructive/5 text-destructive"
}`}
>
{validationResult.valid ? "✅ Valid JSON schema response" : `${validationResult.error}`}
</div>
)}
Send tool result
</button>
</div>
</div>
);
})}
</div>
)}
{/* Prompt input */}
<div className="px-4 py-3 border-t border-border shrink-0">
<div className="flex items-end gap-2">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void handleRun();
}
}}
placeholder="Enter your message… (Enter to send, Shift+Enter for newline)"
rows={2}
className="flex-1 text-sm bg-surface border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-none"
/>
</div>
{/* Structured output validation */}
{validationResult != null && (
<div
className={`text-xs rounded-lg px-3 py-2 border ${
validationResult.valid
? "border-green-500/40 bg-green-500/5 text-green-600 dark:text-green-400"
: "border-destructive/40 bg-destructive/5 text-destructive"
}`}
>
{validationResult.valid ? "✅ Valid JSON schema response" : `${validationResult.error}`}
</div>
</div>
{/* Right panel: tools + structured output config */}
<div className="w-72 shrink-0 border-l border-border bg-bg-alt overflow-y-auto p-4 flex flex-col gap-6">
{/* Tools section */}
<div>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
Function calling
</h3>
<ToolsBuilder toolsBuilder={toolsBuilder} />
</div>
{/* Structured output section */}
<div>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
Structured output
</h3>
<StructuredOutputEditor structuredOutput={structuredOutput} />
</div>
</div>
)}
</div>
);
return (
<BuildWizard
toolsBuilder={toolsBuilder}
structuredOutput={structuredOutput}
running={running}
onRun={() => void handleRun()}
prompt={prompt}
setPrompt={setPrompt}
result={resultArea}
/>
);
}

View File

@@ -104,6 +104,13 @@ export default function CompareTab({ configState }: CompareTabProps) {
// Metrics trackers per column id (plain class instances, no React hooks)
const metricsTrackersRef = useRef<Map<string, ColumnMetricsTracker>>(new Map());
// User prompt input
const [prompt, setPrompt] = useState("");
// RAF throttle: pending chunk accumulator and scheduled frame id
const pendingRef = useRef<Record<string, string>>({});
const rafRef = useRef<number | null>(null);
// Input for model name when adding a column
const [newModel, setNewModel] = useState("");
const addInputRef = useRef<HTMLInputElement>(null);
@@ -127,6 +134,30 @@ export default function CompareTab({ configState }: CompareTabProps) {
return metricsTrackersRef.current.get(id)!;
}
/**
* Throttle per-column chunk updates via requestAnimationFrame.
* Accumulates all deltas that arrive within a single frame and flushes
* them with a single setColumns call, preventing hundreds of re-renders/s.
*/
function pushChunk(colId: string, delta: string) {
pendingRef.current[colId] = (pendingRef.current[colId] ?? "") + delta;
if (rafRef.current == null) {
rafRef.current = requestAnimationFrame(() => {
const snapshot = pendingRef.current;
pendingRef.current = {};
rafRef.current = null;
setColumns((prev) =>
prev.map((c) => {
const extra = snapshot[c.id];
return extra != null ? { ...c, response: c.response + extra } : c;
})
);
});
}
}
function addColumn() {
if (columns.length >= MAX_COLUMNS) return;
const model = newModel.trim() || configState.model;
@@ -179,6 +210,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
...(configState.systemPrompt
? [{ role: "system", content: configState.systemPrompt }]
: []),
{ role: "user", content: prompt },
],
};
@@ -247,10 +279,8 @@ export default function CompareTab({ configState }: CompareTabProps) {
accumulated += content;
tracker.onChunk(1);
updateColumn(col.id, {
response: accumulated,
metrics: tracker.getMetrics(),
});
// Throttled: accumulate delta and flush once per animation frame
pushChunk(col.id, content);
}
const usage = parsed["usage"] as
@@ -269,6 +299,12 @@ export default function CompareTab({ configState }: CompareTabProps) {
});
} catch (err) {
if (controller.signal.aborted) {
// Cancel any pending RAF for this column and clean up its pending delta
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
delete pendingRef.current[col.id];
updateColumn(col.id, { status: "idle" });
return;
}
@@ -303,6 +339,18 @@ export default function CompareTab({ configState }: CompareTabProps) {
return (
<div className="flex flex-col h-full">
{/* Prompt input area */}
<div className="px-4 pt-3 pb-2 border-b border-border bg-bg-alt shrink-0">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Enter your prompt here…"
rows={3}
className="w-full text-sm bg-surface border border-border rounded px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-y"
aria-label="User prompt"
/>
</div>
{/* Compare toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
{/* Run / Cancel all */}
@@ -318,7 +366,7 @@ export default function CompareTab({ configState }: CompareTabProps) {
) : (
<button
onClick={() => void runAll()}
disabled={columns.length === 0}
disabled={columns.length === 0 || !prompt.trim()}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
aria-label="Run all columns"
>

View File

@@ -0,0 +1,294 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx
import { useState } from "react";
import { useTranslations } from "next-intl";
import ToolsBuilder from "../../ToolsBuilder";
import StructuredOutputEditor from "../../StructuredOutputEditor";
import { useToolsBuilder } from "../../../hooks/useToolsBuilder";
import { useStructuredOutput } from "../../../hooks/useStructuredOutput";
type BuildMode = "tools" | "json" | "both";
interface BuildWizardProps {
toolsBuilder: ReturnType<typeof useToolsBuilder>;
structuredOutput: ReturnType<typeof useStructuredOutput>;
running: boolean;
onRun: () => void;
prompt: string;
setPrompt: (value: string) => void;
result: React.ReactNode;
}
interface StepperProps {
currentStep: 1 | 2 | 3;
}
function Stepper({ currentStep }: StepperProps) {
const t = useTranslations("playground.build");
const steps: Array<{ num: 1 | 2 | 3; label: string }> = [
{ num: 1, label: t("step1Label") },
{ num: 2, label: t("step2Label") },
{ num: 3, label: t("step3Label") },
];
return (
<div className="flex items-center gap-0 px-4 py-3 border-b border-border bg-bg-alt shrink-0">
{steps.map((step, idx) => (
<div key={step.num} className="flex items-center">
{idx > 0 && (
<div
className={`h-px w-8 mx-2 transition-colors ${
currentStep > step.num ? "bg-primary" : "bg-border"
}`}
/>
)}
<div className="flex items-center gap-1.5">
<span
className={`flex items-center justify-center w-5 h-5 rounded-full text-[11px] font-semibold transition-colors ${
currentStep === step.num
? "bg-primary text-white"
: currentStep > step.num
? "bg-primary/20 text-primary"
: "bg-border text-text-muted"
}`}
>
{currentStep > step.num ? (
<span className="material-symbols-outlined text-[12px]">check</span>
) : (
step.num
)}
</span>
<span
className={`text-xs font-medium transition-colors ${
currentStep === step.num ? "text-text-main" : "text-text-muted"
}`}
>
{step.label}
</span>
</div>
</div>
))}
</div>
);
}
interface ModeCardProps {
icon: string;
title: string;
description: string;
selected: boolean;
onClick: () => void;
}
function ModeCard({ icon, title, description, selected, onClick }: ModeCardProps) {
return (
<button
onClick={onClick}
className={`flex flex-col gap-2 p-4 rounded-xl border-2 text-left transition-all hover:shadow-sm ${
selected
? "border-primary bg-primary/5"
: "border-border bg-surface hover:border-primary/40"
}`}
>
<span className="text-2xl">{icon}</span>
<span className={`text-sm font-semibold ${selected ? "text-primary" : "text-text-main"}`}>
{title}
</span>
<span className="text-xs text-text-muted leading-relaxed">{description}</span>
</button>
);
}
export default function BuildWizard({
toolsBuilder,
structuredOutput,
running,
onRun,
prompt,
setPrompt,
result,
}: BuildWizardProps) {
const t = useTranslations("playground");
const tb = useTranslations("playground.build");
const [step, setStep] = useState<1 | 2 | 3>(1);
const [mode, setMode] = useState<BuildMode>("tools");
const includesTools = mode === "tools" || mode === "both";
const includesJson = mode === "json" || mode === "both";
function goToStep2() {
setStep(2);
}
function goToStep3() {
setStep(3);
}
function goBack() {
if (step === 2) setStep(1);
else if (step === 3) setStep(2);
}
return (
<div className="flex flex-col h-full overflow-hidden">
<Stepper currentStep={step} />
{/* Step 1: Mode picker */}
{step === 1 && (
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
<div>
<h2 className="text-base font-semibold text-text-main mb-1">{tb("step1Title")}</h2>
<p className="text-xs text-text-muted">{tb("step1Subtitle")}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<ModeCard
icon="🔧"
title={tb("modeToolsTitle")}
description={tb("modeToolsDesc")}
selected={mode === "tools"}
onClick={() => setMode("tools")}
/>
<ModeCard
icon="📋"
title={tb("modeJsonTitle")}
description={tb("modeJsonDesc")}
selected={mode === "json"}
onClick={() => setMode("json")}
/>
<ModeCard
icon="🔧"
title={tb("modeBothTitle")}
description={tb("modeBothDesc")}
selected={mode === "both"}
onClick={() => setMode("both")}
/>
</div>
<div className="flex justify-end pt-2">
<button
onClick={goToStep2}
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
>
{tb("nextButton")}
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
</button>
</div>
</div>
)}
{/* Step 2: Configure */}
{step === 2 && (
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
<div>
<h2 className="text-base font-semibold text-text-main mb-1">{tb("step2Title")}</h2>
<p className="text-xs text-text-muted">{tb("step2Subtitle")}</p>
</div>
{includesTools && (
<div>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
{t("toolsLabel")}
</h3>
<ToolsBuilder toolsBuilder={toolsBuilder} />
</div>
)}
{includesJson && (
<div>
<h3 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">
{t("structuredOutputLabel")}
</h3>
<StructuredOutputEditor structuredOutput={structuredOutput} />
</div>
)}
<div className="flex items-center justify-between pt-2">
<button
onClick={goBack}
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<span className="material-symbols-outlined text-[16px]">arrow_back</span>
{tb("backButton")}
</button>
<button
onClick={goToStep3}
className="flex items-center gap-1.5 text-sm px-4 py-2 rounded-lg bg-primary text-white hover:bg-primary/90 transition-colors"
>
{tb("nextButton")}
<span className="material-symbols-outlined text-[16px]">arrow_forward</span>
</button>
</div>
</div>
)}
{/* Step 3: Run */}
{step === 3 && (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-bg-alt shrink-0">
<button
onClick={goBack}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
{tb("backButton")}
</button>
<div className="w-px h-4 bg-border mx-1" />
<button
onClick={onRun}
disabled={running || (!prompt.trim())}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded bg-primary text-white hover:bg-primary/90 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<span className="material-symbols-outlined text-[14px]">play_arrow</span>
{running ? t("running") : tb("runButton")}
</button>
<div className="ml-auto flex items-center gap-2 text-[11px] text-text-muted">
{includesTools && toolsBuilder.tools.length > 0 && (
<span className="px-1.5 py-0.5 rounded bg-primary/10 text-primary">
{toolsBuilder.tools.length} tool{toolsBuilder.tools.length !== 1 ? "s" : ""}
</span>
)}
{includesJson && structuredOutput.enabled && (
<span className="px-1.5 py-0.5 rounded bg-green-500/10 text-green-600 dark:text-green-400">
JSON mode
</span>
)}
</div>
</div>
{/* Result area (conversation + tool-call UI + validation badge) */}
<div className="flex-1 overflow-y-auto px-4 py-3">
{result}
</div>
{/* Prompt input */}
<div className="px-4 py-3 border-t border-border shrink-0">
<div className="flex items-end gap-2">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onRun();
}
}}
placeholder={tb("promptPlaceholder")}
rows={2}
className="flex-1 text-sm bg-surface border border-border rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary text-text-main resize-none"
/>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -86,12 +86,8 @@ export default function SearchToolsTopBar({
</div>
</div>
{exportState && (
<ExportCodeModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
state={exportState}
/>
{exportOpen && exportState != null && (
<ExportCodeModal onClose={() => setExportOpen(false)} state={exportState} />
)}
</>
);

View File

@@ -5,9 +5,6 @@ import { useTranslations } from "next-intl";
import Link from "next/link";
import type { SearchProviderCatalogItem } from "@/shared/schemas/searchTools";
/** D22 — max 4 providers in parallel */
const MAX_PROVIDERS = 4;
export interface CompareResult {
provider: string;
latency: number;
@@ -15,6 +12,7 @@ export interface CompareResult {
resultCount: number;
responseSize: number;
urls: string[];
results: { title: string; url: string; snippet: string }[];
error?: string;
}
@@ -49,6 +47,17 @@ function getWorstIndex(values: number[], higherIsBetter = false): number {
: values.indexOf(Math.max(...values));
}
/** Build a map of url → count across all compare results to find overlaps */
function buildUrlCountMap(allResults: CompareResult[]): Map<string, number> {
const counts = new Map<string, number>();
for (const cr of allResults) {
for (const r of cr.results) {
if (r.url) counts.set(r.url, (counts.get(r.url) ?? 0) + 1);
}
}
return counts;
}
export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
const t = useTranslations("search");
const activeSearchProviders = providers.filter(
@@ -64,11 +73,18 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
const toggleProvider = useCallback((id: string) => {
setSelectedProviderIds((prev) => {
if (prev.includes(id)) return prev.filter((p) => p !== id);
if (prev.length >= MAX_PROVIDERS) return prev; // cap 4 (D22)
return [...prev, id];
});
}, []);
const selectAll = useCallback(() => {
setSelectedProviderIds(activeSearchProviders.map((p) => p.id));
}, [activeSearchProviders]);
const clearAll = useCallback(() => {
setSelectedProviderIds([]);
}, []);
const handleRun = useCallback(async () => {
if (!query.trim() || selectedProviderIds.length === 0) return;
setLoading(true);
@@ -82,7 +98,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
const res = await fetch("/api/v1/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, provider: providerId, max_results: 5 }),
body: JSON.stringify({ query, provider: providerId, max_results: 10 }),
});
const data = await res.json();
const latency = Date.now() - start;
@@ -95,18 +111,25 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
resultCount: 0,
responseSize: 0,
urls: [],
results: [],
error: data?.error?.message ?? `Error ${res.status}`,
} as CompareResult;
}
const respJson = JSON.stringify(data);
const rawResults = Array.isArray(data.results) ? data.results : [];
return {
provider: providerId,
latency: data.metrics?.response_time_ms ?? latency,
cost: data.usage?.search_cost_usd ?? 0,
resultCount: Array.isArray(data.results) ? data.results.length : 0,
resultCount: rawResults.length,
responseSize: respJson.length,
urls: (data.results ?? []).map((r: { url: string }) => r.url),
urls: rawResults.map((r: { url: string }) => r.url),
results: rawResults.map((r: any) => ({
title: r.title ?? r.url ?? "",
url: r.url ?? "",
snippet: r.snippet ?? r.description ?? "",
})),
} as CompareResult;
} catch (err: unknown) {
return {
@@ -116,6 +139,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
resultCount: 0,
responseSize: 0,
urls: [],
results: [],
error: err instanceof Error ? err.message : "Failed",
} as CompareResult;
}
@@ -132,6 +156,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
resultCount: 0,
responseSize: 0,
urls: [],
results: [],
error: "Request failed",
} as CompareResult),
);
@@ -146,20 +171,16 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
}
}, [query, selectedProviderIds, onMetrics]);
// Compute best/worst indices for coloring
// Compute best/worst indices for column header coloring
const validResults = results.filter((r) => !r.error);
const latencyValues = validResults.map((r) => r.latency);
const costValues = validResults.map((r) => r.cost);
const sizeValues = validResults.map((r) => r.responseSize);
const countValues = validResults.map((r) => r.resultCount);
function getCellClass(resultIndex: number, values: number[], higherIsBetter = false): string {
const validIndex = validResults.findIndex((r) => r === results[resultIndex]);
if (validIndex < 0) return "text-error";
if (validIndex === getBestIndex(values, higherIsBetter)) return "text-success font-medium";
if (validIndex === getWorstIndex(values, higherIsBetter)) return "text-warning";
return "text-text-main";
}
const bestLatencyProvider = validResults[getBestIndex(latencyValues, false)]?.provider;
const bestCostProvider = validResults[getBestIndex(costValues, false)]?.provider;
// URL overlap map: url → number of providers that returned it
const urlCountMap = buildUrlCountMap(results);
if (activeSearchProviders.length === 0) {
return (
@@ -183,7 +204,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
return (
<div className="flex flex-col h-full p-4 space-y-4" data-testid="compare-tab">
{/* Query input */}
{/* Query + provider picker */}
<div className="bg-surface border border-border rounded-lg p-4 space-y-3">
<label
htmlFor="compare-query"
@@ -214,15 +235,32 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
</button>
</div>
{/* Provider picker — max 4 (D22) */}
{/* Provider picker — no cap */}
<div>
<p className="text-[10px] text-text-muted mb-2">
Providers ({selectedProviderIds.length}/{MAX_PROVIDERS}):
</p>
<div className="flex items-center justify-between mb-2">
<p className="text-[10px] text-text-muted">
Providers ({selectedProviderIds.length} selected):
</p>
<div className="flex gap-2">
<button
className="text-[10px] px-2 py-0.5 rounded border border-border text-text-muted hover:text-text-main hover:border-primary/30 transition-colors"
onClick={selectAll}
data-testid="select-all-providers"
>
Select all
</button>
<button
className="text-[10px] px-2 py-0.5 rounded border border-border text-text-muted hover:text-text-main hover:border-primary/30 transition-colors"
onClick={clearAll}
data-testid="clear-providers"
>
Clear
</button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{activeSearchProviders.map((p) => {
const selected = selectedProviderIds.includes(p.id);
const atCap = selectedProviderIds.length >= MAX_PROVIDERS && !selected;
return (
<button
key={p.id}
@@ -230,12 +268,9 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors border",
selected
? "bg-primary/15 text-primary border-primary/30"
: atCap
? "text-text-muted border-border opacity-50 cursor-not-allowed"
: "text-text-muted border-border hover:text-text-main hover:border-primary/30",
: "text-text-muted border-border hover:text-text-main hover:border-primary/30",
].join(" ")}
onClick={() => !atCap && toggleProvider(p.id)}
disabled={atCap}
onClick={() => toggleProvider(p.id)}
data-testid={`provider-toggle-${p.id}`}
aria-pressed={selected}
>
@@ -244,11 +279,6 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
);
})}
</div>
{selectedProviderIds.length >= MAX_PROVIDERS && (
<p className="text-[10px] text-warning mt-1">
Máximo de {MAX_PROVIDERS} providers atingido (D22)
</p>
)}
</div>
</div>
@@ -264,7 +294,7 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
</div>
)}
{/* Results table */}
{/* Layout A — side-by-side columns */}
{hasRun && !loading && results.length > 0 && (
<div
className="bg-surface border border-border rounded-lg overflow-hidden"
@@ -272,87 +302,117 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
>
<div className="px-4 py-2.5 bg-bg-alt border-b border-border">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Resultados &ldquo;{query}&rdquo;
Results &ldquo;{query}&rdquo;
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border">
<th className="text-left p-2 text-text-muted font-semibold w-24" />
{results.map((r) => (
<th
key={r.provider}
className="text-center p-2 font-semibold text-text-muted"
>
{r.provider.replace("-search", "")}
</th>
))}
</tr>
</thead>
<tbody>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">Latência</td>
{results.map((r, i) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, latencyValues, false)}`}
>
{r.error ? "Erro" : `${r.latency}ms`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">Custo</td>
{results.map((r, i) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, costValues, false)}`}
>
{r.error ? "Erro" : `$${r.cost.toFixed(4)}`}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">Resultados</td>
{results.map((r, i) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, countValues, true)}`}
>
{r.error ? "Erro" : r.resultCount}
</td>
))}
</tr>
<tr className="border-b border-border/50">
<td className="p-2 text-text-muted">{t("size")}</td>
{results.map((r, i) => (
<td
key={r.provider}
className={`text-center p-2 ${r.error ? "text-error" : getCellClass(i, sizeValues, false)}`}
>
{r.error ? "Erro" : formatBytes(r.responseSize)}
</td>
))}
</tr>
<tr>
<td className="p-2 text-text-muted">URL overlap</td>
{results.map((r, idx) => {
const baseUrls = results[0]?.urls ?? [];
return (
<td key={r.provider} className="text-center p-2 text-text-main">
{r.error
? "Erro"
: idx === 0
? "—"
: computeOverlap(baseUrls, r.urls)}
</td>
);
})}
</tr>
</tbody>
</table>
<div className="flex gap-3 p-3" style={{ minWidth: `${results.length * 296}px` }}>
{results.map((cr) => {
const isBestLatency = !cr.error && cr.provider === bestLatencyProvider;
const isBestCost = !cr.error && cr.provider === bestCostProvider;
return (
<div
key={cr.provider}
className="min-w-[280px] w-[280px] shrink-0 flex flex-col rounded-lg border border-border bg-surface overflow-hidden"
data-testid={`compare-col-${cr.provider}`}
>
{/* Column header */}
<div className="px-3 py-2 bg-bg-alt border-b border-border">
<p className="text-xs font-semibold text-text-main truncate mb-1">
{cr.provider.replace("-search", "")}
</p>
{cr.error ? (
<p className="text-[10px] text-red-400 truncate">{cr.error}</p>
) : (
<div className="flex flex-wrap gap-x-2 gap-y-0.5 text-[10px] text-text-muted">
<span className={isBestLatency ? "text-emerald-400 font-medium" : ""}>
{cr.latency}ms
</span>
<span className={isBestCost ? "text-emerald-400 font-medium" : ""}>
${cr.cost.toFixed(4)}
</span>
<span>{cr.resultCount} results</span>
<span>{formatBytes(cr.responseSize)}</span>
</div>
)}
</div>
{/* Results list */}
<div className="flex flex-col divide-y divide-border overflow-y-auto max-h-[600px]">
{cr.error ? (
<div className="p-3">
<p className="text-xs text-red-400">{cr.error}</p>
</div>
) : cr.results.length === 0 ? (
<div className="p-3">
<p className="text-xs text-text-muted">No results</p>
</div>
) : (
cr.results.map((r, idx) => {
const isShared = (urlCountMap.get(r.url) ?? 0) > 1;
return (
<div key={idx} className="p-3 space-y-0.5">
<div className="flex items-start gap-1">
{isShared && (
<span
className="text-emerald-400 text-[11px] mt-0.5 shrink-0"
title="in common with another provider"
aria-label="in common"
>
</span>
)}
<a
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-sm text-text-main hover:text-primary leading-snug"
>
{r.title || r.url}
</a>
</div>
{r.snippet && (
<p className="text-xs text-text-muted line-clamp-2 leading-relaxed">
{r.snippet}
</p>
)}
<p className="text-[10px] text-text-muted truncate">{r.url}</p>
</div>
);
})
)}
</div>
</div>
);
})}
</div>
</div>
{/* Overlap summary footer */}
{results.length >= 2 && (
<div className="px-4 py-2 bg-bg-alt border-t border-border">
<div className="flex flex-wrap gap-3 text-[10px] text-text-muted">
{results.slice(1).map((cr) => {
const baseUrls = results[0]?.urls ?? [];
return (
<span key={cr.provider}>
<span className="font-medium text-text-main">
{results[0]?.provider.replace("-search", "")}
</span>
{" vs "}
<span className="font-medium text-text-main">
{cr.provider.replace("-search", "")}
</span>
{": "}
{cr.error ? "—" : computeOverlap(baseUrls, cr.urls)}{" "}
in common
</span>
);
})}
</div>
</div>
)}
</div>
)}
@@ -364,10 +424,10 @@ export default function CompareTab({ providers, onMetrics }: CompareTabProps) {
>
<span className="text-3xl mb-3" aria-hidden="true"></span>
<p className="text-sm text-text-muted mb-1">
Selecione até {MAX_PROVIDERS} providers e insira uma query
Select providers and enter a query to compare
</p>
<p className="text-xs text-text-muted">
Os resultados serão comparados lado a lado com latência, custo e overlap de URLs
Results will be shown side by side with latency, cost, and URL overlap
</p>
</div>
)}

View File

@@ -8,7 +8,7 @@ import { AgentBridgeServerCard } from "./components/AgentBridgeServerCard";
import { AgentList } from "./components/AgentList";
import { EmptyStateNoProviders } from "./components/EmptyStateNoProviders";
import { useAgentBridgeState } from "./hooks/useAgentBridgeState";
import type { MitmTarget } from "@/mitm/types";
import type { MitmTargetView } from "@/mitm/types";
import type { MappingRow } from "./components/ModelMappingTable";
// ── Types ────────────────────────────────────────────────────────────────────
@@ -43,7 +43,7 @@ export interface AgentBridgePageData {
interface AgentBridgePageClientProps {
initialData: AgentBridgePageData;
targets: MitmTarget[];
targets: MitmTargetView[];
hasProviders: boolean;
}

View File

@@ -7,7 +7,7 @@ import { DnsStatusBadge } from "./shared/DnsStatusBadge";
import { ModelMappingTable } from "./ModelMappingTable";
import { SetupWizard } from "./SetupWizard";
import { RiskNoticeModal } from "@/shared/components/RiskNoticeModal";
import type { MitmTarget } from "@/mitm/types";
import type { MitmTargetView } from "@/mitm/types";
import type { AgentStateEntry } from "../AgentBridgePageClient";
import type { MappingRow } from "./ModelMappingTable";
@@ -23,7 +23,7 @@ function hasAcceptedRisk(agentId: string): boolean {
interface AgentCardProps {
target: MitmTarget;
target: MitmTargetView;
agentState: AgentStateEntry | undefined;
serverRunning: boolean;
mappings: MappingRow[];

View File

@@ -3,12 +3,12 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
import { AgentCard } from "./AgentCard";
import type { MitmTarget } from "@/mitm/types";
import type { MitmTargetView } from "@/mitm/types";
import type { AgentStateEntry, AgentMappingsMap } from "../AgentBridgePageClient";
import type { MappingRow } from "./ModelMappingTable";
interface AgentListProps {
targets: MitmTarget[];
targets: MitmTargetView[];
agentStates: AgentStateEntry[];
serverRunning: boolean;
mappingsMap: AgentMappingsMap;

View File

@@ -3,10 +3,10 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import type { AgentStateEntry } from "../AgentBridgePageClient";
import type { MitmTarget } from "@/mitm/types";
import type { MitmTargetView } from "@/mitm/types";
interface SetupWizardProps {
target: MitmTarget;
target: MitmTargetView;
agentState: AgentStateEntry | undefined;
serverRunning: boolean;
onClose: () => void;

View File

@@ -54,7 +54,7 @@ export default async function AgentBridgePage() {
return (
<AgentBridgePageClient
initialData={initialData}
targets={ALL_TARGETS}
targets={ALL_TARGETS.map(({ handler, ...rest }) => rest)}
hasProviders={hasProviders}
/>
);

View File

@@ -1283,7 +1283,45 @@
"a2aNoTasks": "No A2A tasks recorded.",
"a2aLoadingTasks": "Loading A2A tasks...",
"actor": "Actor",
"actorPlaceholder": "Filter by actor"
"actorPlaceholder": "Filter by actor",
"eventTypes": {
"apiKey.activate": "API Key Activated",
"apiKey.ban": "API Key Banned",
"apiKey.deactivate": "API Key Deactivated",
"apiKey.regenerate": "API Key Regenerated",
"apiKey.scopes.grant": "API Key Scopes Granted",
"apiKey.scopes.revoke": "API Key Scopes Revoked",
"apiKey.scopes.update": "API Key Scopes Updated",
"apiKey.unban": "API Key Unbanned",
"auth.login.error": "Login Error",
"auth.login.failed": "Login Failed",
"auth.login.locked": "Login Locked",
"auth.login.misconfigured": "Login Misconfigured",
"auth.login.setup_required": "Login Setup Required",
"auth.login.success": "Login Success",
"auth.logout.success": "Logout Success",
"compliance.cleanup": "Compliance Cleanup",
"provider.credentials.applied": "Provider Credentials Applied",
"provider.credentials.batch_revoked": "Provider Credentials Batch Revoked",
"provider.credentials.bulk_created": "Provider Credentials Bulk Created",
"provider.credentials.bulk_imported": "Provider Credentials Bulk Imported",
"provider.credentials.created": "Provider Credentials Created",
"provider.credentials.imported": "Provider Credentials Imported",
"provider.credentials.revoked": "Provider Credentials Revoked",
"provider.credentials.updated": "Provider Credentials Updated",
"provider.validation.ssrf_blocked": "Provider SSRF Blocked",
"quota.plan.updated": "Quota Plan Updated",
"quota.pool.created": "Quota Pool Created",
"quota.pool.deleted": "Quota Pool Deleted",
"quota.pool.updated": "Quota Pool Updated",
"quota.store.driver_changed": "Quota Store Driver Changed",
"server.start": "Server Start",
"service.reveal_api_key": "Service API Key Revealed",
"settings.update": "Settings Updated",
"settings.update_failed": "Settings Update Failed",
"sync.token.created": "Sync Token Created",
"sync.token.revoked": "Sync Token Revoked"
}
},
"themesPage": {
"title": "Themes",
@@ -3254,7 +3292,8 @@
"qdrantOk": "Healthy ({latencyMs}ms)",
"qdrantError": "Connection error",
"needsReindex": "{count} memory(ies) need reindexing",
"configureCta": "Configure"
"configureCta": "Configure",
"vectorStoreInstallHint": "To enable vector search: npm install sqlite-vec (needs native Node.js, not WASM), then restart."
},
"episodic": "Episodic",
"export": "Export",
@@ -3365,7 +3404,8 @@
"semantic": "Concepts, preferences, and domain knowledge"
},
"totalEntries": "Total Entries",
"type": "Type"
"type": "Type",
"memoryEnabled": "Memory enabled"
},
"skills": {
"title": "Skills",
@@ -5643,7 +5683,11 @@
"vercelRelayProjectNameLabel": "Vercel Project Name",
"vercelRelayFreeTierNote": "Relays are lightweight proxy endpoints deployed on Vercel's free tier to bypass local network/region limitations.",
"vercelRelayDeploying": "Deploying...",
"vercelRelayDeploy": "Deploy"
"vercelRelayDeploy": "Deploy",
"proxyGlobalConfigTab": "Global Config",
"proxyPoolTab": "Proxy Pool",
"freePoolTab": "Free Pool",
"proxyDocumentationTab": "Documentation"
},
"contextRtk": {
"title": "RTK Engine",
@@ -7500,7 +7544,27 @@
"invalidJson": "Invalid JSON",
"running": "Running…",
"runLabel": "Run",
"enterToolResult": "Enter tool result…"
"enterToolResult": "Enter tool result…",
"build": {
"step1Label": "What to test?",
"step2Label": "Configure",
"step3Label": "Run",
"step1Title": "What do you want to test?",
"step1Subtitle": "Choose the capability you want to explore in this session.",
"step2Title": "Configure",
"step2Subtitle": "Set up the tools or JSON schema that will be used in the request.",
"step3Title": "Run",
"modeToolsTitle": "Tools",
"modeToolsDesc": "Test function calling — define tools and see how the model invokes them.",
"modeJsonTitle": "JSON",
"modeJsonDesc": "Test structured output — constrain the response to a JSON schema.",
"modeBothTitle": "Tools + JSON",
"modeBothDesc": "Combine function calling and structured output in a single request.",
"backButton": "Back",
"nextButton": "Next",
"runButton": "Run",
"promptPlaceholder": "Enter your message… (Enter to send, Shift+Enter for newline)"
}
},
"miniPlayground": {
"endpoint": "Endpoint",

View File

@@ -2465,7 +2465,45 @@
"a2aNoTasks": "Nenhuma tarefa A2A registrada.",
"a2aLoadingTasks": "Carregando tarefas A2A...",
"actor": "Autor",
"actorPlaceholder": "Filtrar por autor"
"actorPlaceholder": "Filtrar por autor",
"eventTypes": {
"apiKey.activate": "API Key Ativada",
"apiKey.ban": "API Key Banida",
"apiKey.deactivate": "API Key Desativada",
"apiKey.regenerate": "API Key Regenerada",
"apiKey.scopes.grant": "Escopos de API Key Concedidos",
"apiKey.scopes.revoke": "Escopos de API Key Revogados",
"apiKey.scopes.update": "Escopos de API Key Atualizados",
"apiKey.unban": "API Key Desbanida",
"auth.login.error": "Erro de Login",
"auth.login.failed": "Login Falhou",
"auth.login.locked": "Login Bloqueado",
"auth.login.misconfigured": "Login Mal Configurado",
"auth.login.setup_required": "Configuração de Login Necessária",
"auth.login.success": "Login com Sucesso",
"auth.logout.success": "Logout com Sucesso",
"compliance.cleanup": "Limpeza de Conformidade",
"provider.credentials.applied": "Credenciais do Provedor Aplicadas",
"provider.credentials.batch_revoked": "Credenciais do Provedor Revogadas em Lote",
"provider.credentials.bulk_created": "Credenciais do Provedor Criadas em Massa",
"provider.credentials.bulk_imported": "Credenciais do Provedor Importadas em Massa",
"provider.credentials.created": "Credenciais do Provedor Criadas",
"provider.credentials.imported": "Credenciais do Provedor Importadas",
"provider.credentials.revoked": "Credenciais do Provedor Revogadas",
"provider.credentials.updated": "Credenciais do Provedor Atualizadas",
"provider.validation.ssrf_blocked": "SSRF do Provedor Bloqueado",
"quota.plan.updated": "Plano de Cota Atualizado",
"quota.pool.created": "Pool de Cota Criado",
"quota.pool.deleted": "Pool de Cota Excluído",
"quota.pool.updated": "Pool de Cota Atualizado",
"quota.store.driver_changed": "Driver do Store de Cota Alterado",
"server.start": "Servidor Iniciado",
"service.reveal_api_key": "API Key do Serviço Revelada",
"settings.update": "Configurações Atualizadas",
"settings.update_failed": "Falha ao Atualizar Configurações",
"sync.token.created": "Token de Sincronização Criado",
"sync.token.revoked": "Token de Sincronização Revogado"
}
},
"contextCaveman": {
"title": "Motor Caveman",
@@ -3841,7 +3879,8 @@
"qdrantOk": "Saudável ({latencyMs}ms)",
"qdrantError": "Erro de conexão",
"needsReindex": "{count} memória(s) precisam de reindexação",
"configureCta": "Configurar"
"configureCta": "Configurar",
"vectorStoreInstallHint": "Para ativar a busca vetorial: npm install sqlite-vec (requer Node.js nativo, não WASM) e reinicie."
},
"episodic": "Episódica",
"export": "Exportar",
@@ -3952,7 +3991,8 @@
"semantic": "Conceitos, preferências e conhecimento de domínio"
},
"totalEntries": "Total de Entradas",
"type": "Tipo"
"type": "Tipo",
"memoryEnabled": "Memória ativada"
},
"miniPlayground": {
"endpoint": "Endpoint",
@@ -4261,7 +4301,27 @@
"invalidJson": "JSON inválido",
"running": "Executando…",
"runLabel": "Executar",
"enterToolResult": "Insira o resultado da ferramenta…"
"enterToolResult": "Insira o resultado da ferramenta…",
"build": {
"step1Label": "O que testar?",
"step2Label": "Configurar",
"step3Label": "Rodar",
"step1Title": "O que você quer testar?",
"step1Subtitle": "Escolha a funcionalidade que deseja explorar nesta sessão.",
"step2Title": "Configurar",
"step2Subtitle": "Configure as ferramentas ou o esquema JSON que serão usados na requisição.",
"step3Title": "Rodar",
"modeToolsTitle": "Ferramentas",
"modeToolsDesc": "Teste function calling — defina ferramentas e veja como o modelo as invoca.",
"modeJsonTitle": "JSON",
"modeJsonDesc": "Teste saída estruturada — restrinja a resposta a um esquema JSON.",
"modeBothTitle": "Ferramentas + JSON",
"modeBothDesc": "Combine function calling e saída estruturada em uma única requisição.",
"backButton": "Voltar",
"nextButton": "Próximo",
"runButton": "Executar",
"promptPlaceholder": "Digite sua mensagem… (Enter para enviar, Shift+Enter para nova linha)"
}
},
"pricingModal": {
"title": "Configuração de preços",
@@ -6628,7 +6688,11 @@
"homeQuickStart": "Quick Start",
"homeQuickStartDesc": "Show the Quick Start panel on the Home page.",
"homeProviderTopology": "Provider Topology",
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page."
"homeProviderTopologyDesc": "Show the Provider Topology on the Home page.",
"proxyGlobalConfigTab": "Configuração Global",
"proxyPoolTab": "Pool de Proxy",
"freePoolTab": "Pool Gratuito",
"proxyDocumentationTab": "Documentação"
},
"sidebar": {
"home": "Início",

View File

@@ -38,6 +38,13 @@ export interface MitmTarget {
viability?: "investigating" | "supported" | "deprecated"; // Trae = "investigating"
}
/**
* Serializable view of a MitmTarget for Server→Client Component props.
* Omits `handler` (a function): Next.js forbids passing functions across the
* Server/Client boundary, and the UI never invokes it. See agent-bridge/page.tsx.
*/
export type MitmTargetView = Omit<MitmTarget, "handler">;
export const MitmTargetSchema = z.object({
id: z.enum([
"antigravity", "kiro", "copilot", "codex", "cursor", "zed",

View File

@@ -13,12 +13,22 @@ import {
isLocalOnlyBypassableByManageScope,
isLocalOnlyPath,
isLoopbackHost,
isPrivateLanHost,
} from "../routeGuard";
const MODEL_SYNC_MANAGEMENT_PATH = /^\/api\/providers\/[^/]+\/(sync-models|models)$/;
function requestPeerAddress(ctx: PolicyContext): string | null {
return ctx.request.ip || ctx.request.socket?.remoteAddress || null;
// In the Next middleware runtime (proxy.ts → runAuthzPipeline), ctx.request is
// a NextRequest with no socket/.ip, so the only locality signal is the Host
// header — which is exactly what isLoopbackHost/isPrivateLanHost parse (they
// strip :port). This both fixes the loopback gate (previously the null socket
// made every LOCAL_ONLY request 403, even from localhost) and enables the
// owner-authorized private-LAN carve-out. Fall back to .ip/.socket for any
// non-middleware caller that provides them. Spawn-capable endpoints still
// require manage-scope auth after this gate.
const hostHeader = ctx.request.headers?.get?.("host") ?? null;
return hostHeader || ctx.request.ip || ctx.request.socket?.remoteAddress || null;
}
function isLoopbackRequest(ctx: PolicyContext): boolean {
@@ -26,6 +36,14 @@ function isLoopbackRequest(ctx: PolicyContext): boolean {
return peerAddress ? isLoopbackHost(peerAddress) : false;
}
// Owner-authorized (2026-05-30): allow LOCAL_ONLY *paths* from a trusted private
// LAN, based on the real socket peer IP (not spoofable). Does NOT relax the
// CLI-token gate, which stays strictly loopback.
function isPrivateLanRequest(ctx: PolicyContext): boolean {
const peerAddress = requestPeerAddress(ctx);
return peerAddress ? isPrivateLanHost(peerAddress) : false;
}
function hasValidCliToken(ctx: PolicyContext): boolean {
if (!isLoopbackRequest(ctx)) return false;
const headers = ctx.request.headers;
@@ -70,7 +88,7 @@ export const managementPolicy: RoutePolicy = {
//
// Anonymous (no Bearer / invalid key / wrong scope / no session) requests
// still hit the same 403 LOCAL_ONLY they did before.
if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx)) {
if (isLocalOnlyPath(path) && !isLoopbackRequest(ctx) && !isPrivateLanRequest(ctx)) {
if (isLocalOnlyBypassableByManageScope(path)) {
const apiKey = extractApiKey(ctx.request as unknown as Request);
if (apiKey) {

View File

@@ -89,6 +89,39 @@ export function isLoopbackHost(hostHeader: string | null): boolean {
return LOOPBACK_HOSTS.has(host.toLowerCase());
}
/**
* Private-LAN ranges (RFC 1918 IPv4 + IPv6 ULA/link-local). Matched against the
* real socket peer address (NOT the spoofable Host header), so a public-internet
* client — which presents a public source IP — never matches.
*/
const PRIVATE_LAN_PATTERNS: ReadonlyArray<RegExp> = [
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/,
/^192\.168\.\d{1,3}\.\d{1,3}$/,
/^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/,
/^f[cd][0-9a-f]{2}:/i, // IPv6 ULA fc00::/7
/^fe80:/i, // IPv6 link-local
];
/**
* True when the peer address is a private-LAN address. Used to widen the
* LOCAL_ONLY tier to a trusted private network (owner-authorized 2026-05-30 for
* a LAN-deployed instance). Loopback-only surfaces that do NOT use this (e.g.
* the CLI-token path) remain strictly loopback.
*/
export function isPrivateLanHost(hostHeader: string | null): boolean {
if (!hostHeader) return false;
let host = hostHeader.trim();
if (host.startsWith("[")) {
const bracketEnd = host.indexOf("]");
host = bracketEnd >= 0 ? host.slice(1, bracketEnd) : host.slice(1);
}
host = host.replace(/^::ffff:/i, "");
// Strip :port only for IPv4 / hostname (a lone colon); leave IPv6 intact.
if ((host.match(/:/g) || []).length === 1) host = host.split(":")[0];
host = host.toLowerCase();
return PRIVATE_LAN_PATTERNS.some((re) => re.test(host));
}
export function isLocalOnlyPath(path: string): boolean {
return LOCAL_ONLY_API_PREFIXES.some((p) => path === p || path.startsWith(p));
}

View File

@@ -30,6 +30,7 @@ export default function Select({
className,
selectClassName,
id: externalId,
children,
...props
}: SelectProps) {
const generatedId = useId();
@@ -71,14 +72,18 @@ export default function Select({
)}
{...props}
>
<option value="" disabled className="bg-surface text-text-muted">
{placeholder}
</option>
{options.map((option) => (
<option key={option.value} value={option.value} className="bg-surface text-text-main">
{option.label}
{!children && placeholder && (
<option value="" disabled className="bg-surface text-text-muted">
{placeholder}
</option>
))}
)}
{!children &&
options.map((option) => (
<option key={option.value} value={option.value} className="bg-surface text-text-main">
{option.label}
</option>
))}
{children}
</select>
<div
className="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none text-text-muted"

View File

@@ -0,0 +1,33 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ALL_TARGETS } from "../../src/mitm/targets/index.ts";
// Regression guard for the agent-bridge "erro ao carregar" bug.
//
// `agent-bridge/page.tsx` is a Server Component that passes `targets` to the
// `AgentBridgePageClient` Client Component. Each MitmTarget carries a
// `handler: () => Promise<...>` function. Next.js forbids passing functions
// across the Server/Client boundary, raising at runtime:
// "Functions cannot be passed directly to Client Components ..."
// which broke SSR for the whole page. The fix sanitizes the array via
// ALL_TARGETS.map(({ handler, ...rest }) => rest)
// (a MitmTargetView). These tests pin both halves of that contract.
test("agent-bridge: sanitized targets (no handler) are fully serializable for Client Components", () => {
const views = ALL_TARGETS.map(({ handler, ...rest }) => rest);
assert.ok(views.length > 0, "expected at least one MITM target");
for (const v of views) {
const id = (v as { id?: string }).id ?? "<unknown>";
assert.equal("handler" in v, false, `${id}: handler must be stripped before crossing to a Client Component`);
for (const [key, value] of Object.entries(v)) {
assert.notEqual(typeof value, "function", `${id}.${key} must not be a function (non-serializable)`);
}
assert.doesNotThrow(() => JSON.parse(JSON.stringify(v)), `${id} must be JSON-serializable`);
}
});
test("agent-bridge: raw ALL_TARGETS still carry a handler function (so the sanitization is required)", () => {
for (const t of ALL_TARGETS) {
assert.equal(typeof t.handler, "function", `${t.id} should expose a lazy handler function`);
}
});

View File

@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
const root = join(import.meta.dirname, "../..");
const read = (p: string) => readFileSync(join(root, p), "utf8");
const en = JSON.parse(read("src/i18n/messages/en.json"));
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
test("audit: compliance.eventTypes exists with en/pt-BR parity and key coverage", () => {
const enKeys = Object.keys(en.compliance?.eventTypes ?? {});
const ptKeys = Object.keys(pt.compliance?.eventTypes ?? {});
assert.ok(enKeys.length >= 30, `expected >=30 event-type labels, got ${enKeys.length}`);
assert.deepEqual(enKeys.sort(), ptKeys.sort(), "en/pt-BR eventTypes keys must match");
for (const k of ["provider.credentials.created", "auth.login.success", "quota.pool.created", "sync.token.revoked"]) {
assert.ok(en.compliance.eventTypes[k], `en missing eventTypes.${k}`);
assert.ok(pt.compliance.eventTypes[k], `pt-BR missing eventTypes.${k}`);
}
});
test("audit: ComplianceTab translates action and A2aAuditTab translates task state", () => {
const ct = read("src/app/(dashboard)/dashboard/audit/ComplianceTab.tsx");
const a2a = read("src/app/(dashboard)/dashboard/audit/A2aAuditTab.tsx");
assert.ok(ct.includes("eventTypes.${entry.action}"), "ComplianceTab uses eventTypes i18n lookup");
assert.ok(a2a.includes("a2aState${task.state"), "A2aAuditTab uses a2aState i18n lookup");
});

View File

@@ -0,0 +1,57 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { isPrivateLanHost, isLoopbackHost, isLocalOnlyPath } from "../../src/server/authz/routeGuard.ts";
test("isPrivateLanHost: accepts RFC1918 IPv4 (incl. :port and ::ffff: mapped)", () => {
for (const h of [
"192.168.0.15",
"192.168.0.15:54321",
"10.0.0.5",
"172.16.0.9",
"172.31.255.254",
"::ffff:192.168.1.20",
]) {
assert.equal(isPrivateLanHost(h), true, `expected private-LAN: ${h}`);
}
});
test("isPrivateLanHost: accepts IPv6 ULA / link-local", () => {
assert.equal(isPrivateLanHost("fd12:3456::1"), true);
assert.equal(isPrivateLanHost("fe80::1"), true);
});
test("isPrivateLanHost: rejects public IPs, loopback and junk", () => {
for (const h of [
"8.8.8.8",
"69.164.221.35", // public VPS
"172.32.0.1", // just outside 172.16/12
"127.0.0.1",
"::1",
"example.com",
"",
null,
]) {
assert.equal(isPrivateLanHost(h), false, `expected NOT private-LAN: ${h}`);
}
});
test("isLoopbackHost stays loopback-only (unchanged)", () => {
assert.equal(isLoopbackHost("127.0.0.1"), true);
assert.equal(isLoopbackHost("localhost:20128"), true);
assert.equal(isLoopbackHost("192.168.0.15"), false);
});
test("services + traffic-inspector remain LOCAL_ONLY paths", () => {
assert.equal(isLocalOnlyPath("/api/services/9router/status"), true);
assert.equal(isLocalOnlyPath("/api/tools/traffic-inspector/sessions"), true);
});
test("management policy derives locality from the Host header (middleware socket is null)", () => {
const src = readFileSync(
join(import.meta.dirname, "../../src/server/authz/policies/management.ts"),
"utf8"
);
assert.ok(src.includes('headers?.get?.("host")'), "requestPeerAddress must read the Host header");
});

View File

@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Regression guards for v3.8.8 screen-fix Phase 1 (quick wins).
const root = join(import.meta.dirname, "../..");
const read = (p: string) => readFileSync(join(root, p), "utf8");
test("search-tools: export modal mounts on exportOpen (not by default) without invalid isOpen prop", () => {
const src = read("src/app/(dashboard)/dashboard/search-tools/components/SearchToolsTopBar.tsx");
assert.ok(src.includes("{exportOpen && exportState != null && ("), "modal guarded by exportOpen");
assert.equal(/<ExportCodeModal[^>]*\bisOpen=/.test(src), false, "no invalid isOpen prop on ExportCodeModal");
});
test("memory: tabs ordered memories -> engine -> playground", () => {
const src = read("src/app/(dashboard)/dashboard/memory/page.tsx");
assert.ok(src.includes('["memories", "engine", "playground"]'), "TABS order is memories, engine, playground");
});
test("shared Select: renders children and guards placeholder/options when children present", () => {
const src = read("src/shared/components/Select.tsx");
assert.ok(src.includes("{children}"), "renders children passed by callers");
assert.ok(src.includes("!children && placeholder"), "placeholder guarded by !children");
assert.ok(src.includes("!children &&\n options.map") || src.includes("!children &&"), "options guarded by !children");
});
test("logs: proxy/console tabs removed (dedicated menu pages exist)", () => {
const src = read("src/app/(dashboard)/dashboard/logs/page.tsx");
assert.equal(/value:\s*"proxy-logs"/.test(src), false, "proxy-logs tab removed");
assert.equal(/value:\s*"console"/.test(src), false, "console tab removed");
assert.equal(src.includes("<ProxyLogger"), false, "ProxyLogger not rendered here");
assert.equal(src.includes("<ConsoleLogViewer"), false, "ConsoleLogViewer not rendered here");
assert.equal(src.includes("SegmentedControl"), false, "SegmentedControl removed (single tab left)");
});

View File

@@ -0,0 +1,37 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Regression guards for v3.8.8 Memory screen fixes (Phase 3).
const root = join(import.meta.dirname, "../..");
const read = (p: string) => readFileSync(join(root, p), "utf8");
const en = JSON.parse(read("src/i18n/messages/en.json"));
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
test("memory: health auto-checks on mount + 30s polling", () => {
const src = read("src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx");
assert.ok(src.includes("void checkHealth();"), "calls checkHealth from an effect");
assert.ok(src.includes("setInterval"), "polls health periodically");
});
test("memory: page wires enable/disable toggle via useMemorySettings", () => {
const src = read("src/app/(dashboard)/dashboard/memory/page.tsx");
assert.ok(src.includes("useMemorySettings"), "uses the settings hook");
assert.ok(/role="switch"/.test(src), "renders a switch control");
assert.ok(src.includes("save({ enabled:"), "persists enabled via save()");
});
test("memory: vector store shows install hint when backend is none", () => {
const src = read("src/app/(dashboard)/dashboard/memory/components/MemoryEngineStatus.tsx");
assert.ok(src.includes('status.vectorStore.backend === "none"'), "branches on backend none");
assert.ok(src.includes("engine.vectorStoreInstallHint"), "renders the install-hint key");
});
test("memory i18n: memoryEnabled + engine.vectorStoreInstallHint present in en + pt-BR", () => {
assert.ok(en.memory?.memoryEnabled && pt.memory?.memoryEnabled, "memoryEnabled in both locales");
assert.ok(
en.memory?.engine?.vectorStoreInstallHint && pt.memory?.engine?.vectorStoreInstallHint,
"vectorStoreInstallHint in both locales"
);
});

View File

@@ -0,0 +1,41 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Regression guards for v3.8.8 Playground screen fixes (Phase 4).
const root = join(import.meta.dirname, "../..");
const read = (p: string) => readFileSync(join(root, p), "utf8");
test("playground config: adds Provider + Model selects reusing translator hooks", () => {
const src = read("src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx");
assert.ok(src.includes("useProviderOptions"), "reuses useProviderOptions");
assert.ok(src.includes("useAvailableModels"), "reuses useAvailableModels");
assert.ok(src.includes('update("provider"'), "writes provider into ConfigState");
assert.ok(src.includes("provider?: string"), "ConfigState gains provider");
});
test("playground compare: prompt input + rAF throttle + user message in request", () => {
const src = read("src/app/(dashboard)/dashboard/playground/components/tabs/CompareTab.tsx");
assert.ok(src.includes("requestAnimationFrame"), "throttles stream updates via rAF");
assert.ok(/role:\s*"user"/.test(src), "request body includes a user message");
assert.ok(src.includes("setPrompt"), "has a prompt input control");
});
test("playground build: wizard with 3 modes reusing editors; BuildTab keeps handlers", () => {
const wiz = read("src/app/(dashboard)/dashboard/playground/components/tabs/build/BuildWizard.tsx");
assert.ok(wiz.includes('"tools"') && wiz.includes('"json"') && wiz.includes('"both"'), "three modes");
assert.ok(wiz.includes("ToolsBuilder") && wiz.includes("StructuredOutputEditor"), "reuses both editors");
const tab = read("src/app/(dashboard)/dashboard/playground/components/tabs/BuildTab.tsx");
assert.ok(tab.includes("<BuildWizard"), "BuildTab mounts BuildWizard");
assert.ok(tab.includes("runRequest") && tab.includes("sendToolResult"), "BuildTab preserves run/tool handlers");
});
test("playground build i18n: playground.build keys present with en/pt parity", () => {
const en = JSON.parse(read("src/i18n/messages/en.json"));
const pt = JSON.parse(read("src/i18n/messages/pt-BR.json"));
const ek = Object.keys(en.playground?.build ?? {});
const pk = Object.keys(pt.playground?.build ?? {});
assert.ok(ek.length >= 10, `expected >=10 build keys, got ${ek.length}`);
assert.deepEqual(ek.sort(), pk.sort(), "en/pt-BR playground.build keys must match");
});

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Regression guard: quota-share crashed with "Cannot read properties of undefined
// (reading 'length')" because PoolCard/aggregate read usage.dimensions without a
// guard when the usage snapshot came back without a dimensions array.
const root = join(import.meta.dirname, "../..");
const read = (p: string) => readFileSync(join(root, p), "utf8");
test("quota-share PoolCard guards usage.dimensions", () => {
const pc = read("src/app/(dashboard)/dashboard/costs/quota-share/components/PoolCard.tsx");
assert.ok(pc.includes("usage?.dimensions ?? []"), "computeStatus normalizes dimensions to []");
assert.ok(pc.includes("!!usage?.dimensions?.length"), "hasDimensions guards dimensions");
assert.equal(/[^?.]usage\.dimensions\.length/.test(pc), false, "no unguarded usage.dimensions.length");
});
test("quota-share aggregate hook guards dimensions/perKey", () => {
const agg = read("src/app/(dashboard)/dashboard/costs/quota-share/hooks/usePoolsUsageAggregate.ts");
assert.ok(agg.includes("usage.dimensions ?? []"), "iterates dimensions with ?? []");
assert.ok(agg.includes("dim.perKey ?? []"), "iterates perKey with ?? []");
});