Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions examples/chat/angular/src/app/shell/threads.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
18 changes: 16 additions & 2 deletions examples/chat/python/src/streaming/envelope_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions examples/chat/python/tests/test_envelope_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
29 changes: 29 additions & 0 deletions examples/chat/smoke/CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading