feat(translator): lift useTranslateSession to shell + connect PipelineView to real result

- Move useTranslateSession() to TranslatorPageClient (shell level) for PipelineView to receive real steps
- Build PipelineStep[] from session result (detected/intermediate/translated/response)
- Pass session as prop down to TranslateTab; internal hook kept for isolated test compatibility
- Dedup useProviderOptions: only TranslateTab calls the hook, props pass to SimpleControls
- Remove unused data-advanced-section/data-input-text placeholder (DOM data leak GAP-5)
- Add aria-expanded + aria-controls to AutoFeaturesCard toggle (a11y GAP-2)

Addresses code review RISCO-4, GAP-2, GAP-3, GAP-5.
This commit is contained in:
diegosouzapw
2026-05-28 11:08:36 -03:00
parent 44709df885
commit e20330af69
7 changed files with 161 additions and 78 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { Suspense, useState } from "react";
import { Suspense, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Badge, Card, SegmentedControl } from "@/shared/components";
import TranslatorConceptCard from "./components/TranslatorConceptCard";
@@ -9,10 +9,12 @@ import MonitorTab from "./components/MonitorTab";
import AdvancedSection from "./components/advanced/AdvancedSection";
import RawJsonPanel from "./components/advanced/RawJsonPanel";
import PipelineView from "./components/advanced/PipelineView";
import type { PipelineStep } from "./components/advanced/PipelineView";
import StreamTransformerAccordion from "./components/advanced/StreamTransformerAccordion";
import TestBenchAccordion from "./components/advanced/TestBenchAccordion";
import CompressionPreviewAccordion from "./components/advanced/CompressionPreviewAccordion";
import { useTranslateDeepLink } from "./hooks/useTranslateDeepLink";
import { useTranslateSession } from "./hooks/useTranslateSession";
import type { AdvancedSlug, TranslatorTab } from "./types";
export default function TranslatorPageClient() {
@@ -28,6 +30,9 @@ function TranslatorPageClientInner() {
const [sharedInputContent, setSharedInputContent] = useState("");
const { state, setTab, setAdvanced } = useTranslateDeepLink();
// Lift session to shell so PipelineView can receive real steps
const session = useTranslateSession();
const makeOpenHandler = (slug: AdvancedSlug) => (open: boolean) => {
if (open) {
setAdvanced(slug);
@@ -36,6 +41,80 @@ function TranslatorPageClientInner() {
}
};
const tr = (key: string, fallback: string): string => {
try {
const v = t(key as Parameters<typeof t>[0]);
if (v === key || v === `translator.${key}`) return fallback;
return v as string;
} catch {
return fallback;
}
};
// Build PipelineStep[] from session.result so PipelineView reflects real state
const pipelineSteps = useMemo<PipelineStep[]>(() => {
const r = session.result;
if (r.status === "idle") return [];
const steps: PipelineStep[] = [];
// Step 1 — Client Request (always present once started)
steps.push({
id: "1",
name: tr("pipelineStepClientRequest", "Client Request"),
description: tr("pipelineStepClientRequestDesc", "Request received in client format"),
format: r.detected ?? "openai",
content: sharedInputContent.slice(0, 500),
status: r.status === "error" ? "error" : "done",
});
// Step 2 — Format Detected
steps.push({
id: "2",
name: tr("pipelineStepFormatDetected", "Format Detected"),
description: tr("pipelineStepFormatDetectedDesc", "Auto-detected source format"),
format: r.detected ?? null,
content: r.detected ? JSON.stringify({ detectedFormat: r.detected, confidence: "high" }, null, 2) : "",
status: r.detected ? "done" : r.status === "translating" ? "active" : "pending",
});
// Step 3 — OpenAI Intermediate (only when hub-and-spoke)
if (r.pipelinePath === "hub-and-spoke") {
steps.push({
id: "3",
name: tr("pipelineStepOpenAIIntermediate", "OpenAI Intermediate"),
description: tr("pipelineStepOpenAIIntermediateDesc", "Translated to OpenAI hub format"),
format: "openai",
content: r.intermediateJson ?? "",
status: r.intermediateJson ? "done" : r.status === "translating" ? "active" : "pending",
});
}
// Step 4 — Provider Format (translated result)
steps.push({
id: r.pipelinePath === "hub-and-spoke" ? "4" : "3",
name: tr("pipelineStepProviderFormat", "Provider Format"),
description: tr("pipelineStepProviderFormatDesc", "Translated to provider target format"),
format: r.target,
content: r.translatedJson ?? "",
status: r.translatedJson ? "done" : r.status === "translating" ? "active" : "pending",
});
// Step 5 — Provider Response (only when mode=send and response present)
if (r.responsePreview !== null) {
steps.push({
id: r.pipelinePath === "hub-and-spoke" ? "5" : "4",
name: tr("pipelineStepProviderResponse", "Provider Response"),
description: tr("pipelineStepProviderResponseDesc", "Streaming response from provider"),
format: "openai",
content: r.responsePreview,
status: r.status === "ok" ? "done" : r.status === "sending" ? "active" : "pending",
});
}
return steps;
}, [session.result, sharedInputContent]);
const advancedSlot = (
<AdvancedSection forceOpenSlug={state.advanced}>
<RawJsonPanel
@@ -47,7 +126,7 @@ function TranslatorPageClientInner() {
slug="pipeline"
forceOpen={state.advanced === "pipeline"}
onOpenChange={makeOpenHandler("pipeline")}
steps={[]}
pipelineSteps={pipelineSteps.length > 0 ? pipelineSteps : undefined}
/>
<StreamTransformerAccordion
forceOpen={state.advanced === "streamtransform"}
@@ -92,6 +171,7 @@ function TranslatorPageClientInner() {
<TranslateTab
forceOpenAdvancedSlug={state.advanced}
onAdvancedSlugChange={(slug) => setAdvanced(slug)}
session={session}
/>
)}
@@ -111,7 +191,10 @@ function AutoFeaturesCard() {
return (
<Card className="border-primary/10 bg-primary/5">
<button
type="button"
onClick={() => setShowFeatures((prev) => !prev)}
aria-expanded={showFeatures}
aria-controls="auto-features-grid"
className="flex w-full items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-2">
@@ -130,6 +213,7 @@ function AutoFeaturesCard() {
{showFeatures && (
<div
id="auto-features-grid"
className="grid grid-cols-1 gap-3 px-4 pb-4 sm:grid-cols-2 lg:grid-cols-4"
data-testid="auto-features-grid"
>

View File

@@ -5,7 +5,6 @@ import { useTranslations } from "next-intl";
import { Button, Select, SegmentedControl } from "@/shared/components";
import { InfoTooltip } from "@/shared/components";
import { FORMAT_OPTIONS, FORMAT_META, getExampleTemplates } from "../exampleTemplates";
import { useProviderOptions } from "../hooks/useProviderOptions";
import type { FormatId, TranslateMode } from "../types";
interface SimpleControlsProps {
@@ -22,6 +21,8 @@ interface SimpleControlsProps {
onSubmit: () => void;
onOpenAdvanced: () => void;
isLoading?: boolean;
providerOptions: Array<{ value: string; label: string }>;
loading?: boolean;
}
export default function SimpleControls({
@@ -38,9 +39,10 @@ export default function SimpleControls({
onSubmit,
onOpenAdvanced,
isLoading = false,
providerOptions,
loading = false,
}: SimpleControlsProps) {
const t = useTranslations("translator");
const { providerOptions } = useProviderOptions(provider);
const tr = useCallback(
(key: string, fallback: string): string => {
@@ -150,6 +152,7 @@ export default function SimpleControls({
options={providerOptions.length > 0 ? providerOptions : [{ value: provider, label: provider }]}
value={provider}
onChange={handleProviderChange}
disabled={loading}
/>
</div>
</div>

View File

@@ -3,6 +3,7 @@
import { useState } from "react";
import { Card } from "@/shared/components";
import { useTranslateSession } from "../hooks/useTranslateSession";
import type { UseTranslateSessionReturn } from "../hooks/useTranslateSession";
import { useProviderOptions } from "../hooks/useProviderOptions";
import SimpleControls from "./SimpleControls";
import ResultNarrated from "./ResultNarrated";
@@ -19,11 +20,18 @@ interface TranslateTabProps {
* (open or close). F9 syncs this with the URL query string.
*/
onAdvancedSlugChange?: (slug: AdvancedSlug | null) => void;
/**
* Optional session lifted from shell (TranslatorPageClient) so PipelineView
* can read the result at the shell level. When undefined, an internal session
* is used (isolated rendering mode, e.g. tests).
*/
session?: UseTranslateSessionReturn;
}
export default function TranslateTab({
forceOpenAdvancedSlug = null,
onAdvancedSlugChange,
session: sessionProp,
}: TranslateTabProps) {
// Internal simple-mode state
const [source, setSource] = useState<FormatId>("claude");
@@ -31,11 +39,14 @@ export default function TranslateTab({
const [mode, setMode] = useState<TranslateMode>("send");
// Provider/target state: derive from useProviderOptions
const { provider, setProvider, providerOptions } = useProviderOptions("openai");
// GAP-3: useProviderOptions lives only here; SimpleControls receives it as props
const { provider, setProvider, providerOptions, loading } = useProviderOptions("openai");
// target FormatId mirrors provider selection; managed via SimpleControls callback
const [target, setTarget] = useState<FormatId>("openai");
const { result, run } = useTranslateSession();
// Rules of Hooks: always call unconditionally; fall back to prop when provided
const internalSession = useTranslateSession();
const { result, run } = sessionProp ?? internalSession;
const handleSubmit = () => {
run({ source, target, provider, inputText, mode });
@@ -45,11 +56,6 @@ export default function TranslateTab({
if (onAdvancedSlugChange) {
onAdvancedSlugChange(slug);
}
// Scroll to advanced section if it exists (guard for environments without scrollIntoView)
const advancedEl = document.querySelector("[data-advanced-section]");
if (advancedEl && typeof advancedEl.scrollIntoView === "function") {
advancedEl.scrollIntoView({ behavior: "smooth", block: "start" });
}
};
const handleSeeTranslatedJson = () => {
@@ -60,10 +66,6 @@ export default function TranslateTab({
handleOpenAdvanced("pipeline");
};
// Expose forceOpenAdvancedSlug to the advanced section slot via data attribute
// F4 will read this; for now we write it to a data attribute that F9 will wire
const advancedSlug = forceOpenAdvancedSlug;
// Sync provider options: when providerOptions loads, keep provider in sync
// (useProviderOptions handles this internally; we just need to expose setProvider)
const handleProviderChange = (prov: string) => {
@@ -90,6 +92,8 @@ export default function TranslateTab({
onSubmit={handleSubmit}
onOpenAdvanced={() => handleOpenAdvanced("rawjson")}
isLoading={result.status === "translating" || result.status === "sending"}
providerOptions={providerOptions}
loading={loading}
/>
</Card>
@@ -100,17 +104,6 @@ export default function TranslateTab({
onSeePipeline={handleSeePipeline}
/>
</div>
{/* Advanced section slot — F4 will mount AdvancedSection here.
F9 composes: <TranslateTab onAdvancedSlugChange={...} /> + <AdvancedSection forceOpenSlug={...} />.
For now we render the placeholder that F4/F9 will replace. */}
<div
data-advanced-section
data-force-open-slug={advancedSlug ?? ""}
data-provider-options={JSON.stringify(providerOptions.map((o) => o.value))}
data-source={source}
data-input-text={inputText.slice(0, 100)}
/>
</div>
);
}

View File

@@ -80,6 +80,28 @@ vi.mock("@/shared/components", () => ({
),
}));
// ── useTranslateSession stub (now lifted to shell) ────────────────────────────
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useTranslateSession",
() => ({
useTranslateSession: () => ({
result: {
detected: null,
target: "openai",
status: "idle",
responsePreview: null,
translatedJson: null,
pipelinePath: null,
intermediateJson: null,
errorMessage: null,
latencyMs: null,
},
run: vi.fn(),
reset: vi.fn(),
}),
}),
);
// ── Sub-component stubs that capture received props ───────────────────────────
const capturedTranslateTabProps: Array<{
forceOpenAdvancedSlug?: string | null;
@@ -100,6 +122,7 @@ vi.mock(
}: {
forceOpenAdvancedSlug?: string | null;
onAdvancedSlugChange?: (slug: string | null) => void;
session?: unknown;
}) => {
capturedTranslateTabProps.push({ forceOpenAdvancedSlug });
return (
@@ -154,7 +177,7 @@ vi.mock(
vi.mock(
"@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView",
() => ({
default: ({ forceOpen }: { forceOpen?: boolean; steps?: unknown[] }) => (
default: ({ forceOpen }: { forceOpen?: boolean; pipelineSteps?: unknown[] }) => (
<div data-testid="pipeline-view" data-force-open={String(forceOpen ?? false)} />
),
}),

View File

@@ -94,6 +94,28 @@ vi.mock("@/shared/components", () => ({
),
}));
// ── useTranslateSession stub (now lifted to shell) ────────────────────────────
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useTranslateSession",
() => ({
useTranslateSession: () => ({
result: {
detected: null,
target: "openai",
status: "idle",
responsePreview: null,
translatedJson: null,
pipelinePath: null,
intermediateJson: null,
errorMessage: null,
latencyMs: null,
},
run: vi.fn(),
reset: vi.fn(),
}),
}),
);
// ── Sub-component stubs ───────────────────────────────────────────────────────
vi.mock(
"@/app/(dashboard)/dashboard/translator/components/TranslatorConceptCard",
@@ -111,6 +133,7 @@ vi.mock(
}: {
forceOpenAdvancedSlug?: string | null;
onAdvancedSlugChange?: (slug: string | null) => void;
session?: unknown;
}) => (
<div
data-testid="translate-tab"
@@ -159,7 +182,7 @@ vi.mock(
vi.mock(
"@/app/(dashboard)/dashboard/translator/components/advanced/PipelineView",
() => ({
default: ({ forceOpen }: { forceOpen?: boolean }) => (
default: ({ forceOpen }: { forceOpen?: boolean; pipelineSteps?: unknown[] }) => (
<div data-testid="pipeline-view" data-force-open={String(forceOpen ?? false)} />
),
}),

View File

@@ -88,22 +88,6 @@ vi.mock("@/shared/components", () => ({
),
}));
// --- Mock useProviderOptions ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useProviderOptions",
() => ({
useProviderOptions: () => ({
provider: "openai",
setProvider: vi.fn(),
providerOptions: [
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
],
loading: false,
}),
})
);
// --- Mock useAvailableModels ---
vi.mock(
"@/app/(dashboard)/dashboard/translator/hooks/useAvailableModels",
@@ -180,6 +164,8 @@ function makeProps(overrides: Partial<{
onSubmit: () => void;
onOpenAdvanced: () => void;
isLoading: boolean;
providerOptions: Array<{ value: string; label: string }>;
loading: boolean;
}> = {}) {
return {
source: "claude" as FormatId,
@@ -195,6 +181,8 @@ function makeProps(overrides: Partial<{
onSubmit: vi.fn(),
onOpenAdvanced: vi.fn(),
isLoading: false,
providerOptions: [{ value: "openai", label: "OpenAI" }, { value: "anthropic", label: "Anthropic" }],
loading: false,
...overrides,
};
}

View File

@@ -197,7 +197,7 @@ describe("TranslateTab", () => {
expect(gridEl?.className).toContain("lg:grid-cols-2");
});
it("renders the advanced section slot (data-advanced-section)", async () => {
it("does not expose data-advanced-section placeholder div (GAP-5)", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
@@ -206,8 +206,8 @@ describe("TranslateTab", () => {
await act(async () => {
root.render(<TranslateTab />);
});
const advancedSlot = container.querySelector("[data-advanced-section]");
expect(advancedSlot).toBeTruthy();
// GAP-5: the data-advanced-section DOM data-leak placeholder must not exist
expect(container.querySelector("[data-advanced-section]")).toBeNull();
});
it("calls onAdvancedSlugChange with 'rawjson' when the Advanced button is clicked", async () => {
@@ -233,37 +233,6 @@ describe("TranslateTab", () => {
expect(onAdvancedSlugChange).toHaveBeenCalledWith("rawjson");
});
it("reflects forceOpenAdvancedSlug in data attribute", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
const slug: AdvancedSlug = "pipeline";
await act(async () => {
root.render(
<TranslateTab forceOpenAdvancedSlug={slug} onAdvancedSlugChange={vi.fn()} />
);
});
const advancedSlot = container.querySelector("[data-advanced-section]");
expect(advancedSlot?.getAttribute("data-force-open-slug")).toBe("pipeline");
});
it("forceOpenAdvancedSlug=null results in empty data attribute", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"
);
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(
<TranslateTab forceOpenAdvancedSlug={null} onAdvancedSlugChange={vi.fn()} />
);
});
const advancedSlot = container.querySelector("[data-advanced-section]");
expect(advancedSlot?.getAttribute("data-force-open-slug")).toBe("");
});
it("renders without onAdvancedSlugChange prop (optional)", async () => {
const { default: TranslateTab } = await import(
"@/app/(dashboard)/dashboard/translator/components/TranslateTab"