diff --git a/examples/chat/angular/src/app/shell/threads.service.ts b/examples/chat/angular/src/app/shell/threads.service.ts index 182464197..b76fd2c13 100644 --- a/examples/chat/angular/src/app/shell/threads.service.ts +++ b/examples/chat/angular/src/app/shell/threads.service.ts @@ -74,7 +74,15 @@ export class ThreadsService { await this.refresh(); } - /** Best-effort title from thread metadata; falls back to a truncated id. */ + /** Best-effort title from thread metadata. + * + * The backend writes `metadata.title` from the first user message in a + * thread (see `_maybe_write_thread_title` in the Python graph). Threads + * created but never sent (e.g. via "+ New chat" then abandoned) have + * no title, so we fall back to "Untitled" — easier on the eye than + * the raw `Thread 019e1e98` id prefix, and consistent with how other + * chat apps surface drafts. + */ private toThread(t: SdkThread): Thread { const meta = (t.metadata ?? {}) as { title?: unknown; archived?: unknown; pinned?: unknown; projectId?: unknown }; const customTitle = meta.title; @@ -87,7 +95,7 @@ export class ThreadsService { id: t.thread_id, title: typeof customTitle === 'string' && customTitle.length > 0 ? customTitle - : `Thread ${t.thread_id.slice(0, 8)}`, + : 'Untitled', status: archived ? 'archived' : 'active', pinned, projectId, diff --git a/examples/chat/python/src/streaming/envelope_tool.py b/examples/chat/python/src/streaming/envelope_tool.py index 74270bc8b..1f32af91f 100644 --- a/examples/chat/python/src/streaming/envelope_tool.py +++ b/examples/chat/python/src/streaming/envelope_tool.py @@ -13,7 +13,7 @@ from typing import Optional from langchain_core.tools import tool -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_validator class SurfaceUpdate(BaseModel): @@ -42,11 +42,25 @@ class DataModelUpdate(BaseModel): class A2uiEnvelope(BaseModel): """Single A2UI v1 envelope. Exactly one of the three discriminators - is set per envelope.""" + is set per envelope — the model_validator below enforces this so the + parent LLM cannot emit ambiguous or empty envelopes.""" surfaceUpdate: Optional[SurfaceUpdate] = None beginRendering: Optional[BeginRendering] = None dataModelUpdate: Optional[DataModelUpdate] = None + @model_validator(mode="after") + def _exactly_one_discriminator(self) -> "A2uiEnvelope": + present = sum( + 1 for v in (self.surfaceUpdate, self.beginRendering, self.dataModelUpdate) + if v is not None + ) + if present != 1: + raise ValueError( + f"A2uiEnvelope requires exactly one of " + f"surfaceUpdate / beginRendering / dataModelUpdate; got {present}" + ) + return self + @tool def render_a2ui_surface(envelopes: list[A2uiEnvelope]) -> str: diff --git a/examples/chat/python/tests/test_envelope_tool.py b/examples/chat/python/tests/test_envelope_tool.py index d922763cd..4f848a9db 100644 --- a/examples/chat/python/tests/test_envelope_tool.py +++ b/examples/chat/python/tests/test_envelope_tool.py @@ -32,6 +32,19 @@ def test_a2ui_envelope_accepts_surface_update_field(self): assert e.beginRendering is None assert e.dataModelUpdate is None + def test_a2ui_envelope_rejects_empty(self): + """An envelope with zero discriminators set is rejected.""" + with pytest.raises(ValueError, match="exactly one"): + A2uiEnvelope() + + def test_a2ui_envelope_rejects_multiple_discriminators(self): + """An envelope with two discriminators set is rejected.""" + with pytest.raises(ValueError, match="exactly one"): + A2uiEnvelope( + surfaceUpdate={"surfaceId": "s", "components": []}, + beginRendering={"surfaceId": "s", "root": "r"}, + ) + class TestRenderA2uiSurfaceTool: def test_serializes_envelopes_to_json_string(self): diff --git a/examples/chat/smoke/CHECKLIST.md b/examples/chat/smoke/CHECKLIST.md index d47738921..24cd0e10f 100644 --- a/examples/chat/smoke/CHECKLIST.md +++ b/examples/chat/smoke/CHECKLIST.md @@ -389,3 +389,32 @@ Components NOT yet exercised by the demo (deferred to future media-focused sugge - [ ] At viewport ≤ 768px: chat input + send remain accessible; sidenav does not push the chat horizontally - [ ] No horizontal scrollbar at any tested viewport - [ ] Toggling collapse manually overrides the responsive default until the next breakpoint crossing + +## Projects + +- [ ] Sidenav renders a **PROJECTS** section between Search and RECENT +- [ ] "+ New project" affordance visible at the top of the section +- [ ] Clicking "+ New project" replaces the affordance with an inline input ("New project name", autofocused) +- [ ] Typing a name + Enter creates the project, persists to `localStorage` (`ngaf-chat-demo:projects`), and **auto-selects** it +- [ ] Escape (or blur with empty value) cancels the inline create +- [ ] Selecting a project filters the RECENT list to threads whose `metadata.projectId` matches +- [ ] Selecting an empty project leaves RECENT empty; ARCHIVED section unaffected +- [ ] Clicking the active project row again deselects it (RECENT returns to unfiltered) +- [ ] Active project row is visually distinguished (`data-active`) +- [ ] Hover a project row — kebab fades in +- [ ] Kebab menu order: **Rename**, **Delete** (no Pin/Unpin/Archive — projects are a different surface) +- [ ] **Rename** → inline edit input on the row; Enter commits, Escape cancels; persists across reload +- [ ] **Delete** → confirmation dialog; on confirm, project removed; member threads remain (projectId becomes orphaned and threads fall back to default RECENT) +- [ ] Project state (list + selected id) persists across reload + +## Move thread to project + +- [ ] With at least one project created, open a thread row's kebab in active mode +- [ ] Menu order: **Rename**, **Pin/Unpin**, **Move to project**, **Archive**, **Delete** +- [ ] Clicking "Move to project" opens a second overflow-menu (submenu) anchored to the same kebab +- [ ] Submenu lists `[No project, ...projects]` with the thread's current projectId highlighted +- [ ] Selecting a project moves the thread (PATCH `metadata.projectId`); the thread disappears from the current view (optimistic-hide) +- [ ] After the next sidenav refresh, the thread appears under the new project's filtered list +- [ ] Selecting "No project" clears the thread's projectId; thread returns to default RECENT +- [ ] Move action is idempotent (selecting the current project is a no-op) +- [ ] No `console.error` during project create / rename / delete / move