feat(playground): scaffold PlaygroundStudio + StudioTopBar + StudioConfigPane

Adds the PlaygroundStudio orchestrator (tab routing via ?tab= deep-link,
shared configState), StudioTopBar (4 tabs + export placeholder modal), and
StudioConfigPane (collapsible, 10-endpoint select, model, system prompt,
ParamSliders). F7 slots (SLOT_PRESETS / SLOT_IMPROVE) left as JSX comments.
This commit is contained in:
diegosouzapw
2026-05-28 09:24:21 -03:00
parent b9c8fc2f6e
commit 68ec69a9c5
3 changed files with 377 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
"use client";
// src/app/(dashboard)/dashboard/playground/PlaygroundStudio.tsx
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import StudioTopBar, { type StudioTab } from "./components/StudioTopBar";
import StudioConfigPane, { type ConfigState } from "./components/StudioConfigPane";
import { DEFAULT_PARAMS } from "./components/ParamSliders";
import dynamic from "next/dynamic";
import type { StreamMetrics } from "@/shared/schemas/playground";
// Lazy-load tabs to reduce initial bundle size
const ChatTab = dynamic(() => import("./components/tabs/ChatTab"), { ssr: false });
const ApiTab = dynamic(() => import("./components/tabs/ApiTab"), { ssr: false });
const INITIAL_METRICS: StreamMetrics = {
ttftMs: null,
totalMs: null,
tokensIn: 0,
tokensOut: 0,
tps: null,
costUsd: null,
};
const INITIAL_CONFIG: ConfigState = {
endpoint: "chat.completions",
baseUrl: typeof window !== "undefined" ? window.location.origin : "http://localhost:20128",
model: "",
systemPrompt: "You are a helpful assistant.",
params: DEFAULT_PARAMS,
};
function resolveTab(value: string | null): StudioTab {
if (value === "chat" || value === "compare" || value === "api" || value === "build") {
return value;
}
return "chat";
}
/**
* PlaygroundStudio — orchestrator component for the Playground Studio.
* - Manages active tab state (supports ?tab=chat|compare|api|build deep-link)
* - Manages shared configState for all tabs
* - Renders StudioTopBar + content + StudioConfigPane
*
* F7 SLOTS:
* - SLOT_PRESETS: F7 will replace the comment in StudioConfigPane with PresetPicker
* - SLOT_IMPROVE: F7 will replace the comment in StudioConfigPane with ImprovePromptButton
*/
export function PlaygroundStudio() {
const searchParams = useSearchParams();
// Derive active tab from URL — no effect needed, avoids setState-in-effect lint violation.
// When URL changes (client nav), searchParams updates, component re-renders with new tab.
const activeTab: StudioTab = resolveTab(searchParams.get("tab"));
const [manualTab, setManualTab] = useState<StudioTab | null>(null);
const effectiveTab = manualTab ?? activeTab;
const [configState, setConfigState] = useState<ConfigState>(INITIAL_CONFIG);
const [metrics, setMetrics] = useState<StreamMetrics>(INITIAL_METRICS);
function handleTabChange(tab: StudioTab) {
setManualTab(tab);
}
function handleMetricsUpdate(m: StreamMetrics) {
setMetrics(m);
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)] overflow-hidden">
{/* Top bar with tabs + token/cost counter + export button */}
<StudioTopBar
activeTab={effectiveTab}
onTabChange={handleTabChange}
metrics={metrics}
/>
{/* Main content area: tab content + config pane */}
<div className="flex flex-1 overflow-hidden">
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{effectiveTab === "chat" && (
<ChatTab configState={configState} onMetricsUpdate={handleMetricsUpdate} />
)}
{effectiveTab === "compare" && (
<div className="flex items-center justify-center h-full text-text-muted">
<div className="text-center space-y-2">
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
compare
</span>
<p className="text-sm">Compare tab F7 implementation pending</p>
</div>
</div>
)}
{effectiveTab === "api" && (
<ApiTab configState={configState} />
)}
{effectiveTab === "build" && (
<div className="flex items-center justify-center h-full text-text-muted">
<div className="text-center space-y-2">
<span className="material-symbols-outlined text-[48px] text-text-muted/30">
build
</span>
<p className="text-sm">Build tab F7 implementation pending</p>
</div>
</div>
)}
</div>
{/* Config pane — always visible, collapsible */}
{/* SLOT_PRESETS and SLOT_IMPROVE are inside StudioConfigPane */}
<StudioConfigPane
configState={configState}
setConfigState={setConfigState}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/StudioConfigPane.tsx
import { useState } from "react";
import ParamSliders, { type PlaygroundParams } from "./ParamSliders";
import type { PlaygroundEndpoint } from "@/lib/playground/codeExport";
import { endpointToPath } from "@/lib/playground/codeExport";
export interface ConfigState {
endpoint: PlaygroundEndpoint;
baseUrl: string;
model: string;
systemPrompt: string;
params: PlaygroundParams;
}
interface StudioConfigPaneProps {
configState: ConfigState;
setConfigState: (s: ConfigState) => void;
}
const ENDPOINT_OPTIONS: Array<{ value: PlaygroundEndpoint; label: string }> = [
{ value: "chat.completions", label: "Chat completions" },
{ value: "completions", label: "Completions" },
{ value: "embeddings", label: "Embeddings" },
{ value: "images", label: "Images" },
{ value: "audio.transcriptions", label: "Audio transcriptions" },
{ value: "audio.speech", label: "Audio speech" },
{ value: "moderations", label: "Moderations" },
{ value: "rerank", label: "Rerank" },
{ value: "search", label: "Search" },
{ value: "web.fetch", label: "Web fetch" },
];
/**
* Right-side collapsible config pane for PlaygroundStudio.
* Slots for F7:
* - SLOT_PRESETS: PresetPicker will be injected here
* - SLOT_IMPROVE: ImprovePromptButton will be injected here
*/
export default function StudioConfigPane({ configState, setConfigState }: StudioConfigPaneProps) {
const [collapsed, setCollapsed] = useState(false);
function update<K extends keyof ConfigState>(key: K, value: ConfigState[K]) {
setConfigState({ ...configState, [key]: value });
}
if (collapsed) {
return (
<div className="flex flex-col items-center w-8 shrink-0">
<button
onClick={() => setCollapsed(false)}
className="mt-2 p-1.5 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Expand config pane"
aria-label="Expand config pane"
>
<span className="material-symbols-outlined text-[18px]">settings</span>
</button>
</div>
);
}
return (
<aside
className="w-72 shrink-0 border-l border-border bg-bg-alt flex flex-col overflow-y-auto"
aria-label="Config pane"
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
Config
</span>
<button
onClick={() => setCollapsed(true)}
className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Collapse config pane"
aria-label="Collapse config pane"
>
<span className="material-symbols-outlined text-[16px]">chevron_right</span>
</button>
</div>
<div className="flex flex-col gap-5 p-4">
{/* SLOT_PRESETS: F7 substituirá com PresetPicker */}
{/* SLOT_PRESETS */}
{/* Endpoint */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
Endpoint
</label>
<select
value={configState.endpoint}
onChange={(e) => update("endpoint", e.target.value as PlaygroundEndpoint)}
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"
>
{ENDPOINT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label} {endpointToPath(opt.value)}
</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"
/>
</div>
{/* System prompt */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-text-muted uppercase tracking-wider">
System prompt
</label>
<textarea
value={configState.systemPrompt}
onChange={(e) => update("systemPrompt", e.target.value)}
placeholder="You are a helpful assistant."
rows={4}
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 resize-y"
/>
{/* SLOT_IMPROVE: F7 substituirá com ImprovePromptButton */}
{/* SLOT_IMPROVE */}
</div>
{/* Param sliders */}
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-text-muted uppercase tracking-wider">
Parameters
</span>
<ParamSliders
params={configState.params}
setParams={(p) => update("params", p)}
/>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,108 @@
"use client";
// src/app/(dashboard)/dashboard/playground/components/StudioTopBar.tsx
import { useState } from "react";
import TokenCostCounter from "./TokenCostCounter";
import type { StreamMetrics } from "@/shared/schemas/playground";
export type StudioTab = "chat" | "compare" | "api" | "build";
interface StudioTopBarProps {
activeTab: StudioTab;
onTabChange: (tab: StudioTab) => void;
metrics: StreamMetrics;
}
interface TabConfig {
id: StudioTab;
label: string;
icon: string;
}
const TABS: TabConfig[] = [
{ id: "chat", label: "Chat", icon: "chat" },
{ id: "compare", label: "Compare", icon: "compare" },
{ id: "api", label: "API", icon: "api" },
{ id: "build", label: "Build", icon: "build" },
];
/**
* Top bar with tab switcher, token/cost counter, and export code button.
* Export code modal is a placeholder in F6; F7 replaces it with ExportCodeModal.
*/
export default function StudioTopBar({ activeTab, onTabChange, metrics }: StudioTopBarProps) {
const [exportOpen, setExportOpen] = useState(false);
return (
<>
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-bg-alt shrink-0">
{/* Tabs */}
<div className="flex items-center gap-1" role="tablist">
{TABS.map((tab) => (
<button
key={tab.id}
role="tab"
aria-selected={activeTab === tab.id}
onClick={() => onTabChange(tab.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTab === tab.id
? "bg-primary/10 text-primary"
: "text-text-muted hover:text-text-main hover:bg-black/5 dark:hover:bg-white/5"
}`}
>
<span className="material-symbols-outlined text-[16px]">{tab.icon}</span>
<span>{tab.label}</span>
</button>
))}
</div>
{/* Right side: token counter + export button */}
<div className="flex items-center gap-3">
<TokenCostCounter
tokensIn={metrics.tokensIn}
tokensOut={metrics.tokensOut}
costUsd={metrics.costUsd}
/>
<button
onClick={() => setExportOpen(true)}
className="flex items-center gap-1 text-xs px-2.5 py-1.5 rounded border border-border hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-text-main transition-colors"
title="Export code"
aria-label="Export code"
>
<span className="font-mono text-[11px]">&lt;/&gt;</span>
<span>Export</span>
</button>
</div>
</div>
{/* Export code placeholder modal — F7 replaces with ExportCodeModal */}
{exportOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={() => setExportOpen(false)}
>
<div
className="bg-surface border border-border rounded-xl p-6 w-[480px] max-w-full shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-text-main">Export code</h2>
<button
onClick={() => setExportOpen(false)}
className="text-text-muted hover:text-text-main"
aria-label="Close export modal"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<p className="text-sm text-text-muted">
Export code modal F7 implementation pending.
</p>
</div>
</div>
)}
</>
);
}