fix(frontend): refine activity layout and input state

This commit is contained in:
User
2026-07-14 10:32:11 +02:00
parent 6dbf93fff9
commit 3633a5ebd9
7 changed files with 87 additions and 7 deletions
@@ -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");
});
@@ -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();
});
+16 -2
View File
@@ -33,6 +33,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
export function AppShell() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [creatingSession, setCreatingSession] = useState(false);
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
const { data: sessions = [] } = useQuery<SessionSummary[]>({
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 (
<div className="flex h-screen bg-background text-foreground">
<div
data-testid="app-shell"
data-activity-layout={showActivity ? "open" : "closed"}
className="flex h-screen bg-background text-foreground"
>
{panelSession && (
<SessionDocumentsPanel
session={panelSession}
@@ -289,7 +300,7 @@ export function AppShell() {
{showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />}
{/* Conversation column */}
<div className="flex min-w-0 flex-1 flex-col">
<div className={["flex min-w-0 flex-col", showActivity ? "w-3/5 shrink-0" : "flex-1"].join(" ")}>
{activeSessionId && (
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
{/* 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"}
/>
</div>
<ComposerFooter />
@@ -361,6 +373,7 @@ export function AppShell() {
</div>
{/* Right session rail — symmetric to the portal's left sidebar */}
{!showActivity && (
<aside className="flex w-[15vw] shrink-0 flex-col border-l border-border bg-sidebar">
<div className="relative px-4 pb-3 pt-5 text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
@@ -501,6 +514,7 @@ export function AppShell() {
)}
</div>
</aside>
)}
<Toaster />
@@ -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(<ModelActivityPanel onClose={vi.fn()} />);
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");
});
+2 -1
View File
@@ -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 (
<aside className="flex w-[30vw] max-w-[30vw] shrink-0 flex-col border-r border-border bg-sidebar">
<aside className="flex w-2/5 shrink-0 flex-col border-r border-border bg-sidebar">
<div className="flex items-center justify-between border-b border-border/60 px-3 py-3">
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
<div className="flex items-center gap-1">
+15
View File
@@ -67,3 +67,18 @@ test("does not POST when input is empty", async () => {
await new Promise((r) => setTimeout(r, 50));
expect(called).toBe(false);
});
test("keeps the new-question composer white by default", () => {
render(<SteerInput sessionId={null} />);
const input = screen.getByRole("textbox", { name: /new question/i });
expect(input).not.toHaveAttribute("data-awaiting-input");
expect(input).not.toHaveClass("thot-awaiting-input");
});
test("marks the composer as awaiting input only when requested", () => {
render(<SteerInput sessionId={null} awaitingInput />);
const input = screen.getByRole("textbox", { name: /new question/i });
expect(input).toHaveAttribute("data-awaiting-input", "true");
expect(input).toHaveClass("thot-awaiting-input");
});
+5 -2
View File
@@ -23,6 +23,8 @@ interface Props {
onStop?: () => void;
/** Lets the parent focus the composer (e.g. on "New session"). */
inputRef?: React.Ref<HTMLTextAreaElement>;
/** The workflow is explicitly waiting for text in this composer. */
awaitingInput?: boolean;
}
export function SteerInput({
@@ -32,6 +34,7 @@ export function SteerInput({
onSessionCreateFailed,
onStop,
inputRef,
awaitingInput = false,
}: Props) {
const [text, setText] = useState("");
const [busy, setBusy] = useState(false);
@@ -116,8 +119,8 @@ export function SteerInput({
onKeyDown={handleKeyDown}
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
aria-label={sessionId ? "Steering" : "New question"}
data-awaiting-input={sessionId ? undefined : "true"}
className={["max-h-40 flex-1 resize-none rounded-lg px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", sessionId ? "bg-transparent" : "thot-awaiting-input"].join(" ")}
data-awaiting-input={awaitingInput ? "true" : undefined}
className={["max-h-40 flex-1 resize-none rounded-lg bg-card px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", awaitingInput && "thot-awaiting-input"].filter(Boolean).join(" ")}
/>
<button
type="button"