From 07646bc7403f2f63d129252023ed1d19a3c39665 Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Tue, 17 Mar 2026 18:28:27 +0100 Subject: [PATCH] Sprint 6G: operator chat response mode --- BUILD_REPORT.md | 142 ++++++------- REVIEW_REPORT.md | 30 +-- apps/web/app/chat/page.test.tsx | 88 +++++++++ apps/web/app/chat/page.tsx | 156 +++++++++++---- apps/web/app/globals.css | 142 ++++++++++++- apps/web/components/mode-toggle.tsx | 47 +++++ apps/web/components/request-composer.tsx | 16 +- .../web/components/response-composer.test.tsx | 103 ++++++++++ apps/web/components/response-composer.tsx | 187 ++++++++++++++++++ apps/web/components/response-history.test.tsx | 93 +++++++++ apps/web/components/response-history.tsx | 95 +++++++++ apps/web/components/status-badge.tsx | 4 + apps/web/lib/api.test.ts | 43 ++++ apps/web/lib/api.ts | 59 ++++++ apps/web/lib/fixtures.ts | 164 +++++++++++++++ 15 files changed, 1227 insertions(+), 142 deletions(-) create mode 100644 apps/web/app/chat/page.test.tsx create mode 100644 apps/web/components/mode-toggle.tsx create mode 100644 apps/web/components/response-composer.test.tsx create mode 100644 apps/web/components/response-composer.tsx create mode 100644 apps/web/components/response-history.test.tsx create mode 100644 apps/web/components/response-history.tsx diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 6896c60..ea5f49d 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -2,89 +2,87 @@ ## sprint objective -Implement Sprint 6F by extending the AliceBot web shell so approved approvals can be executed from `/approvals` and their resulting execution state can be reviewed from `/approvals` and `/tasks` using only the shipped approval-execution and tool-execution read endpoints. +Implement Sprint 6G by turning `/chat` into a dual-mode operator conversation surface with: + +- assistant response mode backed by `POST /v0/responses` +- governed request mode retained through `POST /v0/approvals/requests` + +The sprint stays inside the shipped backend seams and keeps the two behaviors visibly separate. ## completed work -- extended `apps/web/lib/api.ts` with typed execution support for: - - `POST /v0/approvals/{approval_id}/execute` - - `GET /v0/tool-executions` - - `GET /v0/tool-executions/{execution_id}` -- added fixture-backed execution records in `apps/web/lib/fixtures.ts` so fixture mode now covers: - - approved but not executed - - executed task and execution review -- updated `apps/web/app/approvals/page.tsx` to: - - discover linked execution records for the selected approval - - surface explicit live unavailable state when execution review cannot be loaded - - keep fixture fallback explicit when live API configuration is absent -- updated `apps/web/app/tasks/page.tsx` to: - - read latest execution detail from `task.latest_execution_id` - - fall back to fixture execution detail only when a matching fixture exists - - surface explicit unavailable messaging when a live execution read fails without fixture coverage -- extended `apps/web/components/approval-actions.tsx` to: - - keep approve/reject for pending approvals - - show execute for eligible approved approvals - - show bounded loading, success, failure, and read-only states -- extended `apps/web/components/approval-detail.tsx` and `apps/web/components/task-summary.tsx` with the new bounded `apps/web/components/execution-summary.tsx` -- updated `apps/web/components/task-step-list.tsx` to make execution linkage and blocked reasons clearer inside the existing step timeline -- refined `apps/web/app/globals.css` for the scoped surfaces with stronger containment, calmer grouping, better wrapping behavior, and more stable responsive stacking -- added or updated narrow frontend coverage in: +- updated `apps/web/app/chat/page.tsx` to: + - make assistant mode the default `/chat` state + - add an explicit mode toggle between assistant chat and governed request submission + - seed fixture history only when live API configuration is absent + - keep the side rail mode-specific so supporting guidance stays relevant instead of noisy +- added `apps/web/components/mode-toggle.tsx` as a stable two-state switch with clear labeling and active-state emphasis +- added `apps/web/components/response-composer.tsx` to: + - submit normal assistant questions through `POST /v0/responses` + - keep thread identity explicit + - provide explicit fixture preview fallback when live API configuration is absent +- added `apps/web/components/response-history.tsx` to show bounded assistant history with: + - operator prompt + - assistant reply + - model metadata + - compile and response trace summaries + - direct links into `/traces` +- refined `apps/web/components/request-composer.tsx` so governed mode reads as an intentional approval-gated workflow instead of a chat-like surface +- extended `apps/web/lib/api.ts` with typed assistant-response submission support for `POST /v0/responses` +- extended `apps/web/lib/fixtures.ts` with assistant response fixtures, fixture trace coverage, and deterministic preview entries +- refined `apps/web/app/globals.css` for the scoped `/chat` surface with: + - stronger hierarchy + - calmer spacing + - bounded history panels + - more deliberate prompt/reply grouping + - safer wrapping for long ids, trace references, and body text + - cleaner mobile stacking for the mode switch and chat workspace +- added narrow frontend coverage in: - `apps/web/lib/api.test.ts` - - `apps/web/components/approval-actions.test.tsx` - - `apps/web/components/execution-summary.test.tsx` + - `apps/web/app/chat/page.test.tsx` + - `apps/web/components/response-composer.test.tsx` + - `apps/web/components/response-history.test.tsx` ## incomplete work - no scoped sprint deliverables remain incomplete in code - intentionally not added: - backend changes - - new routes - - execution mutation beyond the shipped approval execute seam - - execution filtering, search, or pagination - - broader task workflow redesign outside `/approvals` and `/tasks` + - thread browsing or thread creation UI + - auth changes + - new routes outside `/chat` + - hidden tool routing or autonomous action behavior -## files changed +## exact /chat files and components updated -- `apps/web/app/approvals/page.tsx` -- `apps/web/app/tasks/page.tsx` +- `apps/web/app/chat/page.tsx` +- `apps/web/app/chat/page.test.tsx` - `apps/web/app/globals.css` -- `apps/web/components/approval-actions.tsx` -- `apps/web/components/approval-detail.tsx` -- `apps/web/components/task-summary.tsx` -- `apps/web/components/task-step-list.tsx` +- `apps/web/components/request-composer.tsx` +- `apps/web/components/response-composer.tsx` +- `apps/web/components/response-history.tsx` +- `apps/web/components/mode-toggle.tsx` - `apps/web/components/status-badge.tsx` -- `apps/web/components/execution-summary.tsx` - `apps/web/lib/api.ts` - `apps/web/lib/fixtures.ts` - `apps/web/lib/api.test.ts` -- `apps/web/components/approval-actions.test.tsx` -- `apps/web/components/execution-summary.test.tsx` +- `apps/web/components/response-composer.test.tsx` +- `apps/web/components/response-history.test.tsx` - `BUILD_REPORT.md` ## route backing mode -- `/approvals` is: - - live-API-backed for approval list/detail and linked execution review when API configuration is present +- assistant mode in `/chat` is: + - live-API-backed when API configuration is present - fixture-backed when API configuration is absent - - explicitly unavailable for linked execution review when live execution reads fail -- `/tasks` is: - - live-API-backed for task detail, step detail, and latest execution review when API configuration is present +- governed request mode in `/chat` is: + - live-API-backed when API configuration is present - fixture-backed when API configuration is absent - - mixed only when a live task falls back to fixture execution detail ## backend endpoints consumed -- `POST /v0/approvals/{approval_id}/execute` -- `GET /v0/tool-executions` -- `GET /v0/tool-executions/{execution_id}` -- existing carried-forward reads already used by the shell: - - `GET /v0/approvals` - - `GET /v0/approvals/{approval_id}` - - `POST /v0/approvals/{approval_id}/approve` - - `POST /v0/approvals/{approval_id}/reject` - - `GET /v0/tasks` - - `GET /v0/tasks/{task_id}` - - `GET /v0/tasks/{task_id}/steps` +- `POST /v0/responses` +- `POST /v0/approvals/requests` ## exact commands run @@ -96,19 +94,22 @@ Implement Sprint 6F by extending the AliceBot web shell so approved approvals ca - lint result: PASS - test result: PASS - - `4` test files passed - - `20` tests passed + - `7` test files passed + - `28` tests passed - build result: PASS ## desktop and mobile visual verification notes - no browser-driven visual QA pass was executed in this turn - desktop note: - - code inspection indicates `/approvals` and `/tasks` now use stronger internal grouping for action handling and execution review - - ids, badges, and payload snapshots have explicit wrapping and overflow handling inside bounded cards + - assistant mode now presents the composer and bounded response history as two coordinated panels instead of one long undifferentiated form + - the mode switch is visible near the page header and reads as a stable route-level decision rather than an inline afterthought + - response prompt, reply, ids, and trace summaries all use explicit containment styles with overflow wrapping + - live-configured `/chat` now starts empty in both modes instead of showing synthetic fixture history - mobile note: - - the shared shell still collapses the split layouts to one column below the existing breakpoint - - execution review, action bars, and buttons now stack into full-width rows to preserve containment on narrow screens + - the mode switch collapses to one column below the existing breakpoint + - the assistant workspace collapses from a two-panel layout to one column so the composer remains primary and the history panel follows cleanly + - buttons continue to expand to full width on narrow screens to avoid cramped action rows ## blockers/issues @@ -117,14 +118,15 @@ Implement Sprint 6F by extending the AliceBot web shell so approved approvals ca ## recommended next step -Run a browser-based QA pass against a live configured backend to validate: -- the execute transition from approved to executed or blocked -- the exact empty/unavailable messaging in live failure cases -- the density of output snapshots on long real-world payloads +Run a browser-based QA pass against both assistant mode and governed mode to validate: + +- real long-form assistant replies in the bounded history panel +- mode-switch readability and perceived hierarchy on tablet widths +- trace-link destinations against a live configured backend ## intentionally deferred after this sprint +- thread browsing, thread create flows, or any broader conversation management UI +- backend changes beyond the shipped `/v0/responses` and `/v0/approvals/requests` seams - any Gmail, Calendar, auth, runner, or broader workflow expansion -- any execution list filters, sorting controls, or search UI -- any task-step mutation UI beyond existing backend reads -- any redesign outside the scoped `/approvals` and `/tasks` review surfaces +- redesign of unrelated routes outside the scoped `/chat` surface diff --git a/REVIEW_REPORT.md b/REVIEW_REPORT.md index 04d781d..0d67e0a 100644 --- a/REVIEW_REPORT.md +++ b/REVIEW_REPORT.md @@ -6,20 +6,20 @@ PASS ## criteria met -- The sprint stayed a UI sprint and did not widen backend scope. The implementation remains confined to the web shell and uses only the shipped approval/task/execution seams. -- The UI can trigger `POST /v0/approvals/{approval_id}/execute` for eligible approved approvals through `apps/web/lib/api.ts` and `apps/web/components/approval-actions.tsx`. -- The UI can show resulting execution state using existing execution and task reads in `apps/web/app/approvals/page.tsx`, `apps/web/app/tasks/page.tsx`, `apps/web/components/approval-detail.tsx`, `apps/web/components/task-summary.tsx`, `apps/web/components/task-step-list.tsx`, and `apps/web/components/execution-summary.tsx`. -- `/approvals` and `/tasks` make execution state understandable without widening backend scope. Loading, success, blocked/failure, empty, and unavailable states are all explicitly surfaced. -- When API configuration is absent, execution controls degrade to explicit fixture/read-only behavior rather than broken interaction. -- The sprint stayed within the listed in-scope screens, components, and files. -- `DESIGN_SYSTEM.md` was followed materially. The execution controls and review surfaces remain bounded and consistent with the existing operator-shell tone. -- `BUILD_REPORT.md` is aligned with the implemented sprint scope and now reflects the current verification totals. +- The sprint stayed a UI sprint and did not widen backend scope. The implementation remains confined to the web shell and uses only the shipped seams `POST /v0/responses` and `POST /v0/approvals/requests`. +- `/chat` supports assistant response mode via `POST /v0/responses` through `apps/web/lib/api.ts` and `apps/web/components/response-composer.tsx`. +- `/chat` retains governed request mode via the existing approval-request seam through `apps/web/components/request-composer.tsx`. +- The mode switch is explicit and understandable. `apps/web/components/mode-toggle.tsx` keeps assistant and governed modes visibly separate. +- Assistant replies and trace summaries are visible in bounded history panels via `apps/web/components/response-history.tsx`. +- Fixture fallback is now explicit and correctly scoped to the no-config path. Live-configured `/chat` starts empty in both modes instead of showing seeded synthetic history. This is enforced in `apps/web/app/chat/page.tsx` and covered by `apps/web/app/chat/page.test.tsx`. +- The sprint stayed within the exact in-scope files and components listed in the sprint packet. +- The UI continues to follow `DESIGN_SYSTEM.md` materially. The `/chat` surface remains restrained, bounded, and readable on the inspected responsive layouts. +- `BUILD_REPORT.md` now matches the implemented route-backing behavior and current verification totals. - Verification passed in `apps/web`: - `npm run lint` - `npm test` - `npm run build` - - current totals: `4` test files, `20` tests -- `next build` did not leave tracked churn in `apps/web/tsconfig.json` or `apps/web/next-env.d.ts`. + - current totals: `7` test files, `28` tests ## criteria missed @@ -27,15 +27,15 @@ PASS ## quality issues -- No blocking quality issues found in the current Sprint 6F implementation. +- No blocking quality issues found in the current Sprint 6G implementation. ## regression risks -- Residual risk is limited to live-data wording and density because the visual notes are still based on code inspection rather than a browser QA pass against a configured backend. That does not block sprint acceptance. +- Residual risk is limited to browser-level presentation because no live browser QA pass was executed in this review cycle. That does not block sprint acceptance. ## docs issues -- No blocking docs issues remain for Sprint 6F. +- No blocking docs issues remain for Sprint 6G. ## should anything be added to RULES.md? @@ -47,5 +47,5 @@ PASS ## recommended next action -- Sprint 6F can be considered review-passed. -- Next follow-up should be a browser-based QA pass against a live configured backend to validate the approved-to-executed or blocked transition and the exact operator-facing wording in live failure cases. +- Sprint 6G can be considered review-passed. +- Next follow-up should be a browser-based QA pass against a live configured backend to validate long-form assistant replies, mode-switch hierarchy on tablet widths, and trace-link destinations. diff --git a/apps/web/app/chat/page.test.tsx b/apps/web/app/chat/page.test.tsx new file mode 100644 index 0000000..2fcf424 --- /dev/null +++ b/apps/web/app/chat/page.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import ChatPage from "./page"; + +const { getApiConfigMock, hasLiveApiConfigMock } = vi.hoisted(() => ({ + getApiConfigMock: vi.fn(), + hasLiveApiConfigMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + "aria-current": ariaCurrent, + }: { + href: string; + children: React.ReactNode; + className?: string; + "aria-current"?: string; + }) => ( + + {children} + + ), +})); + +vi.mock("../../lib/api", async () => { + const actual = await vi.importActual("../../lib/api"); + return { + ...actual, + getApiConfig: getApiConfigMock, + hasLiveApiConfig: hasLiveApiConfigMock, + }; +}); + +describe("ChatPage", () => { + beforeEach(() => { + getApiConfigMock.mockReset(); + hasLiveApiConfigMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("does not seed fixture assistant history when live API configuration is present", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + render(await ChatPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Live submission enabled")).toBeInTheDocument(); + expect(screen.getByText("No assistant replies yet")).toBeInTheDocument(); + expect(screen.queryByText("Fixture response preview")).not.toBeInTheDocument(); + expect(screen.queryByText(/What do I need to know about the last Vitamin D request/i)).not.toBeInTheDocument(); + }); + + it("does not seed fixture governed-request history when live API configuration is present", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + + render( + await ChatPage({ + searchParams: Promise.resolve({ + mode: "request", + }), + }), + ); + + expect(screen.getByText("Live submission enabled")).toBeInTheDocument(); + expect(screen.getByText("No governed requests yet")).toBeInTheDocument(); + expect(screen.queryByText("Fixture preview")).not.toBeInTheDocument(); + expect(screen.queryByText(/place_order \/ supplements/i)).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/chat/page.tsx b/apps/web/app/chat/page.tsx index b81b950..0f59d38 100644 --- a/apps/web/app/chat/page.tsx +++ b/apps/web/app/chat/page.tsx @@ -1,67 +1,139 @@ +import { ModeToggle, type ChatMode } from "../../components/mode-toggle"; import { PageHeader } from "../../components/page-header"; import { RequestComposer } from "../../components/request-composer"; +import { ResponseComposer } from "../../components/response-composer"; import { SectionCard } from "../../components/section-card"; import { getApiConfig, hasLiveApiConfig } from "../../lib/api"; -import { requestHistoryFixtures } from "../../lib/fixtures"; +import { requestHistoryFixtures, responseHistoryFixtures } from "../../lib/fixtures"; -export default function ChatPage() { +type ChatPageProps = { + searchParams?: Promise>; +}; + +function normalizeMode(value: string | string[] | undefined): ChatMode { + if (Array.isArray(value)) { + return normalizeMode(value[0]); + } + + return value === "request" ? "request" : "assistant"; +} + +export default async function ChatPage({ searchParams }: ChatPageProps) { + const resolvedSearchParams = searchParams ? await searchParams : undefined; + const mode = normalizeMode(resolvedSearchParams?.mode); const apiConfig = getApiConfig(); const liveModeReady = hasLiveApiConfig(apiConfig); + const initialResponseEntries = liveModeReady ? [] : responseHistoryFixtures; + const initialRequestEntries = liveModeReady ? [] : requestHistoryFixtures; return (
{liveModeReady ? "Live submission enabled" : "Fixture preview mode"} - Approval-request seam only + Responses and approvals stay explicit
} /> + +
- + {mode === "assistant" ? ( + + ) : ( + + )}
- -
    -
  • Requests are submitted directly to `POST /v0/approvals/requests` using shipped payload fields only.
  • -
  • The operator supplies thread and tool identifiers explicitly instead of relying on hidden web-side routing.
  • -
  • Every resulting summary keeps decision, approval linkage, task status, and trace references visible.
  • -
-
+ {mode === "assistant" ? ( + <> + +
    +
  • Questions are submitted directly to `POST /v0/responses` with only the shipped user, thread, and message fields.
  • +
  • The operator still provides thread identity explicitly instead of relying on hidden routing or auto-selected context.
  • +
  • Each reply keeps compile and response trace summaries attached so explainability remains one click away.
  • +
+
+ + +
+
+
Assistant mode
+
Answer questions, summarize state, and explain prior work without submitting an approval request.
+
+
+
Governed mode
+
Submit action-oriented payloads that can create approval and task records through the shipped request seam.
+
+
+
Fallback
+
Fixture previews stay explicit when live API configuration is absent instead of failing silently.
+
+
+
Trace review
+
Compile and response trace IDs remain linked from each reply so the operator can inspect why the answer was produced.
+
+
+
+ + ) : ( + <> + +
    +
  • Requests are submitted directly to `POST /v0/approvals/requests` using shipped payload fields only.
  • +
  • The operator supplies thread and tool identifiers explicitly instead of relying on hidden web-side routing.
  • +
  • Every resulting summary keeps decision, approval linkage, task status, and trace references visible.
  • +
+
- -
-
-
Required
-
Thread ID, tool ID, action, scope
-
-
-
Optional
-
Domain hint, risk hint
-
-
-
Attributes
-
JSON object sent unchanged to the backend
-
-
-
Fallback
-
Fixture preview instead of broken live submission
-
-
-
+ +
+
+
Required
+
Thread ID, tool ID, action, scope
+
+
+
Optional
+
Domain hint, risk hint
+
+
+
Attributes
+
JSON object sent unchanged to the backend
+
+
+
Fallback
+
Fixture preview instead of broken live submission
+
+
+
+ + )}
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 44f4bcf..2736378 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -261,6 +261,7 @@ code, font-size: 0.82rem; line-height: 1; white-space: nowrap; + max-width: 100%; } .shell-main { @@ -321,7 +322,7 @@ code, } .content-grid--wide { - grid-template-columns: minmax(0, 1.55fr) minmax(300px, 0.9fr); + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.86fr); align-items: flex-start; } @@ -346,8 +347,8 @@ code, .section-card { display: grid; - gap: 20px; - padding: 28px; + gap: 18px; + padding: 26px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 252, 248, 0.72)), var(--surface); @@ -500,6 +501,7 @@ code, line-height: 1; text-transform: uppercase; white-space: nowrap; + max-width: 100%; } .status-badge--success { @@ -604,8 +606,8 @@ code, .composer-card { display: grid; - gap: 24px; - padding: 24px; + gap: 22px; + padding: 26px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-xl); @@ -613,6 +615,11 @@ code, backdrop-filter: blur(14px); } +.composer-card--chat-primary, +.section-card--history { + min-height: 100%; +} + .composer-card__header, .detail-stack, .trace-panel, @@ -622,6 +629,23 @@ code, gap: 16px; } +.composer-card__header--tight { + gap: 14px; +} + +.composer-intro { + display: grid; + gap: 8px; +} + +.composer-title { + margin: 0; + font-size: 1.18rem; + font-weight: 600; + letter-spacing: -0.03em; + line-height: 1.2; +} + .governance-banner { display: flex; flex-wrap: wrap; @@ -638,6 +662,63 @@ code, color: var(--text); } +.governance-banner--assistant { + background: rgba(54, 93, 124, 0.06); + border-color: rgba(54, 93, 124, 0.12); +} + +.mode-toggle { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.mode-toggle__item { + display: grid; + gap: 8px; + padding: 18px 20px; + border-radius: 22px; + border: 1px solid rgba(42, 52, 66, 0.08); + background: rgba(255, 252, 248, 0.66); + box-shadow: var(--shadow-md); + transition: + border-color 140ms ease, + background-color 140ms ease, + transform 140ms ease, + box-shadow 140ms ease; +} + +.mode-toggle__item:hover, +.mode-toggle__item:focus-visible { + transform: translateY(-1px); + border-color: var(--border-strong); + background: rgba(255, 255, 255, 0.78); +} + +.mode-toggle__item.is-active { + border-color: rgba(39, 75, 99, 0.2); + background: rgba(255, 255, 255, 0.84); + box-shadow: 0 18px 36px rgba(32, 43, 56, 0.06); +} + +.mode-toggle__label { + font-size: 1rem; + font-weight: 600; + letter-spacing: -0.02em; +} + +.mode-toggle__description { + color: var(--text-soft); + line-height: 1.6; +} + +.chat-workspace { + display: grid; + gap: 24px; + grid-template-columns: minmax(0, 1.04fr) minmax(340px, 0.92fr); + align-items: stretch; +} + .form-field { display: grid; gap: 10px; @@ -684,7 +765,7 @@ code, display: flex; flex-wrap: wrap; gap: 12px; - align-items: center; + align-items: flex-start; justify-content: space-between; } @@ -695,6 +776,7 @@ code, align-items: center; color: var(--text-soft); font-size: 0.9rem; + max-width: 640px; } .history-list, @@ -762,6 +844,7 @@ code, display: flex; flex-wrap: wrap; gap: 10px; + align-items: center; } .split-layout { @@ -772,7 +855,7 @@ code, .list-panel__header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; gap: 14px; } @@ -844,6 +927,12 @@ code, overflow-wrap: anywhere; } +.history-list--scrollable { + max-height: 920px; + overflow: auto; + padding-right: 6px; +} + .inline-link { color: var(--accent); text-decoration: underline; @@ -884,6 +973,34 @@ code, font-size: 0.84rem; line-height: 1.45; overflow-wrap: anywhere; + max-width: 100%; +} + +.conversation-stack { + display: grid; + gap: 12px; +} + +.conversation-block { + display: grid; + gap: 10px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.conversation-block--accent { + background: rgba(245, 248, 252, 0.86); + border-color: rgba(54, 93, 124, 0.12); +} + +.response-copy { + margin: 0; + color: var(--text); + line-height: 1.65; + white-space: pre-wrap; + overflow-wrap: anywhere; } .reason-list { @@ -1061,6 +1178,7 @@ code, .content-grid--wide, .dashboard-grid--detail, .split-layout, + .chat-workspace, .metric-grid, .route-grid { grid-template-columns: 1fr; @@ -1086,6 +1204,11 @@ code, border-radius: 24px; } + .mode-toggle { + grid-template-columns: 1fr; + gap: 12px; + } + .shell-topbar__row, .page-header, .composer-actions, @@ -1126,6 +1249,11 @@ code, .button-secondary { width: 100%; } + + .history-list--scrollable { + max-height: none; + padding-right: 0; + } } @keyframes loading-sheen { diff --git a/apps/web/components/mode-toggle.tsx b/apps/web/components/mode-toggle.tsx new file mode 100644 index 0000000..f5316c5 --- /dev/null +++ b/apps/web/components/mode-toggle.tsx @@ -0,0 +1,47 @@ +import Link from "next/link"; + +export type ChatMode = "assistant" | "request"; + +type ModeToggleProps = { + currentMode: ChatMode; +}; + +const MODE_ITEMS: Array<{ + mode: ChatMode; + label: string; + description: string; +}> = [ + { + mode: "assistant", + label: "Ask the assistant", + description: "Normal ask-and-answer interaction through the shipped response seam.", + }, + { + mode: "request", + label: "Submit a governed request", + description: "Approval-gated action submission through the existing request seam.", + }, +]; + +export function ModeToggle({ currentMode }: ModeToggleProps) { + return ( + + ); +} diff --git a/apps/web/components/request-composer.tsx b/apps/web/components/request-composer.tsx index a42e9e1..06c9b99 100644 --- a/apps/web/components/request-composer.tsx +++ b/apps/web/components/request-composer.tsx @@ -159,13 +159,11 @@ export function RequestComposer({ } return ( -
-
+
+
{liveModeReady ? "Live operator mode" : "Fixture operator mode"} - - Requests stay explicitly governed and recent routing plus request traces remain attached to each submission. - + Requests stay explicitly governed and the resulting approval, task, and trace links remain attached.
@@ -191,10 +189,11 @@ export function RequestComposer({
-
- +
+

Governed request

+

Approval-gated action submission

- Submit the shipped approval-request payload directly. This surface is request-oriented, not a freeform chat transcript. + Submit the shipped approval-request payload directly. This mode is purpose-built for consequential actions, not freeform conversation.

@@ -293,6 +292,7 @@ export function RequestComposer({
+

Recent activity

Recent governed request summaries

Latest submissions stay grouped with decision, approval linkage, task state, and traces.

diff --git a/apps/web/components/response-composer.test.tsx b/apps/web/components/response-composer.test.tsx new file mode 100644 index 0000000..37363b3 --- /dev/null +++ b/apps/web/components/response-composer.test.tsx @@ -0,0 +1,103 @@ +import React from "react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ResponseComposer } from "./response-composer"; + +const { submitAssistantResponseMock } = vi.hoisted(() => ({ + submitAssistantResponseMock: vi.fn(), +})); + +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +vi.mock("../lib/api", async () => { + const actual = await vi.importActual("../lib/api"); + return { + ...actual, + submitAssistantResponse: submitAssistantResponseMock, + }; +}); + +describe("ResponseComposer", () => { + beforeEach(() => { + submitAssistantResponseMock.mockReset(); + }); + + afterEach(() => { + cleanup(); + }); + + it("submits assistant messages through the shipped response endpoint", async () => { + submitAssistantResponseMock.mockResolvedValue({ + assistant: { + event_id: "assistant-event-1", + sequence_no: 3, + text: "You prefer oat milk.", + model_provider: "openai_responses", + model: "gpt-5-mini", + }, + trace: { + compile_trace_id: "compile-trace-1", + compile_trace_event_count: 3, + response_trace_id: "response-trace-1", + response_trace_event_count: 2, + }, + }); + + render( + , + ); + + fireEvent.change(screen.getByLabelText("Ask the assistant"), { + target: { value: "What do I usually take in coffee?" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Ask assistant" })); + + await waitFor(() => { + expect(submitAssistantResponseMock).toHaveBeenCalledWith("https://api.example.com", { + user_id: "user-1", + thread_id: "thread-1", + message: "What do I usually take in coffee?", + }); + }); + + expect(await screen.findByText("You prefer oat milk.")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Open compile trace" })).toHaveAttribute( + "href", + "/traces?trace=compile-trace-1", + ); + expect(screen.getByText(/Assistant reply added successfully/i)).toBeInTheDocument(); + }); + + it("adds an explicit fixture preview when live API configuration is absent", async () => { + render(); + + fireEvent.change(screen.getByLabelText("Ask the assistant"), { + target: { value: "Summarize the latest thread state." }, + }); + fireEvent.click(screen.getByRole("button", { name: "Ask assistant" })); + + expect(submitAssistantResponseMock).not.toHaveBeenCalled(); + expect(await screen.findByText(/Fixture mode generated a preview response only/i)).toBeInTheDocument(); + expect(screen.getByText(/Fixture response preview added/i)).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/response-composer.tsx b/apps/web/components/response-composer.tsx new file mode 100644 index 0000000..f04a951 --- /dev/null +++ b/apps/web/components/response-composer.tsx @@ -0,0 +1,187 @@ +"use client"; + +import type { FormEvent } from "react"; +import { useState } from "react"; + +import type { AssistantResponsePayload, ResponseHistoryEntry } from "../lib/api"; +import { submitAssistantResponse } from "../lib/api"; +import { buildFixtureResponseEntry } from "../lib/fixtures"; +import { ResponseHistory } from "./response-history"; +import { StatusBadge } from "./status-badge"; + +type ResponseComposerProps = { + initialEntries: ResponseHistoryEntry[]; + apiBaseUrl?: string; + userId?: string; + defaultThreadId?: string; +}; + +export function ResponseComposer({ + initialEntries, + apiBaseUrl, + userId, + defaultThreadId, +}: ResponseComposerProps) { + const [threadId, setThreadId] = useState(defaultThreadId ?? ""); + const [message, setMessage] = useState(""); + const [entries, setEntries] = useState(initialEntries); + const [statusText, setStatusText] = useState("Ready to ask the assistant inside the current thread."); + const [statusTone, setStatusTone] = useState<"info" | "success" | "danger">("info"); + const [isSubmitting, setIsSubmitting] = useState(false); + + const liveModeReady = Boolean(apiBaseUrl && userId); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + + const nextThreadId = threadId.trim(); + const nextMessage = message.trim(); + + if (!nextThreadId || !nextMessage) { + setStatusTone("danger"); + setStatusText("Thread ID and a message are both required."); + return; + } + + const payload: AssistantResponsePayload = { + user_id: userId ?? "fixture-user", + thread_id: nextThreadId, + message: nextMessage, + }; + + setStatusTone("info"); + setStatusText( + liveModeReady + ? "Submitting the operator message through the assistant response endpoint..." + : "Preparing a fixture-backed assistant response preview...", + ); + setIsSubmitting(true); + + if (!liveModeReady) { + const entry = buildFixtureResponseEntry({ + threadId: nextThreadId, + message: nextMessage, + }); + setEntries((current) => [entry, ...current]); + setMessage(""); + setStatusTone("success"); + setStatusText( + "Fixture response preview added. Configure the web API base URL and user ID to persist assistant replies and traces.", + ); + setIsSubmitting(false); + return; + } + + try { + const response = await submitAssistantResponse(apiBaseUrl!, payload); + const entry: ResponseHistoryEntry = { + id: response.trace.response_trace_id, + submittedAt: new Date().toISOString(), + source: "live", + threadId: nextThreadId, + message: nextMessage, + assistantText: response.assistant.text, + assistantEventId: response.assistant.event_id, + assistantSequenceNo: response.assistant.sequence_no, + modelProvider: response.assistant.model_provider, + model: response.assistant.model, + summary: + "The reply was returned through the shipped response seam and linked to both compile and response traces.", + trace: { + compileTraceId: response.trace.compile_trace_id, + compileTraceEventCount: response.trace.compile_trace_event_count, + responseTraceId: response.trace.response_trace_id, + responseTraceEventCount: response.trace.response_trace_event_count, + }, + }; + + setEntries((current) => [entry, ...current]); + setMessage(""); + setStatusTone("success"); + setStatusText("Assistant reply added successfully. Linked trace summaries are visible alongside the response."); + } catch (error) { + const detail = error instanceof Error ? error.message : "Request failed"; + setStatusTone("danger"); + setStatusText(`Unable to submit assistant message: ${detail}`); + } finally { + setIsSubmitting(false); + } + } + + return ( +
+
+
+
+ {liveModeReady ? "Live assistant mode" : "Fixture assistant mode"} + + Normal questions go through `POST /v0/responses` while thread identity and trace linkage stay explicit. + +
+ +
+ +

+ Keep the thread explicit so assistant replies stay attached to the intended conversation context. +

+ setThreadId(event.target.value)} + placeholder="Thread UUID" + /> +
+
+ +
+
+ +