diff --git a/backend/src/bridge/session-bridge.ts b/backend/src/bridge/session-bridge.ts
index c7f418f5..830cade4 100644
--- a/backend/src/bridge/session-bridge.ts
+++ b/backend/src/bridge/session-bridge.ts
@@ -3,6 +3,7 @@ import type { RpcClient } from "../rpc/rpc-client.js";
export type ClientEvent =
| { type: "ui_request"; ui_request: any }
| { type: "text_delta"; text: string }
+ | { type: "activity_delta"; text: string }
| { type: "info"; [k: string]: any }
| { type: "system_event"; [k: string]: any };
@@ -27,6 +28,8 @@ export class SessionBridge {
this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" });
} else if (m.type === "message_update" && m.assistantMessageEvent?.type === "text_delta") {
this.fan({ type: "text_delta", text: m.assistantMessageEvent.delta ?? "" });
+ } else if (m.type === "message_update" && m.assistantMessageEvent?.type === "thinking_delta") {
+ this.fan({ type: "activity_delta", text: m.assistantMessageEvent.delta ?? "" });
} else if (m.type === "text_delta") {
this.fan({ type: "text_delta", text: m.text ?? "" });
// tool_execution_* events are intentionally NOT forwarded: they clutter
diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts
index 0a6fe55c..138272e9 100644
--- a/backend/src/routes/sessions.ts
+++ b/backend/src/routes/sessions.ts
@@ -69,8 +69,7 @@ export function sessionRoutes(
const options = {
provider: s.provider,
model: s.model,
- // Keep the saved preference in the manifest; F1 starts tool-first.
- thinking: "off",
+ thinking: s.thinking,
author: getUser(req).id,
question: b.question,
};
@@ -117,9 +116,7 @@ export function sessionRoutes(
const options = {
provider: saved?.provider,
model: saved?.model,
- // Phase 1 must reach a widget instead of exposing a long reasoning trace.
- // The session keeps its saved preference for later turns.
- thinking: "off",
+ thinking: saved?.thinking ?? settings.thinking,
author: getUser(req).id,
mode: "resume" as const,
};
diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts
index b1a89e2c..4d4f8dab 100644
--- a/backend/test/routes-sessions.test.ts
+++ b/backend/test/routes-sessions.test.ts
@@ -47,6 +47,65 @@ test("POST /sessions usa i settings (workspace/provider/model/thinking) e crea+a
unlinkSync(modelKey);
});
+test("POST /sessions configura Pi con il thinking globale selezionato", async () => {
+ let configured: any;
+ const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} };
+ const runtime = { bridge } as any;
+ const mgr = {
+ createFor: () => runtime,
+ configure: async (_rt: any, options: any) => { configured = options; },
+ start: () => {},
+ teardown: () => {},
+ } as any;
+ const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
+ mgr,
+ thtRunner: {
+ ollamaEnsure: async () => ({ ok: true }),
+ searchPack: async () => {},
+ sessionNew: async () => ({ id: "s-thinking" }),
+ } as any,
+ getSettings: () => ({ workspace: "psd", provider: "zai", model: "glm-5.2", thinking: "high" }) as any,
+ });
+
+ await app.inject({ method: "POST", url: "/sessions", payload: { question: "q" } });
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(configured.thinking).toBe("high");
+});
+
+test("POST /sessions/:id/resume configura Pi con il thinking persistito", async () => {
+ let configured: any;
+ const bridge = { onClientEvent: () => {}, emitClientEvent: () => {} };
+ const runtime = { bridge } as any;
+ const mgr = {
+ get: () => undefined,
+ createFor: () => runtime,
+ configure: async (_rt: any, options: any) => { configured = options; },
+ start: () => {},
+ teardown: () => {},
+ } as any;
+ const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
+ mgr,
+ thtRunner: {
+ ollamaEnsure: async () => ({ ok: true }),
+ sessionShow: async () => ({
+ status: "open",
+ archived: false,
+ provider: "zai",
+ model: "glm-5.2",
+ thinking: "medium",
+ }),
+ reopenSession: async () => {},
+ } as any,
+ getSettings: () => ({ workspace: "psd", thinking: "low" }) as any,
+ });
+
+ await app.inject({ method: "POST", url: "/sessions/s-thinking/resume" });
+ await new Promise((resolve) => setImmediate(resolve));
+
+ expect(configured.thinking).toBe("medium");
+});
+
test("POST /sessions/:id/response inoltra al bridge (no error)", async () => {
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), {
thtRunner: {
diff --git a/backend/test/session-bridge.test.ts b/backend/test/session-bridge.test.ts
index 8ae0dcb5..73d15d94 100644
--- a/backend/test/session-bridge.test.ts
+++ b/backend/test/session-bridge.test.ts
@@ -28,6 +28,20 @@ test("real Pi message_update (assistantMessageEvent text_delta) becomes a text_d
expect(seen).toHaveLength(1);
});
+test("real Pi thinking_delta becomes a dedicated activity_delta to the FE", () => {
+ const { rpc, fire } = fakeRpc();
+ const b = new SessionBridge(rpc);
+ const seen: any[] = [];
+ b.onClientEvent((e) => seen.push(e));
+
+ fire({
+ type: "message_update",
+ assistantMessageEvent: { type: "thinking_delta", contentIndex: 0, delta: "Valuto le ambiguità" },
+ });
+
+ expect(seen).toEqual([{ type: "activity_delta", text: "Valuto le ambiguità" }]);
+});
+
test("extension_ui_request nativo (method:input, title=json) diventa ui_request col descriptor ed è il pendente", () => {
const { rpc, fire } = fakeRpc();
const b = new SessionBridge(rpc);
diff --git a/docs/superpowers/plans/2026-07-14-workflow-ui-regressions.md b/docs/superpowers/plans/2026-07-14-workflow-ui-regressions.md
new file mode 100644
index 00000000..53341a7b
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-14-workflow-ui-regressions.md
@@ -0,0 +1,307 @@
+# Workflow UI Regressions 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:** Restore Phase 1 model activity, make join review read-only, improve CTE presentation, and prevent malformed phase summaries from crashing the UI.
+
+**Architecture:** Preserve the existing Pi→backend→SSE→React pipeline while introducing one explicit `activity_delta` event and one explicit `join-review` widget contract. Enforce model-authored artifact shapes at the gate and retain defensive rendering for legacy payloads.
+
+**Tech Stack:** TypeScript, Fastify, React 18, Zustand, Vitest/Testing Library, Node test runner, Pi gate extension, Docker Compose.
+
+## Global Constraints
+
+- UI strings remain English; persisted workspace content remains in the workspace language.
+- The harness remains the owner of workflow decisions and persistence.
+- Joins are informational and are persisted as a complete set only after `Continue`.
+- `Other — specify` is the only join-editing path and must not persist the rejected proposal.
+- F3 rewrite approval remains automatic with no reviewer widget.
+- No verbatim reasoning is persisted to session artifacts.
+
+---
+
+### Task 1: Restore the Model Activity stream
+
+**Files:**
+- Modify: `backend/test/routes-sessions.test.ts`
+- Modify: `backend/test/session-bridge.test.ts`
+- Modify: `backend/src/routes/sessions.ts`
+- Modify: `backend/src/bridge/session-bridge.ts`
+- Modify: `frontend/src/api/types.ts`
+- Modify: `frontend/src/store/sessionStore.ts`
+- Modify: `frontend/src/store/sessionStore.test.ts`
+- Modify: `frontend/src/shell/ModelActivityPanel.tsx`
+- Modify: `frontend/src/shell/ModelActivityPanel.test.tsx`
+
+**Interfaces:**
+- Produces: `ClientEvent`/`StreamEvent` variant `{ type: "activity_delta"; text: string }`.
+- Produces: Zustand `activity: Entry[]`, consumed by `ModelActivityPanel`.
+
+- [x] **Step 1: Write backend failing tests**
+
+Add route assertions showing create passes the configured `thinking` level and resume passes the
+manifest level. Add a bridge test that emits:
+
+```ts
+rpc.emit("event", {
+ type: "message_update",
+ assistantMessageEvent: { type: "thinking_delta", delta: "reasoning" },
+});
+expect(events).toContainEqual({ type: "activity_delta", text: "reasoning" });
+```
+
+- [x] **Step 2: Run backend tests and verify RED**
+
+Run: `cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts`
+
+Expected: failures show `thinking` is still `off` and no `activity_delta` is emitted.
+
+- [x] **Step 3: Implement backend event/config changes**
+
+Use `thinking: s.thinking` for new sessions and
+`thinking: saved?.thinking ?? settings.thinking` on resume. Extend the bridge event union and map
+nested `thinking_delta` to `activity_delta` without changing final `text_delta` behavior.
+
+- [x] **Step 4: Write frontend failing tests**
+
+Assert `applyEvent({ type: "activity_delta", text: "reasoning" })` appends to `activity` and not
+`transcript`; render the activity panel after applying only `activity_delta` and expect the text.
+
+- [x] **Step 5: Run frontend tests and verify RED**
+
+Run: `cd frontend && npx vitest run src/store/sessionStore.test.ts src/shell/ModelActivityPanel.test.tsx`
+
+Expected: TypeScript/test failures because the event variant and activity state do not exist.
+
+- [x] **Step 6: Implement frontend activity state**
+
+Add the event type, `activity` state, append logic mirroring streamed entry accumulation, reset it
+with the session, and have the panel select `state.activity`.
+
+- [x] **Step 7: Run targeted tests and typechecks**
+
+Run: `cd backend && npx vitest run test/routes-sessions.test.ts test/session-bridge.test.ts && npx tsc --noEmit -p .`
+
+Run: `cd frontend && npx vitest run src/store/sessionStore.test.ts src/shell/ModelActivityPanel.test.tsx && npx tsc -b`
+
+Expected: PASS.
+
+### Task 2: Replace editable join selection with read-only review
+
+**Files:**
+- Modify: `harness/.pi/extensions/gate/builders.js`
+- Modify: `harness/.pi/extensions/gate/__tests__/builders.test.js`
+- Modify: `harness/.pi/extensions/tht-gate.js`
+- Create: `harness/.pi/extensions/gate/__tests__/gate_join_review.test.js`
+- Modify: `harness/.pi/skills/tht-sessione/SKILL.md`
+- Create: `frontend/src/widgets/JoinReviewWidget.tsx`
+- Create: `frontend/src/widgets/JoinReviewWidget.test.tsx`
+- Modify: `frontend/src/widgets/index.ts`
+- Modify: `frontend/src/api/types.ts`
+- Modify: `frontend/src/widgets/registry.test.tsx`
+
+**Interfaces:**
+- Produces: `buildJoinReviewRequest({ id, phase, title, options })` with widget `join-review`.
+- Consumes: options `{ id, label, detail?, rationale? }`.
+- Produces: response `{ id, kind: "join-review", choices: allOptionIds }` on `Continue`.
+
+- [ ] **Step 1: Write failing builder and gate tests**
+
+Assert the builder emits read-only option details, `confirm_label: "Continue"`, and reserved controls.
+Exercise `reviewer_decide` with only `join_modified` decisions; queue a Continue response containing
+all ids and assert every `tht decision add` call occurs. Queue `control:"freetext"` and assert no
+decision is written.
+
+- [ ] **Step 2: Run harness tests and verify RED**
+
+Run: `cd harness && node --test .pi/extensions/gate/__tests__/builders.test.js .pi/extensions/gate/__tests__/gate_join_review.test.js`
+
+Expected: builder/export/widget contract is missing and join calls still emit `multiselect`.
+
+- [ ] **Step 3: Implement builder and gate routing**
+
+Add `buildJoinReviewRequest`. In `reviewer_decide`, detect a non-empty, join-only merit list:
+
+```js
+const joinOnly = opts.length > 0 && opts.every((o) => o.decision.type === "join_modified");
+```
+
+Emit `join-review` with `detail` and `rationale`; after Continue persist all original decisions.
+On free text, return feedback without persistence. Keep all other decisions on `multiselect`.
+
+- [ ] **Step 4: Write failing frontend widget tests**
+
+Render two join cards and assert there are no checkboxes. Click `Continue` and expect all ids in the
+response. Open `Other — specify`, submit correction text, and expect a freetext control response.
+
+- [ ] **Step 5: Run frontend widget tests and verify RED**
+
+Run: `cd frontend && npx vitest run src/widgets/JoinReviewWidget.test.tsx src/widgets/registry.test.tsx`
+
+Expected: widget and registry entry are missing.
+
+- [ ] **Step 6: Implement and register JoinReviewWidget**
+
+Render semantic cards with label, detail, and rationale, one `Continue` primary button, and
+`ReservedControls`. Register `join-review` and extend `WidgetOption` with optional `detail` and
+`rationale` strings.
+
+- [ ] **Step 7: Update model instructions and verify targeted tests**
+
+Document that joins must be a separate join-only `reviewer_decide` call; the reviewer cannot remove
+individual joins and textual corrections require a complete revised proposal.
+
+Run: `cd harness && npm test`
+
+Run: `cd frontend && npx vitest run src/widgets/JoinReviewWidget.test.tsx src/widgets/registry.test.tsx && npx tsc -b`
+
+Expected: PASS.
+
+### Task 3: Improve CTE plan layout and hide inert SQL controls
+
+**Files:**
+- Modify: `frontend/src/viewers/CtePlanViewer.tsx`
+- Modify: `frontend/src/viewers/CtePlanViewer.test.tsx`
+- Modify: `frontend/src/viewers/SqlViewer.tsx`
+- Modify: `frontend/src/viewers/SqlViewer.test.tsx`
+- Modify: `frontend/src/viewers/CteResultViewer.test.tsx`
+
+**Interfaces:**
+- `SqlViewer({ blocks })` shows the layout toggle only when `blocks.length > 1`.
+- CTE plan data shape remains unchanged.
+
+- [ ] **Step 1: Write failing semantic/layout tests**
+
+Assert long filter data is rendered in distinct elements labeled Column, Operator, and Value;
+assert card sections expose stable headings. Assert a one-block SQL viewer has no Horizontal or
+Vertical controls while a two-block viewer retains both.
+
+- [ ] **Step 2: Run tests and verify RED**
+
+Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx`
+
+Expected: structured filter labels are absent and the one-block toggle is present.
+
+- [ ] **Step 3: Implement responsive CTE cards**
+
+Use a bordered header grid, prose blocks with `leading-relaxed`, metadata rows with fixed labels,
+`break-words`/`font-mono` for physical identifiers, and a responsive filter grid such as
+`grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]`. Keep output columns wrapping.
+
+- [ ] **Step 4: Hide the single-block toggle**
+
+Wrap the layout control in `{blocks.length > 1 && (...)}` without changing multi-block state or
+rendering.
+
+- [ ] **Step 5: Run targeted tests and typecheck**
+
+Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx && npx tsc -b`
+
+Expected: PASS.
+
+### Task 4: Harden phase summaries against malformed open questions
+
+**Files:**
+- Modify: `harness/.pi/extensions/gate/artifact-contracts.js`
+- Modify: `harness/.pi/extensions/gate/__tests__/artifact-contracts.test.js`
+- Modify: `harness/.pi/skills/tht-sessione/SKILL.md`
+- Modify: `frontend/src/viewers/artifactV2.ts`
+- Modify: `frontend/src/viewers/PhaseSummaryViewer.tsx`
+- Modify: `frontend/src/viewers/PhaseSummaryViewer.test.tsx`
+- Modify: `frontend/src/viewers/ArtifactView.test.tsx`
+
+**Interfaces:**
+- Gate contract: `open_questions?: string[]`.
+- Frontend legacy normalization: string entries pass through; objects prefer `question`, then
+ `label`; all other values become safe text or are omitted.
+
+- [ ] **Step 1: Write failing gate validation test**
+
+Pass the exact observed payload shape:
+
+```js
+open_questions: [{ label: "pazienti_finale restituisce 0 righe", question: "Verificare i filtri" }]
+```
+
+Expect `ok:false` and an error naming `open_questions[0]`.
+
+- [ ] **Step 2: Run gate test and verify RED**
+
+Run: `cd harness && node --test .pi/extensions/gate/__tests__/artifact-contracts.test.js`
+
+Expected: payload is currently accepted.
+
+- [ ] **Step 3: Implement strict gate validation**
+
+Reject a non-array `open_questions` value and every non-string entry with an indexed error.
+Document the exact array-of-strings shape in the session skill.
+
+- [ ] **Step 4: Write failing frontend resilience test**
+
+Render a phase-summary artifact containing the observed object and assert the screen displays
+`Verificare i filtri` and does not show the ErrorBoundary fallback.
+
+- [ ] **Step 5: Run frontend test and verify RED**
+
+Run: `cd frontend && npx vitest run src/viewers/PhaseSummaryViewer.test.tsx src/viewers/ArtifactView.test.tsx`
+
+Expected: React reports an object child/rendering failure.
+
+- [ ] **Step 6: Implement safe legacy normalization**
+
+Add a small `phaseOpenQuestionText(value: unknown): string | null` helper and map/filter entries
+before rendering. Preserve the strict public TypeScript contract for new v2 payloads.
+
+- [ ] **Step 7: Run targeted tests and typechecks**
+
+Run: `cd harness && npm test`
+
+Run: `cd frontend && npx vitest run src/viewers/PhaseSummaryViewer.test.tsx src/viewers/ArtifactView.test.tsx && npx tsc -b`
+
+Expected: PASS.
+
+### Task 5: Full verification, durable notes, and Docker deployment
+
+**Files:**
+- Modify: `PROJECT_STATE.md`
+- Modify or create under `brain/codebase/` and update `brain/index.md` if the vault is present.
+
+**Interfaces:**
+- Running Compose services must use the newly built `thothii-core:local` and
+ `thothii-frontend:local` image ids.
+
+- [ ] **Step 1: Run all regression suites**
+
+Run: `cd backend && npx vitest run && npx tsc --noEmit -p . && npm run build`
+
+Run: `cd frontend && npx vitest run && npx tsc -b && npm run build`
+
+Run: `cd harness && npm test`
+
+Expected: all pass.
+
+- [ ] **Step 2: Update project state and durable architectural note**
+
+Record the implemented contracts, verification counts, deployment state, and the distinction
+between building an image and recreating a service.
+
+- [ ] **Step 3: Build and recreate Compose services**
+
+Run: `docker compose build`
+
+Run: `docker compose up -d --force-recreate`
+
+Expected: both services are recreated from the new images.
+
+- [ ] **Step 4: Verify deployed containers**
+
+Run: `docker compose ps`
+
+Run: `docker inspect thothii-core thothii-frontend --format '{{.Name}} {{.Image}} {{.State.Health.Status}}'`
+
+Expected: both report the new image ids and `healthy`.
+
+- [ ] **Step 5: Review diff and commit intentionally**
+
+Run: `git diff --check && git status --short && git diff --stat`
+
+Commit only the files in this plan with a scoped message after review.
diff --git a/docs/superpowers/specs/2026-07-14-workflow-ui-regressions-design.md b/docs/superpowers/specs/2026-07-14-workflow-ui-regressions-design.md
new file mode 100644
index 00000000..1d011f0b
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-14-workflow-ui-regressions-design.md
@@ -0,0 +1,68 @@
+# Workflow UI Regressions Design
+
+## Goal
+
+Restore visible model reasoning and make the F4/F6 review experience deterministic,
+readable, and resilient to malformed model-authored artifacts.
+
+## Diagnosed causes
+
+- Session creation and resume force Pi `thinking` to `off`, regardless of the saved setting.
+- The backend bridge forwards assistant `text_delta` events but drops `thinking_delta` events.
+- The Model Activity panel reads final assistant text rather than a dedicated activity stream.
+- F4 joins use the editable `multiselect` contract; `recommended` is not translated into
+ `selected` outside F2, so every join initially appears unchecked.
+- The CTE result viewer passes one SQL block to a viewer that always displays its
+ multi-block Horizontal/Vertical control.
+- CTE plan cards flatten long filters, table names, keys, and rationale into loosely spaced text.
+- The F6 phase-summary payload accepted an object in `open_questions`; React then attempted to
+ render that object directly and the widget error boundary displayed the generic failure message.
+- The F3 auto-approval fix was present in the image tag but not in the running containers because
+ they were built without being recreated.
+
+## Design
+
+### Model activity
+
+Session creation uses the global `thinking` preference and resume uses the persisted manifest
+preference, falling back to the current global setting. `SessionBridge` maps Pi's nested
+`thinking_delta` into a distinct SSE `activity_delta`. The frontend stores that stream separately
+from final assistant text, and Model Activity renders only activity deltas. This prevents internal
+reasoning from leaking into transcript-oriented state while making the panel accurately reflect
+the configured model activity.
+
+### Join review
+
+F4 calls to `reviewer_decide` whose merit decisions are all `join_modified` become a read-only
+`join-review` widget. Each proposed join is rendered as an informational card with its name,
+join expression, and rationale. `Continue` returns every join id; the gate persists all decisions.
+`Other — specify` returns textual feedback without persisting the current proposal, so the model
+must revise and present the complete join set again. Mixed join/non-join calls remain regular
+multiselects, and the skill instructs the model to keep joins in a separate call.
+
+### CTE presentation
+
+The plan viewer uses a responsive card layout: a compact numbered header, purpose and rationale
+as readable prose, aligned metadata rows for dependencies and keys, wrapped table rows, and a
+structured filter grid with separate column/operator/value fields. Output columns remain compact
+wrapping chips. The SQL layout switch is hidden whenever the viewer receives fewer than two blocks.
+
+### Phase-summary resilience
+
+The gate validates that `open_questions` is an array of strings and reports a corrective error to
+the model before emitting a widget. The frontend also normalizes legacy malformed entries to a
+safe string (preferring `question`, then `label`) so old or externally produced payloads cannot
+crash React. The session skill documents the exact `string[]` contract.
+
+### Deployment
+
+After targeted and full regression tests, build both Docker images and recreate the Compose
+services. Verify that the running containers use the newly built image ids and that both health
+checks pass.
+
+## Non-goals
+
+- Persisting verbatim model reasoning in session artifacts.
+- Allowing individual joins to be removed from the join review.
+- Redesigning multi-block SQL comparison behavior.
+- Changing the eight-phase workflow or F3 auto-approval semantics.
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 27ec7eea..aa1fac4c 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -58,6 +58,7 @@ export interface UiResponse {
export type StreamEvent =
| { type: "ui_request"; ui_request: WidgetDescriptor }
| { type: "text_delta"; text: string }
+ | { type: "activity_delta"; text: string }
| { type: "info"; level?: "info" | "warning" | "error"; text: string }
| { type: "system_event"; event: string; [k: string]: unknown };
diff --git a/frontend/src/shell/ModelActivityPanel.test.tsx b/frontend/src/shell/ModelActivityPanel.test.tsx
index 3c7f4f4b..637cd266 100644
--- a/frontend/src/shell/ModelActivityPanel.test.tsx
+++ b/frontend/src/shell/ModelActivityPanel.test.tsx
@@ -6,14 +6,22 @@ import { ModelActivityPanel } from "./ModelActivityPanel";
beforeEach(() => useSessionStore.getState().resetSession());
-test("renders the streamed model transcript", () => {
- useSessionStore.getState().applyEvent({ type: "text_delta", text: "Promoting table dim_patient." });
+test("renders the streamed model activity", () => {
+ useSessionStore.getState().applyEvent({ type: "activity_delta", text: "Promoting table dim_patient." });
render();
expect(screen.getByText(/Promoting table dim_patient/)).toBeInTheDocument();
});
+test("does not treat final assistant text as model reasoning", () => {
+ useSessionStore.getState().applyEvent({ type: "text_delta", text: "Final answer" });
+ render();
+
+ expect(screen.queryByText("Final answer")).not.toBeInTheDocument();
+ expect(screen.getByText("No activity yet.")).toBeInTheDocument();
+});
+
test("renders markdown emphasis instead of literal asterisks", () => {
- useSessionStore.getState().applyEvent({ type: "text_delta", text: "Chiarimento su **fibrillazione atriale**." });
+ useSessionStore.getState().applyEvent({ type: "activity_delta", text: "Chiarimento su **fibrillazione atriale**." });
render();
expect(screen.getByText("fibrillazione atriale").tagName).toBe("STRONG");
});
@@ -28,7 +36,7 @@ test("close button calls onClose", async () => {
const EIGHT = ["para-01", "para-02", "para-03", "para-04", "para-05", "para-06", "para-07", "para-08"].join("\n\n");
test("collapsed shows only the last 5 paragraphs of the model-stream tail", () => {
- useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT });
+ useSessionStore.getState().applyEvent({ type: "activity_delta", text: EIGHT });
render();
expect(screen.getByText("para-08")).toBeInTheDocument();
expect(screen.getByText("para-04")).toBeInTheDocument();
@@ -37,7 +45,7 @@ test("collapsed shows only the last 5 paragraphs of the model-stream tail", () =
});
test("expanding reveals the full stream", async () => {
- useSessionStore.getState().applyEvent({ type: "text_delta", text: EIGHT });
+ useSessionStore.getState().applyEvent({ type: "activity_delta", text: EIGHT });
render();
await userEvent.click(screen.getByRole("button", { name: /show more/i }));
expect(screen.getByText("para-01")).toBeInTheDocument();
@@ -46,7 +54,7 @@ test("expanding reveals the full stream", async () => {
test("paragraphs stay separated as distinct blocks", () => {
useSessionStore
.getState()
- .applyEvent({ type: "text_delta", text: "Primo paragrafo.\n\nSecondo paragrafo." });
+ .applyEvent({ type: "activity_delta", text: "Primo paragrafo.\n\nSecondo paragrafo." });
render();
const first = screen.getByText("Primo paragrafo.");
const second = screen.getByText("Secondo paragrafo.");
@@ -58,7 +66,7 @@ test("paragraphs stay separated as distinct blocks", () => {
test("separates activity updates concatenated after sentence punctuation", () => {
useSessionStore.getState().applyEvent({
- type: "text_delta",
+ type: "activity_delta",
text: "Ambiguità principale risolta.Finestra temporale risolta.Terza ambiguità risolta.",
});
render();
diff --git a/frontend/src/shell/ModelActivityPanel.tsx b/frontend/src/shell/ModelActivityPanel.tsx
index c94da2c0..19d80fa1 100644
--- a/frontend/src/shell/ModelActivityPanel.tsx
+++ b/frontend/src/shell/ModelActivityPanel.tsx
@@ -24,13 +24,13 @@ export function formatModelActivity(text: string): string {
* (refreshing as the stream grows); the expand toggle reveals the full stream. Opened
* on demand from the rotating activity icon. */
export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
- const transcript = useSessionStore((s) => s.transcript);
+ const activity = useSessionStore((s) => s.activity);
const [expanded, setExpanded] = useState(false);
// Blank lines are markdown's paragraph separator — split on them (not on every "\n",
// which would also break single soft-wrapped lines apart) so each block below is a
// real paragraph and the tail cut lands on paragraph boundaries.
- const paragraphs = transcript
+ const paragraphs = activity
.map((e) => e.text)
.join("\n\n")
.split(/\n{2,}/)
diff --git a/frontend/src/store/sessionStore.test.ts b/frontend/src/store/sessionStore.test.ts
index f50eb666..cdeefd0e 100644
--- a/frontend/src/store/sessionStore.test.ts
+++ b/frontend/src/store/sessionStore.test.ts
@@ -15,6 +15,15 @@ test("text_delta accumulates into transcript", () => {
expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("Analisi");
});
+test("activity_delta accumulates separately from the assistant transcript", () => {
+ const s = useSessionStore.getState();
+ s.applyEvent({ type: "activity_delta", text: "Valuto " });
+ s.applyEvent({ type: "activity_delta", text: "le ambiguità" });
+
+ expect(useSessionStore.getState().activity.at(-1)?.text).toBe("Valuto le ambiguità");
+ expect(useSessionStore.getState().transcript).toEqual([]);
+});
+
test("info appends to stepMessages", () => {
useSessionStore.getState().applyEvent({ type: "info", level: "warning", text: "attenzione" });
expect(useSessionStore.getState().stepMessages.at(-1)).toEqual({ level: "warning", text: "attenzione" });
@@ -122,4 +131,5 @@ test("resetSession clears lastUserEntry and stepMessages", () => {
st.resetSession();
expect(useSessionStore.getState().lastUserEntry).toBeNull();
expect(useSessionStore.getState().stepMessages).toEqual([]);
+ expect(useSessionStore.getState().activity).toEqual([]);
});
diff --git a/frontend/src/store/sessionStore.ts b/frontend/src/store/sessionStore.ts
index ebe23e7c..7fd66b90 100644
--- a/frontend/src/store/sessionStore.ts
+++ b/frontend/src/store/sessionStore.ts
@@ -9,6 +9,7 @@ interface Entry {
interface SessionState {
pendingWidget: WidgetDescriptor | null;
transcript: Entry[];
+ activity: Entry[];
toasts: { level: string; text: string }[];
stepMessages: { level: string; text: string }[];
lastUserEntry: { kind: "input" | "choice"; text: string } | null;
@@ -31,6 +32,7 @@ interface SessionState {
const empty = {
pendingWidget: null,
transcript: [] as Entry[],
+ activity: [] as Entry[],
toasts: [] as { level: string; text: string }[],
stepMessages: [] as { level: string; text: string }[],
lastUserEntry: null as { kind: "input" | "choice"; text: string } | null,
@@ -63,6 +65,13 @@ export const useSessionStore = create((set) => ({
// Streamed text means the turn is alive (also covers reattaching mid-turn).
return { transcript: t, agentActive: true };
}
+ if (e.type === "activity_delta") {
+ const activity = [...st.activity];
+ const last = activity.at(-1);
+ if (last) activity[activity.length - 1] = { role: "assistant", text: last.text + e.text };
+ else activity.push({ role: "assistant", text: e.text });
+ return { activity, agentActive: true };
+ }
if (e.type === "info") {
const stepMessages = [...st.stepMessages, { level: e.level ?? "info", text: e.text }];
// An error during the active phase marks that phase red until the next gate.