From 3633a5ebd90e1aeb6523d0b54ab3245c9b594736 Mon Sep 17 00:00:00 2001 From: User Date: Tue, 14 Jul 2026 10:32:11 +0200 Subject: [PATCH] fix(frontend): refine activity layout and input state --- .../src/shell/AppShell.new-session.test.tsx | 11 ++++++++++ .../src/shell/AppShell.session-mgmt.test.tsx | 20 +++++++++++++++++ frontend/src/shell/AppShell.tsx | 22 +++++++++++++++---- .../src/shell/ModelActivityPanel.test.tsx | 16 ++++++++++++++ frontend/src/shell/ModelActivityPanel.tsx | 3 ++- frontend/src/shell/SteerInput.test.tsx | 15 +++++++++++++ frontend/src/shell/SteerInput.tsx | 7 ++++-- 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/frontend/src/shell/AppShell.new-session.test.tsx b/frontend/src/shell/AppShell.new-session.test.tsx index fd773ced..d660b8a2 100644 --- a/frontend/src/shell/AppShell.new-session.test.tsx +++ b/frontend/src/shell/AppShell.new-session.test.tsx @@ -41,6 +41,8 @@ test("New session starts prewarm without delaying composer focus", async () => { const composer = screen.getByRole("textbox", { name: /new question/i }); await waitFor(() => expect(prewarmStarted).toBe(true)); await waitFor(() => expect(composer).toHaveFocus()); + expect(composer).toHaveAttribute("data-awaiting-input", "true"); + expect(composer).toHaveClass("thot-awaiting-input"); }); test("shows the question and creation timer before POST /sessions resolves, then opens SSE", async () => { @@ -87,3 +89,12 @@ test("a failed create restores the landing view and preserves the question for r expect(useSessionStore.getState().lastUserEntry).toBeNull(); expect(FakeEventSource.instances).toHaveLength(0); }); + + +test("marks the composer as awaiting input for a pending freetext gate", () => { + useSessionStore.setState({ + pendingWidget: { id: "free-1", widget: "freetext", title: "Clarify" }, + }); + renderShell(); + expect(screen.getByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true"); +}); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index c3ae218e..c41e7904 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -168,3 +168,23 @@ test("renaming a group reassigns its members via setSessionGroup", async () => { await userEvent.click(screen.getByRole("button", { name: /save/i })); await waitFor(() => expect(groupSets).toEqual([{ id: "s1", group: "Cardiologia" }])); }); + + +test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => { + server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 204 }))); + wrap(); + await userEvent.click(await screen.findByText("Attiva uno")); + await userEvent.click(await screen.findByRole("button", { name: /resume/i })); + + expect(screen.getByText("Sessions")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); + + const shell = screen.getByTestId("app-shell"); + expect(shell).toHaveAttribute("data-activity-layout", "open"); + expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("w-2/5"); + expect(screen.queryByText("Sessions")).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: /hide model activity/i })); + expect(shell).toHaveAttribute("data-activity-layout", "closed"); + expect(screen.getByText("Sessions")).toBeInTheDocument(); +}); diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index ea710303..05a59e92 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -33,6 +33,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; export function AppShell() { const [activeSessionId, setActiveSessionId] = useState(null); const [creatingSession, setCreatingSession] = useState(false); + const [awaitingQuestion, setAwaitingQuestion] = useState(false); const { data: sessions = [] } = useQuery({ queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000, }); @@ -104,6 +105,7 @@ export function AppShell() { // working spinner shows straight away; the backend calls run after. setPanelSession(null); resetSession(); + setAwaitingQuestion(false); // Optimistic: the resume POST is about to hand the ball to the harness. setAgentActive(true); setActiveSessionId(id); @@ -231,6 +233,7 @@ export function AppShell() { // explicit “Stop & save” action is allowed to call /close. resetSession(); setActiveSessionId(null); + setAwaitingQuestion(false); } // The final workflow turn ends with the session already finalized on disk: // refetch now instead of waiting for the 10s poll, so the completed state @@ -241,6 +244,7 @@ export function AppShell() { function startNewSession() { resetSession(); + setAwaitingQuestion(true); setCreatingSession(false); setActiveSessionId(null); // Best effort only: session creation keeps the authoritative readiness gate. @@ -250,6 +254,7 @@ export function AppShell() { } function beginSessionCreation() { + setAwaitingQuestion(false); setCreatingSession(true); } @@ -258,6 +263,7 @@ export function AppShell() { // while useSessionStream opens the durable session's SSE channel. setActiveSessionId(id); setCreatingSession(false); + setAwaitingQuestion(false); refresh(); } @@ -274,11 +280,16 @@ export function AppShell() { } finally { resetSession(); setActiveSessionId(null); + setAwaitingQuestion(false); } } return ( -
+
{panelSession && ( setShowActivity(false)} />} {/* Conversation column */} -
+
{activeSessionId && (
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */} @@ -352,6 +363,7 @@ export function AppShell() { onSessionCreateFailed={failSessionCreation} onStop={() => setStopConfirm(true)} inputRef={composerRef} + awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"} />
@@ -361,7 +373,8 @@ export function AppShell() {
{/* Right session rail — symmetric to the portal's left sidebar */} - + )} diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx index 0c1073be..3c7f4f4b 100644 --- a/frontend/src/shell/ModelActivityPanel.test.tsx +++ b/frontend/src/shell/ModelActivityPanel.test.tsx @@ -54,3 +54,19 @@ test("paragraphs stay separated as distinct blocks", () => { expect(second.tagName).toBe("P"); expect(first).not.toBe(second); }); + + +test("separates activity updates concatenated after sentence punctuation", () => { + useSessionStore.getState().applyEvent({ + type: "text_delta", + text: "Ambiguità principale risolta.Finestra temporale risolta.Terza ambiguità risolta.", + }); + render(); + + const first = screen.getByText("Ambiguità principale risolta."); + const second = screen.getByText("Finestra temporale risolta."); + const third = screen.getByText("Terza ambiguità risolta."); + expect(first.tagName).toBe("P"); + expect(second.tagName).toBe("P"); + expect(third.tagName).toBe("P"); +}); diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx index b015fad5..c94da2c0 100644 --- a/frontend/src/shell/ModelActivityPanel.tsx +++ b/frontend/src/shell/ModelActivityPanel.tsx @@ -10,6 +10,7 @@ const TAIL_PARAGRAPHS = 5; export function formatModelActivity(text: string): string { return text .replace(/\r\n?/g, "\n") + .replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n") .replace(/^[\t ]*[•‣–]\s+/gm, "- ") .replace(/^(\d+)\)\s+/gm, (_, n) => `${n}. `) .replace(/\n(?=\S)(?![-*+] |\d+\. |>|`|\|)/g, "\n\n") @@ -40,7 +41,7 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) { const source = formatModelActivity(shown.join("\n\n")); return ( -