fix(frontend): refine activity layout and input state
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,7 +373,8 @@ export function AppShell() {
|
||||
</div>
|
||||
|
||||
{/* Right session rail — symmetric to the portal's left sidebar */}
|
||||
<aside className="flex w-[15vw] shrink-0 flex-col border-l border-border bg-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">
|
||||
Thoth<span className="text-primary">II</span>
|
||||
@@ -500,7 +513,8 @@ export function AppShell() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</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");
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user