From cc363b5480ef4b75e58b9c400532288ee9d8f6ef Mon Sep 17 00:00:00 2001 From: Sami Rusani Date: Tue, 17 Mar 2026 23:58:58 +0100 Subject: [PATCH] Sprint 6I: chat thread selection and continuity review UI --- BUILD_REPORT.md | 288 +++++------------ apps/web/app/chat/page.test.tsx | 122 ++++++- apps/web/app/chat/page.tsx | 299 ++++++++++++------ apps/web/app/globals.css | 81 ++++- apps/web/components/mode-toggle.tsx | 16 +- apps/web/components/request-composer.test.tsx | 171 ++++++++++ apps/web/components/request-composer.tsx | 54 ++-- .../web/components/response-composer.test.tsx | 18 +- apps/web/components/response-composer.tsx | 59 ++-- apps/web/components/thread-create.test.tsx | 80 +++++ apps/web/components/thread-create.tsx | 139 ++++++++ .../web/components/thread-event-list.test.tsx | 67 ++++ apps/web/components/thread-event-list.tsx | 181 +++++++++++ apps/web/components/thread-list.test.tsx | 63 ++++ apps/web/components/thread-list.tsx | 97 ++++++ apps/web/components/thread-summary.test.tsx | 66 ++++ apps/web/components/thread-summary.tsx | 116 +++++++ apps/web/lib/api.test.ts | 120 +++++++ apps/web/lib/api.ts | 91 ++++++ apps/web/lib/fixtures.ts | 181 +++++++++++ 20 files changed, 1954 insertions(+), 355 deletions(-) create mode 100644 apps/web/components/request-composer.test.tsx create mode 100644 apps/web/components/thread-create.test.tsx create mode 100644 apps/web/components/thread-create.tsx create mode 100644 apps/web/components/thread-event-list.test.tsx create mode 100644 apps/web/components/thread-event-list.tsx create mode 100644 apps/web/components/thread-list.test.tsx create mode 100644 apps/web/components/thread-list.tsx create mode 100644 apps/web/components/thread-summary.test.tsx create mode 100644 apps/web/components/thread-summary.tsx diff --git a/BUILD_REPORT.md b/BUILD_REPORT.md index 7a3a1ab..1f82a31 100644 --- a/BUILD_REPORT.md +++ b/BUILD_REPORT.md @@ -2,7 +2,38 @@ ## sprint objective -Implement Sprint 6H by exposing narrow user-scoped continuity APIs over the shipped continuity store: +Implement Sprint 6I by extending `/chat` with visible thread selection, compact thread creation, and bounded continuity review using only the shipped continuity APIs, while preserving the existing assistant-response and governed-request seams. + +## exact `/chat` files and components updated + +- `apps/web/app/chat/page.tsx` +- `apps/web/app/chat/page.test.tsx` +- `apps/web/app/globals.css` +- `apps/web/components/mode-toggle.tsx` +- `apps/web/components/request-composer.tsx` +- `apps/web/components/request-composer.test.tsx` +- `apps/web/components/response-composer.tsx` +- `apps/web/components/response-composer.test.tsx` +- `apps/web/components/thread-list.tsx` +- `apps/web/components/thread-list.test.tsx` +- `apps/web/components/thread-create.tsx` +- `apps/web/components/thread-create.test.tsx` +- `apps/web/components/thread-summary.tsx` +- `apps/web/components/thread-summary.test.tsx` +- `apps/web/components/thread-event-list.tsx` +- `apps/web/components/thread-event-list.test.tsx` +- `apps/web/lib/api.ts` +- `apps/web/lib/api.test.ts` +- `apps/web/lib/fixtures.ts` +- `BUILD_REPORT.md` + +## continuity data mode + +- live API-backed when the web API base URL and user ID are configured +- fixture-backed for thread selection, thread summary, continuity review, and history when API configuration is absent +- explicit unavailable state for thread creation when API configuration is absent + +## shipped backend continuity endpoints consumed - `POST /v0/threads` - `GET /v0/threads` @@ -10,208 +41,61 @@ Implement Sprint 6H by exposing narrow user-scoped continuity APIs over the ship - `GET /v0/threads/{thread_id}/sessions` - `GET /v0/threads/{thread_id}/events` -The work stays inside backend continuity scope and does not widen response generation, task orchestration, Gmail, or UI behavior. - -## completed work - -- added continuity response contracts and ordering metadata in `apps/api/src/alicebot_api/contracts.py` -- introduced `ThreadCreateInput` -- introduced `ThreadRecord` -- introduced `ThreadCreateResponse` -- introduced `ThreadListSummary` -- introduced `ThreadListResponse` -- introduced `ThreadDetailResponse` -- introduced `ThreadSessionRecord` -- introduced `ThreadSessionListSummary` -- introduced `ThreadSessionListResponse` -- introduced `ThreadEventRecord` -- introduced `ThreadEventListSummary` -- introduced `ThreadEventListResponse` -- introduced continuity ordering constants: - - `THREAD_LIST_ORDER` - - `THREAD_SESSION_LIST_ORDER` - - `THREAD_EVENT_LIST_ORDER` -- added deterministic thread listing support in `apps/api/src/alicebot_api/store.py` with `list_threads()` -- implemented the five scoped continuity endpoints in `apps/api/src/alicebot_api/main.py` -- kept continuity reads user-scoped by reusing the existing RLS-backed `ContinuityStore` -- added unit coverage for create/list/detail/session/event response shape, ordering, and invisible-thread handling -- added Postgres-backed integration coverage for create/list/detail/session/event behavior and cross-user isolation -- added a migration guard asserting the shipped thread created-time index remains present for deterministic continuity review queries - -## exact ordering rules - -- thread list order: `created_at DESC`, then `id DESC` -- thread session list order: `started_at ASC`, then `created_at ASC`, then `id ASC` -- thread event list order: `sequence_no ASC` - -## incomplete work - -- no in-scope code deliverables remain incomplete -- intentionally not added: - - thread rename - - thread archive - - session mutation APIs - - event mutation or deletion behavior - - thread search, pagination, or filtering - - `/chat` UI thread selection or thread creation UX - -## files changed - -- `ARCHITECTURE.md` -- `apps/api/src/alicebot_api/contracts.py` -- `apps/api/src/alicebot_api/store.py` -- `apps/api/src/alicebot_api/main.py` -- `tests/unit/test_20260310_0001_foundation_continuity.py` -- `tests/unit/test_events.py` -- `tests/integration/test_continuity_api.py` -- `BUILD_REPORT.md` +## additional existing seams preserved + +- `POST /v0/responses` +- `POST /v0/approvals/requests` + +## completed UI work + +- replaced the raw manual thread-ID-first assistant flow with a selected-thread-driven `/chat` surface +- added a bounded right-rail continuity stack: + - visible thread list + - compact thread-create card + - selected-thread identity summary + - bounded recent continuity review for sessions and events +- updated assistant mode to submit against the selected thread instead of a typed UUID field +- updated governed mode to reuse the selected thread explicitly while keeping tool/action/scope controls unchanged +- preserved thread continuity across mode switches by carrying the selected thread in the route query +- added fixture continuity data so fallback states remain explicit and readable when the live API is not configured +- tightened spacing, containment, overflow handling, and responsive stacking for long IDs, pills, and continuity cards + +## exact commands run + +- `cd apps/web && npm run lint` +- `cd apps/web && npm test` +- `cd apps/web && npm run build` + +## verification results + +- `npm run lint`: PASS +- `npm test`: PASS (`40` tests) +- `npm run build`: PASS + +## concise desktop visual verification notes + +- `/chat` now reads with a clearer hierarchy: composer/history on the left, continuity selection/review on the right +- selected thread identity is repeated in a controlled way at the page header, composer, and summary card so context stays explicit without relying on a raw UUID input +- long thread IDs and metadata pills wrap inside their containers instead of leaking outside cards +- continuity review remains bounded and readable rather than becoming a transcript-style event dump + +## concise mobile visual verification notes + +- the wide layout collapses to one column at the existing responsive breakpoints +- thread review subpanels collapse from two columns to one column on small screens +- composer actions and secondary buttons expand to full width on mobile to avoid cramped wrapping +- selected-thread banners and summary toplines stack cleanly instead of forcing horizontal overflow + +## blockers or issues -## tests run - -- `./.venv/bin/python -m pytest tests/unit/test_events.py tests/unit/test_20260310_0001_foundation_continuity.py tests/integration/test_continuity_api.py` - - result: partial pass, then blocked by sandbox-local Postgres access for integration setup -- `./.venv/bin/python -m pytest tests/unit/test_main.py -q` - - result: PASS (`41` passed) -- `./.venv/bin/python -m pytest tests/integration/test_continuity_api.py` - - result: PASS (`2` passed) -- `./.venv/bin/python -m pytest tests/unit` - - result: PASS (`454` passed) -- `./.venv/bin/python -m pytest tests/integration` - - result: PASS (`145` passed) - -## example thread create response - -```json -{ - "thread": { - "id": "30000000-0000-4000-8000-000000000003", - "title": "Gamma thread", - "created_at": "2026-03-17T10:00:00+00:00", - "updated_at": "2026-03-17T10:00:00+00:00" - } -} -``` - -## example thread list response - -```json -{ - "items": [ - { - "id": "30000000-0000-4000-8000-000000000003", - "title": "Gamma thread", - "created_at": "2026-03-17T10:00:00+00:00", - "updated_at": "2026-03-17T10:00:00+00:00" - }, - { - "id": "00000000-0000-4000-8000-000000000002", - "title": "Beta thread", - "created_at": "2026-03-17T09:00:00+00:00", - "updated_at": "2026-03-17T09:00:00+00:00" - }, - { - "id": "00000000-0000-4000-8000-000000000001", - "title": "Alpha thread", - "created_at": "2026-03-17T09:00:00+00:00", - "updated_at": "2026-03-17T09:00:00+00:00" - } - ], - "summary": { - "total_count": 3, - "order": ["created_at_desc", "id_desc"] - } -} -``` - -## example thread detail response - -```json -{ - "thread": { - "id": "00000000-0000-4000-8000-000000000002", - "title": "Beta thread", - "created_at": "2026-03-17T09:00:00+00:00", - "updated_at": "2026-03-17T09:00:00+00:00" - } -} -``` - -## example thread-session list response - -```json -{ - "items": [ - { - "id": "10000000-0000-4000-8000-000000000001", - "thread_id": "00000000-0000-4000-8000-000000000002", - "status": "completed", - "started_at": "2026-03-17T09:00:00+00:00", - "ended_at": "2026-03-17T09:05:00+00:00", - "created_at": "2026-03-17T09:00:00+00:00" - }, - { - "id": "10000000-0000-4000-8000-000000000002", - "thread_id": "00000000-0000-4000-8000-000000000002", - "status": "active", - "started_at": "2026-03-17T10:00:00+00:00", - "ended_at": null, - "created_at": "2026-03-17T10:00:00+00:00" - } - ], - "summary": { - "thread_id": "00000000-0000-4000-8000-000000000002", - "total_count": 2, - "order": ["started_at_asc", "created_at_asc", "id_asc"] - } -} -``` - -## example thread-event list response - -```json -{ - "items": [ - { - "id": "20000000-0000-4000-8000-000000000002", - "thread_id": "00000000-0000-4000-8000-000000000002", - "session_id": "10000000-0000-4000-8000-000000000002", - "sequence_no": 1, - "kind": "message.user", - "payload": {"text": "Hello"}, - "created_at": "2026-03-17T10:00:00+00:00" - }, - { - "id": "20000000-0000-4000-8000-000000000001", - "thread_id": "00000000-0000-4000-8000-000000000002", - "session_id": "10000000-0000-4000-8000-000000000002", - "sequence_no": 2, - "kind": "message.assistant", - "payload": {"text": "Hello back"}, - "created_at": "2026-03-17T10:01:00+00:00" - } - ], - "summary": { - "thread_id": "00000000-0000-4000-8000-000000000002", - "total_count": 2, - "order": ["sequence_no_asc"] - } -} -``` - -## blockers/issues - -- no remaining code blockers inside sprint scope -- local sandbox execution initially blocked localhost Postgres access for integration setup; rerunning the Postgres-backed suite with unrestricted execution resolved verification -- `ARCHITECTURE.md` was updated after review so the documented runtime/API inventory matches the shipped `/v0/threads*` continuity surface - -## recommended next step - -Use these continuity endpoints in a follow-up `/chat` sprint so the operator can create a thread, browse visible threads, and load thread history without typing raw thread ids manually. +- no remaining blockers inside sprint scope +- no screenshot automation or browser capture was run during this pass; visual notes are based on the implemented layout, CSS breakpoints, and successful web build/test verification ## intentionally deferred after this sprint -- all UI work for thread selection, thread creation, and session history presentation -- any new session write endpoint -- any event rewrite, delete, or archive behavior -- any broader chat orchestration changes -- any Gmail, Calendar, auth, approval, task, execution, or runner scope expansion +- thread rename, archive, search, filter, and pagination +- full transcript tooling or unbounded event review +- thread-event mutation UI +- new backend continuity endpoints +- unrelated route redesigns +- Gmail, Calendar, auth, runner, connector, task-orchestration, or broader workflow scope expansion diff --git a/apps/web/app/chat/page.test.tsx b/apps/web/app/chat/page.test.tsx index 2fcf424..d511221 100644 --- a/apps/web/app/chat/page.test.tsx +++ b/apps/web/app/chat/page.test.tsx @@ -4,9 +4,20 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import ChatPage from "./page"; -const { getApiConfigMock, hasLiveApiConfigMock } = vi.hoisted(() => ({ +const { + getApiConfigMock, + getThreadDetailMock, + getThreadEventsMock, + getThreadSessionsMock, + hasLiveApiConfigMock, + listThreadsMock, +} = vi.hoisted(() => ({ getApiConfigMock: vi.fn(), + getThreadDetailMock: vi.fn(), + getThreadEventsMock: vi.fn(), + getThreadSessionsMock: vi.fn(), hasLiveApiConfigMock: vi.fn(), + listThreadsMock: vi.fn(), })); vi.mock("next/link", () => ({ @@ -27,19 +38,34 @@ vi.mock("next/link", () => ({ ), })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ + push: vi.fn(), + refresh: vi.fn(), + }), +})); + vi.mock("../../lib/api", async () => { const actual = await vi.importActual("../../lib/api"); return { ...actual, getApiConfig: getApiConfigMock, + getThreadDetail: getThreadDetailMock, + getThreadEvents: getThreadEventsMock, + getThreadSessions: getThreadSessionsMock, hasLiveApiConfig: hasLiveApiConfigMock, + listThreads: listThreadsMock, }; }); describe("ChatPage", () => { beforeEach(() => { getApiConfigMock.mockReset(); + getThreadDetailMock.mockReset(); + getThreadEventsMock.mockReset(); + getThreadSessionsMock.mockReset(); hasLiveApiConfigMock.mockReset(); + listThreadsMock.mockReset(); }); afterEach(() => { @@ -54,10 +80,38 @@ describe("ChatPage", () => { defaultToolId: "tool-1", }); hasLiveApiConfigMock.mockReturnValue(true); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockResolvedValue({ + thread: { + id: "thread-1", + title: "Gamma thread", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); render(await ChatPage({ searchParams: Promise.resolve({}) })); - expect(screen.getByText("Live submission enabled")).toBeInTheDocument(); + expect(screen.getByText("Live continuity enabled")).toBeInTheDocument(); + expect(screen.getByText("Selected: Gamma thread")).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(); @@ -71,6 +125,33 @@ describe("ChatPage", () => { defaultToolId: "tool-1", }); hasLiveApiConfigMock.mockReturnValue(true); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockResolvedValue({ + thread: { + id: "thread-1", + title: "Gamma thread", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + }); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); render( await ChatPage({ @@ -80,9 +161,44 @@ describe("ChatPage", () => { }), ); - expect(screen.getByText("Live submission enabled")).toBeInTheDocument(); + expect(screen.getByText("Live continuity 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(); }); + + it("shows an unavailable continuity status when live continuity reads fail", async () => { + getApiConfigMock.mockReturnValue({ + apiBaseUrl: "https://api.example.com", + userId: "user-1", + defaultThreadId: "thread-1", + defaultToolId: "tool-1", + }); + hasLiveApiConfigMock.mockReturnValue(true); + listThreadsMock.mockResolvedValue({ + items: [ + { + id: "thread-1", + title: "Gamma thread", + created_at: "2026-03-17T10:00:00Z", + updated_at: "2026-03-17T10:00:00Z", + }, + ], + summary: { total_count: 1, order: ["created_at_desc", "id_desc"] }, + }); + getThreadDetailMock.mockRejectedValue(new Error("detail failed")); + getThreadSessionsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + getThreadEventsMock.mockResolvedValue({ + items: [], + summary: { thread_id: "thread-1", total_count: 0, order: [] }, + }); + + render(await ChatPage({ searchParams: Promise.resolve({}) })); + + expect(screen.getByText("Continuity unavailable")).toBeInTheDocument(); + expect(screen.getByText("Summary unavailable")).toBeInTheDocument(); + }); }); diff --git a/apps/web/app/chat/page.tsx b/apps/web/app/chat/page.tsx index 0f59d38..082ed55 100644 --- a/apps/web/app/chat/page.tsx +++ b/apps/web/app/chat/page.tsx @@ -2,14 +2,45 @@ 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, responseHistoryFixtures } from "../../lib/fixtures"; +import { ThreadCreate } from "../../components/thread-create"; +import { ThreadEventList } from "../../components/thread-event-list"; +import { ThreadList } from "../../components/thread-list"; +import { ThreadSummary } from "../../components/thread-summary"; +import type { ThreadEventItem, ThreadItem, ThreadSessionItem } from "../../lib/api"; +import { + getApiConfig, + getThreadDetail, + getThreadEvents, + getThreadSessions, + hasLiveApiConfig, + listThreads, +} from "../../lib/api"; +import { + getFixtureThread, + getFixtureThreadEvents, + getFixtureThreadSessions, + requestHistoryFixtures, + responseHistoryFixtures, + threadFixtures, +} from "../../lib/fixtures"; type ChatPageProps = { searchParams?: Promise>; }; +type ContinuitySource = "live" | "fixture" | "unavailable"; + +type ContinuityViewModel = { + threadListSource: ContinuitySource; + continuitySource: ContinuitySource; + unavailableReason?: string; + threads: ThreadItem[]; + selectedThreadId: string; + selectedThread: ThreadItem | null; + sessions: ThreadSessionItem[]; + events: ThreadEventItem[]; +}; + function normalizeMode(value: string | string[] | undefined): ChatMode { if (Array.isArray(value)) { return normalizeMode(value[0]); @@ -18,11 +49,135 @@ function normalizeMode(value: string | string[] | undefined): ChatMode { return value === "request" ? "request" : "assistant"; } +function normalizeThreadId(value: string | string[] | undefined) { + if (Array.isArray(value)) { + return normalizeThreadId(value[0]); + } + + return value?.trim() ?? ""; +} + +function resolveSelectedThreadId( + requestedThreadId: string, + defaultThreadId: string, + threads: ThreadItem[], +) { + const availableIds = new Set(threads.map((thread) => thread.id)); + + if (requestedThreadId && availableIds.has(requestedThreadId)) { + return requestedThreadId; + } + + if (defaultThreadId && availableIds.has(defaultThreadId)) { + return defaultThreadId; + } + + return threads[0]?.id ?? ""; +} + +async function loadFixtureContinuity( + requestedThreadId: string, + defaultThreadId: string, +): Promise { + const selectedThreadId = resolveSelectedThreadId(requestedThreadId, defaultThreadId, threadFixtures); + + return { + threadListSource: "fixture", + continuitySource: "fixture", + threads: threadFixtures, + selectedThreadId, + selectedThread: selectedThreadId ? getFixtureThread(selectedThreadId) : null, + sessions: selectedThreadId ? getFixtureThreadSessions(selectedThreadId) : [], + events: selectedThreadId ? getFixtureThreadEvents(selectedThreadId) : [], + }; +} + +async function loadLiveContinuity( + apiBaseUrl: string, + userId: string, + requestedThreadId: string, + defaultThreadId: string, +): Promise { + try { + const threadResponse = await listThreads(apiBaseUrl, userId); + const threads = threadResponse.items; + const selectedThreadId = resolveSelectedThreadId(requestedThreadId, defaultThreadId, threads); + + if (!selectedThreadId) { + return { + threadListSource: "live", + continuitySource: "live", + threads, + selectedThreadId: "", + selectedThread: null, + sessions: [], + events: [], + }; + } + + const [threadResult, sessionsResult, eventsResult] = await Promise.allSettled([ + getThreadDetail(apiBaseUrl, selectedThreadId, userId), + getThreadSessions(apiBaseUrl, selectedThreadId, userId), + getThreadEvents(apiBaseUrl, selectedThreadId, userId), + ]); + + const unavailableReason = + threadResult.status === "rejected" + ? threadResult.reason instanceof Error + ? threadResult.reason.message + : "Thread detail failed to load." + : sessionsResult.status === "rejected" + ? sessionsResult.reason instanceof Error + ? sessionsResult.reason.message + : "Thread sessions failed to load." + : eventsResult.status === "rejected" + ? eventsResult.reason instanceof Error + ? eventsResult.reason.message + : "Thread events failed to load." + : undefined; + + return { + threadListSource: "live", + continuitySource: unavailableReason ? "unavailable" : "live", + unavailableReason, + threads, + selectedThreadId, + selectedThread: + threadResult.status === "fulfilled" + ? threadResult.value.thread + : threads.find((thread) => thread.id === selectedThreadId) ?? null, + sessions: sessionsResult.status === "fulfilled" ? sessionsResult.value.items : [], + events: eventsResult.status === "fulfilled" ? eventsResult.value.items : [], + }; + } catch (error) { + return { + threadListSource: "unavailable", + continuitySource: "unavailable", + unavailableReason: error instanceof Error ? error.message : "Thread continuity failed to load.", + threads: [], + selectedThreadId: "", + selectedThread: null, + sessions: [], + events: [], + }; + } +} + export default async function ChatPage({ searchParams }: ChatPageProps) { const resolvedSearchParams = searchParams ? await searchParams : undefined; const mode = normalizeMode(resolvedSearchParams?.mode); + const requestedThreadId = normalizeThreadId(resolvedSearchParams?.thread); const apiConfig = getApiConfig(); const liveModeReady = hasLiveApiConfig(apiConfig); + const continuity = liveModeReady + ? await loadLiveContinuity( + apiConfig.apiBaseUrl, + apiConfig.userId, + requestedThreadId, + apiConfig.defaultThreadId, + ) + : await loadFixtureContinuity(requestedThreadId, apiConfig.defaultThreadId); + const initialResponseEntries = liveModeReady ? [] : responseHistoryFixtures; const initialRequestEntries = liveModeReady ? [] : requestHistoryFixtures; @@ -31,16 +186,26 @@ export default async function ChatPage({ searchParams }: ChatPageProps) { - {liveModeReady ? "Live submission enabled" : "Fixture preview mode"} - Responses and approvals stay explicit + + {continuity.continuitySource === "unavailable" + ? "Continuity unavailable" + : liveModeReady + ? "Live continuity enabled" + : "Fixture continuity preview"} + + + {continuity.selectedThread + ? `Selected: ${continuity.selectedThread.title}` + : "Select or create a thread"} + } /> - +
{mode === "assistant" ? ( @@ -48,92 +213,50 @@ export default async function ChatPage({ searchParams }: ChatPageProps) { initialEntries={initialResponseEntries} apiBaseUrl={apiConfig.apiBaseUrl} userId={apiConfig.userId} - defaultThreadId={apiConfig.defaultThreadId} + selectedThreadId={continuity.selectedThreadId} + selectedThreadTitle={continuity.selectedThread?.title} /> ) : ( - + )}
- {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
-
-
-
- - )} + + + + + + +
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 2736378..578d44c 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -279,12 +279,16 @@ code, gap: 24px; } +.page-stack { + gap: 28px; +} + .page-header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; - gap: 18px 24px; + gap: 20px 28px; } .page-header__copy { @@ -347,8 +351,8 @@ code, .section-card { display: grid; - gap: 18px; - padding: 26px; + gap: 20px; + padding: 28px; background: linear-gradient(180deg, rgba(255, 255, 255, 0.5), rgba(255, 252, 248, 0.72)), var(--surface); @@ -365,7 +369,7 @@ code, .section-card__header { display: grid; - gap: 10px; + gap: 12px; } .section-card__title { @@ -566,8 +570,9 @@ code, border-radius: 14px; border: 1px solid transparent; font-weight: 600; - line-height: 1; + line-height: 1.2; text-align: center; + overflow-wrap: anywhere; transition: transform 140ms ease, background-color 140ms ease, @@ -607,7 +612,7 @@ code, .composer-card { display: grid; gap: 22px; - padding: 26px; + padding: 28px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-xl); @@ -647,10 +652,8 @@ code, } .governance-banner { - display: flex; - flex-wrap: wrap; + display: grid; gap: 10px; - align-items: center; padding: 14px 16px; background: rgba(39, 75, 99, 0.06); border: 1px solid rgba(39, 75, 99, 0.12); @@ -675,8 +678,8 @@ code, .mode-toggle__item { display: grid; - gap: 8px; - padding: 18px 20px; + gap: 10px; + padding: 20px 22px; border-radius: 22px; border: 1px solid rgba(42, 52, 66, 0.08); background: rgba(255, 252, 248, 0.66); @@ -714,7 +717,7 @@ code, .chat-workspace { display: grid; - gap: 24px; + gap: 22px; grid-template-columns: minmax(0, 1.04fr) minmax(340px, 0.92fr); align-items: stretch; } @@ -747,6 +750,10 @@ code, border-radius: 18px; color: var(--text); resize: vertical; + transition: + border-color 140ms ease, + box-shadow 140ms ease, + background-color 140ms ease; } .form-field textarea { @@ -754,6 +761,14 @@ code, line-height: 1.6; } +.form-field textarea:focus-visible, +.form-field input:focus-visible { + outline: none; + border-color: rgba(39, 75, 99, 0.26); + box-shadow: 0 0 0 4px rgba(39, 75, 99, 0.08); + background: rgba(255, 255, 255, 0.9); +} + .field-hint { margin: 0; color: var(--text-muted); @@ -802,6 +817,25 @@ code, gap: 14px; } +.selected-thread-panel, +.thread-summary__topline { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.68); + border: 1px solid rgba(42, 52, 66, 0.08); +} + +.selected-thread-panel__copy { + display: grid; + gap: 8px; + min-width: 0; +} + .history-entry__topline, .list-row__topline, .timeline-item__topline, @@ -896,6 +930,10 @@ code, background: var(--accent-soft); } +.list-row[aria-current="page"] { + box-shadow: inset 0 0 0 1px rgba(39, 75, 99, 0.08); +} + .list-row__title { margin: 0; font-size: 0.98rem; @@ -945,6 +983,12 @@ code, gap: 20px; } +.thread-review-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .detail-group { display: grid; gap: 12px; @@ -1043,6 +1087,13 @@ code, overflow-wrap: anywhere; } +.thread-summary__id { + margin: 0; + color: var(--text-soft); + line-height: 1.65; + overflow-wrap: anywhere; +} + .execution-summary { display: grid; gap: 16px; @@ -1212,6 +1263,8 @@ code, .shell-topbar__row, .page-header, .composer-actions, + .selected-thread-panel, + .thread-summary__topline, .list-panel__header, .history-entry__topline, .list-row__topline, @@ -1235,6 +1288,10 @@ code, grid-template-columns: 1fr; } + .thread-review-grid { + grid-template-columns: 1fr; + } + .form-field-group--two-up { grid-template-columns: 1fr; } diff --git a/apps/web/components/mode-toggle.tsx b/apps/web/components/mode-toggle.tsx index f5316c5..4560671 100644 --- a/apps/web/components/mode-toggle.tsx +++ b/apps/web/components/mode-toggle.tsx @@ -4,6 +4,7 @@ export type ChatMode = "assistant" | "request"; type ModeToggleProps = { currentMode: ChatMode; + selectedThreadId?: string; }; const MODE_ITEMS: Array<{ @@ -23,12 +24,23 @@ const MODE_ITEMS: Array<{ }, ]; -export function ModeToggle({ currentMode }: ModeToggleProps) { +export function ModeToggle({ currentMode, selectedThreadId }: ModeToggleProps) { return (