12 KiB
Model activity layout and composer state Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make activity updates readable, use the full application width as a 40/60 Model-activity/chat layout while activity is open, and reserve green input highlighting for genuine user-input states.
Architecture: Keep AppShell as the owner of the transient activity-panel and composer state. ModelActivityPanel converts the known punctuation-boundary stream-concatenation case into Markdown paragraphs before rendering. The composer receives an explicit boolean from AppShell; widgets keep their existing independently highlighted textareas.
Tech Stack: React 18, TypeScript, Tailwind CSS, Zustand, Vitest, Testing Library, MSW.
Global Constraints
- Preserve the existing SSE and persisted-session contracts; this is frontend-only.
- UI chrome and test names stay English; Italian stream content is rendered unchanged except for paragraph separation.
- When Model activity is open, it occupies 40% and the conversation 60% of the app area; the session rail is not rendered.
- The normal composer is white. It is green only after New session begins question entry and while a pending
freetextwidget awaits a response. - Existing widget textareas with
data-awaiting-input="true"remain green whenever rendered. - Run
npx vitest runandnpx tsc -bfromfrontend/before claiming completion.
File structure
frontend/src/shell/ModelActivityPanel.tsxowns activity Markdown normalization and the left panel's width.frontend/src/shell/ModelActivityPanel.test.tsxverifies activity rendering and the sentence-boundary regression.frontend/src/shell/AppShell.tsxowns conditional 40/60 shell layout, hides the right rail, and passes the composer state.frontend/src/shell/AppShell.new-session.test.tsxverifies shell layout switches and question-entry highlighting.frontend/src/shell/SteerInput.tsxrenders the composer according to an explicitawaitingInputprop.frontend/src/shell/SteerInput.test.tsxverifies white default and explicit green composer states.
Task 1: Preserve readable activity message boundaries
Files:
- Modify:
frontend/src/shell/ModelActivityPanel.tsx:10-17 - Modify:
frontend/src/shell/ModelActivityPanel.test.tsx:1-62
Interfaces:
-
Consumes:
useSessionStore((s) => s.transcript), whose entries providetext: string. -
Produces:
formatModelActivity(text: string): string, returning normalized Markdown with blank lines between independent sentences concatenated without whitespace. -
Step 1: Write the failing regression test
Add this test after
paragraphs stay separated as distinct blocks: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"); }); -
Step 2: Run the focused test to verify it fails
Run:
npx vitest run src/shell/ModelActivityPanel.test.tsx -t "concatenated after sentence punctuation"Expected: FAIL because the three strings are rendered as one paragraph and exact individual text matches cannot be found.
-
Step 3: Add the minimal Markdown normalization
In
formatModelActivity, immediately after newline normalization, add the punctuation rule below. It targets only a sentence-ending./!/?immediately followed by an uppercase Italian/Latin letter, which is the malformed streamed-update signature; it does not affect ordinary spaces, lowercase continuations, or Markdown lists.return text .replace(/\r\n?/g, "\n") .replace(/([.!?])(?=[A-ZÀ-ÖØ-Þ])/g, "$1\n\n") .replace(/^[\t ]*[•‣–]\s+/gm, "- ") -
Step 4: Run the focused panel suite
Run:
npx vitest run src/shell/ModelActivityPanel.test.tsxExpected: PASS, including existing Markdown and collapsed-tail behavior.
-
Step 5: Commit the activity formatting task
git add frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/ModelActivityPanel.test.tsx git commit -m "fix(frontend): separate concatenated model activity updates"
Task 2: Switch the shell between normal and 40/60 activity layout
Files:
- Modify:
frontend/src/shell/AppShell.tsx:280-365 - Modify:
frontend/src/shell/AppShell.new-session.test.tsx:1-90
Interfaces:
-
Consumes: local
showActivity: booleanandtoggleActivity()inAppShell. -
Produces:
data-activity-layout="open" | "closed"on the shell root; the activityasidehasw-2/5, the conversation column hasw-3/5, and the right sessionsasiderenders only whenshowActivityis false. -
Step 1: Write the failing layout behavior test
Add this test to
AppShell.new-session.test.tsx:test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => { renderShell(); 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(); }); -
Step 2: Run the focused test to verify it fails
Run:
npx vitest run src/shell/AppShell.new-session.test.tsx -t "40/60 activity and chat layout"Expected: FAIL because the root has no test id/layout marker, Model activity is not reachable without an active session, and the right rail remains mounted.
-
Step 3: Make Model activity available and conditionally lay out the shell
Update
AppShellas follows:<div data-testid="app-shell" data-activity-layout={showActivity ? "open" : "closed"} className="flex h-screen bg-background text-foreground" > {showActivity && <ModelActivityPanel onClose={() => setShowActivity(false)} />} <div className={[ "flex min-w-0 flex-col", showActivity ? "w-3/5 shrink-0" : "flex-1", ].join(" ")}>Remove the
activeSessionId &&condition around the header toggle so it is available in the landing view. Change the panel root inModelActivityPanel.tsxfromw-[30vw] max-w-[30vw]tow-2/5 shrink-0; the panel and conversation widths then exactly fill the shell. Finally, wrap the existing right-session-railasidein!showActivity && (...)so it is unmounted while the activity panel is open. -
Step 4: Run the focused shell suite
Run:
npx vitest run src/shell/AppShell.new-session.test.tsxExpected: PASS, including composer focus and provisional session creation tests.
-
Step 5: Commit the layout task
git add frontend/src/shell/AppShell.tsx frontend/src/shell/ModelActivityPanel.tsx frontend/src/shell/AppShell.new-session.test.tsx git commit -m "feat(frontend): use full width for open model activity"
Task 3: Make composer highlighting explicit and input-driven
Files:
- Modify:
frontend/src/shell/SteerInput.tsx:10-26,112-121 - Modify:
frontend/src/shell/AppShell.tsx:34-45,242-267,344-354 - Modify:
frontend/src/shell/SteerInput.test.tsx:1-74 - Modify:
frontend/src/shell/AppShell.new-session.test.tsx:28-44
Interfaces:
-
Consumes:
AppShelllocalawaitingQuestion: booleanandpendingWidget?.widgetfrom Zustand. -
Produces: optional
SteerInputpropawaitingInput?: boolean; its textarea hasdata-awaiting-input="true"andthot-awaiting-inputexactly when that prop is true. -
Step 1: Write the failing component tests
Add these tests to
SteerInput.test.tsx:test("keeps the composer white by default", () => { render(<SteerInput sessionId={null} />); expect(screen.getByRole("textbox", { name: /new question/i })).not.toHaveAttribute("data-awaiting-input"); expect(screen.getByRole("textbox", { name: /new question/i })).not.toHaveClass("thot-awaiting-input"); }); test("marks the composer as awaiting input only when requested", () => { render(<SteerInput sessionId={null} awaitingInput />); expect(screen.getByRole("textbox", { name: /new question/i })).toHaveAttribute("data-awaiting-input", "true"); expect(screen.getByRole("textbox", { name: /new question/i })).toHaveClass("thot-awaiting-input"); });Extend the existing New-session focus test with:
expect(composer).toHaveAttribute("data-awaiting-input", "true"); expect(composer).toHaveClass("thot-awaiting-input"); -
Step 2: Run the focused tests to verify they fail
Run:
npx vitest run src/shell/SteerInput.test.tsx src/shell/AppShell.new-session.test.tsx -t "composer|New session starts"Expected: FAIL because a landing composer currently always has
data-awaiting-input="true"andSteerInputhas noawaitingInputprop. -
Step 3: Add the explicit question-entry state and prop
Add
awaitingInput?: booleantoSteerInputprops and replace the textarea attributes/classes with: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(" ")}In
AppShell, initializeconst [awaitingQuestion, setAwaitingQuestion] = useState(false). Set it to true instartNewSession; set it to false inbeginSessionCreation,finishSessionCreation,stopSession,doResume, and thesession_exiteffect. Keep it true onfailSessionCreationso the retained question remains visibly ready to retry. Pass the prop to the composer:awaitingInput={awaitingQuestion || pendingWidget?.widget === "freetext"}Do not change
FreetextWidget.tsxorReservedControls.tsx: their rendered textareas already accurately signal a required user response. -
Step 4: Run focused input and shell tests
Run:
npx vitest run src/shell/SteerInput.test.tsx src/shell/AppShell.new-session.test.tsxExpected: PASS. The initial landing composer and active-session steering input are white; New session and a pending freetext gate are green.
-
Step 5: Commit the composer-state task
git add frontend/src/shell/AppShell.tsx frontend/src/shell/SteerInput.tsx frontend/src/shell/SteerInput.test.tsx frontend/src/shell/AppShell.new-session.test.tsx git commit -m "fix(frontend): highlight composer only when input is needed"
Task 4: Verify the integrated frontend change
Files:
- Modify only if verification exposes a TypeScript or test issue in the files listed above.
Interfaces:
-
Consumes: the completed shell, activity-panel, and composer contracts from Tasks 1–3.
-
Produces: validated frontend behavior with no API or persistence changes.
-
Step 1: Run the entire frontend test suite
Run:
npx vitest runExpected: PASS with no failed test files.
-
Step 2: Run the frontend typecheck
Run:
npx tsc -bExpected: exit code 0 and no TypeScript diagnostics.
-
Step 3: Inspect the final working-tree diff
Run:
git diff --check && git status --shortExpected: no whitespace errors. Confirm that only the planned frontend files and this plan/spec are present among this task's changes; preserve all unrelated pre-existing modifications.
-
Step 4: Commit verification-only follow-up, if needed
If Steps 1–3 required a corrective code or test change, stage only that correction and commit it with:
git add <corrected-files> git commit -m "test(frontend): verify activity layout and composer states"If no corrective change was required, do not create an empty commit.