Fix new-question landing and question-language HITL
Publish documentation / publish (push) Successful in 34s

Reset the activity panel when starting a new question so the landing navigation is restored. Detect and persist the original question language, pass it through runtime and widget descriptors, and scope HITL controls to that language.

Validated with gate, session, backend and frontend tests, TypeScript checks, Ruff and strict docs build. Rebuilt and restarted local core/frontend; both healthy and serving HTTP successfully.
This commit is contained in:
Codex
2026-09-21 19:47:22 +02:00
parent 23e52c80de
commit bd416f7327
22 changed files with 232 additions and 43 deletions
+1
View File
@@ -31,6 +31,7 @@ export interface SchemaTable {
export interface WidgetDescriptor {
id: string;
interaction_language?: string;
schema_version?: number;
session_id?: string;
phase?: string;
+12 -2
View File
@@ -1,4 +1,4 @@
import { useCallback, useSyncExternalStore } from "react";
import { createContext, useCallback, useContext, useSyncExternalStore, type ReactNode } from "react";
import { itCore } from "./locales/it-core";
import { itAdmin } from "./locales/it-admin";
import { itWorkflow } from "./locales/it-workflow";
@@ -63,8 +63,18 @@ export function translate(message: string, params?: TranslationParams): string {
return translateIn(locale, message, params);
}
const InteractionLocale = createContext<string | undefined>(undefined);
/** Scope HITL controls to the session language without changing application navigation. */
export function InteractionLanguage({ language, children }: { language?: string; children: ReactNode }) {
return <InteractionLocale.Provider value={language ? resolveLocale(language) : undefined}>
{children}
</InteractionLocale.Provider>;
}
export function useI18n() {
const current = useSyncExternalStore(subscribe, getLocale, () => "en");
const uiLocale = useSyncExternalStore(subscribe, getLocale, () => "en");
const current = useContext(InteractionLocale) ?? uiLocale;
const t = useCallback(
(message: string, params?: TranslationParams) => translateIn(current, message, params),
[current],
@@ -1213,7 +1213,7 @@ test("closing and reopening Model activity preserves the complete activity log",
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
});
test("session finalization shows the completion banner and returns to landing", async () => {
test("a new question after finalization restores the landing and administration navigation", async () => {
useSessionStore.getState().resetSession();
let finalized = false;
server.use(
@@ -1221,7 +1221,7 @@ test("session finalization shows the completion banner and returns to landing",
http.get("/api/sessions", () =>
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
);
wrap();
wrap(adminUser);
await userEvent.click(await screen.findByText("Attiva uno"));
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
// The final turn ends: the session is now finalized on disk and agent_end
@@ -1233,9 +1233,18 @@ test("session finalization shows the completion banner and returns to landing",
});
const cta = await screen.findByRole("button", { name: /start a new question/i });
expect(screen.getByText(/session completed and finalized/i)).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
expect(screen.getByRole("heading", { name: "Model activity" })).toBeVisible();
expect(screen.queryByRole("complementary", { name: "Session navigation" })).not.toBeInTheDocument();
await userEvent.click(cta);
// Landing state: the composer invites a brand-new question.
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Model activity" })).not.toBeInTheDocument();
expect(screen.queryByText(/session completed and finalized/i)).not.toBeInTheDocument();
const navigation = screen.getByRole("complementary", { name: "Session navigation" });
expect(within(navigation).getByRole("button", { name: "Administration" })).toBeVisible();
expect(screen.getByRole("textbox", { name: /new question/i })).toHaveValue("");
expect(useSessionStore.getState().activityLog).toEqual([]);
});
test("renaming a group reassigns its members via setSessionGroup", async () => {
+2 -2
View File
@@ -728,9 +728,9 @@ export function AppShell({ canLogout }: AppShellProps) {
invalidateResumeIntent();
newSessionOperationRef.current = null;
resetSession();
// Starting a new question closes any open session detail panel: the reader is
// moving away from that session, so its left-hand box must not linger.
// Restore the landing layout, including the right-hand administration rail.
setPanelSession(null);
setShowActivity(false);
setAwaitingQuestion(true);
setCreatingSession(false);
selectActiveSession(null);
@@ -23,3 +23,23 @@ test("gate chrome follows UI locale while model questions and choices remain in
expect(screen.getByRole("button", { name: "Salva e procedi" })).toBeInTheDocument();
expect(useSessionStore.getState().pendingWidget?.options?.[0].label).toBe("Salva e procedi");
});
test.each([
["en", "it", "Save and proceed", "Other — specify"],
["it", "en", "Salva e procedi", "Altro: specifica"],
])("HITL controls follow question language %s even when the UI locale changes", (language, uiLocale, save, other) => {
setLocale(uiLocale);
useSessionStore.setState({ pendingWidget: {
id: "language-gate", widget: "select", interaction_language: language,
title: "Which admission date should be used?",
options: [{ id: "approve", label: "Save and proceed", label_i18n: "Save and proceed" }],
reserved: ["back", "exit", "other"],
} });
render(<WidgetHost sessionId="s1" />);
expect(screen.getByRole("button", { name: save })).toBeVisible();
expect(screen.getByRole("button", { name: other })).toBeVisible();
act(() => setLocale(language));
act(() => setLocale(uiLocale));
expect(screen.getByRole("button", { name: other })).toBeVisible();
expect(screen.getByText("Which admission date should be used?")).toBeVisible();
});
+8 -1
View File
@@ -1,4 +1,4 @@
import { useI18n } from "../i18n";
import { InteractionLanguage, useI18n } from "../i18n";
import { useEffect, useRef, useState } from "react";
import { useSessionStore } from "../store/sessionStore";
import { resolve } from "../widgets";
@@ -9,6 +9,13 @@ import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperat
import { localizeWidget } from "../widgets/localize";
export function WidgetHost({ sessionId }: { sessionId: string | null }) {
const language = useSessionStore((s) => s.pendingWidget?.interaction_language);
return <InteractionLanguage language={language}>
<SessionWidgetHost sessionId={sessionId} />
</InteractionLanguage>;
}
function SessionWidgetHost({ sessionId }: { sessionId: string | null }) {
const { t: translate } = useI18n();
const source = useSessionStore((s) => s.pendingWidget);
const pending = source ? localizeWidget(source, translate) : null;