diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 796ef0fe..d889348e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -244,6 +244,7 @@ jobs: needs: ci-scope if: github.event_name == 'push' || needs.ci-scope.outputs.examples_chat == 'true' runs-on: ubuntu-latest + timeout-minutes: 35 steps: - uses: actions/checkout@v6.0.2 - uses: actions/setup-node@v6.3.0 @@ -264,7 +265,9 @@ jobs: uses: actions/upload-artifact@v4 with: name: examples-chat-e2e-trace - path: examples/chat/angular/e2e/test-results/ + path: | + test-results/ + examples/chat/angular/e2e/test-results/ retention-days: 7 cockpit-e2e: diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts index 6b2b6b1a..41f6e311 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.spec.ts @@ -1,12 +1,40 @@ +import { signal } from '@angular/core'; import { describe, it, expect, beforeEach } from 'vitest'; import { TestBed } from '@angular/core/testing'; import { provideRouter, Router } from '@angular/router'; +import { LangGraphThreadsAdapter } from '@ngaf/langgraph'; import { DemoShell } from './demo-shell.component'; +function createThreadsAdapterMock() { + const threads = signal([]); + const archivedThreads = signal([]); + return { + threads: threads.asReadonly(), + archivedThreads: archivedThreads.asReadonly(), + refresh: async () => undefined, + getThread: async () => null, + create: async () => 'new-thread', + delete: async () => undefined, + rename: async () => undefined, + archive: async () => undefined, + unarchive: async () => undefined, + pin: async () => undefined, + unpin: async () => undefined, + moveToProject: async () => undefined, + reorderPinned: async () => undefined, + }; +} + +const threadsAdapterProvider = { + provide: LangGraphThreadsAdapter, + useFactory: createThreadsAdapterMock, +}; + describe('DemoShell — mode signal', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ + threadsAdapterProvider, provideRouter([ { path: 'embed', component: DemoShell }, { path: 'popup', component: DemoShell }, @@ -48,6 +76,7 @@ describe('DemoShell — toolbar layout', () => { it('no longer renders the "New conversation" button (removed for tightness)', () => { TestBed.configureTestingModule({ providers: [ + threadsAdapterProvider, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -63,6 +92,7 @@ describe('DemoShell — toolbar layout', () => { it('renders fields without visible per-field labels (tighter toolbar)', () => { TestBed.configureTestingModule({ providers: [ + threadsAdapterProvider, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -85,6 +115,7 @@ describe('DemoShell — toolbar dropdowns use chat-select', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ + threadsAdapterProvider, provideRouter([ { path: 'embed', component: DemoShell }, { path: '', pathMatch: 'full', redirectTo: 'embed' }, @@ -105,3 +136,67 @@ describe('DemoShell — toolbar dropdowns use chat-select', () => { } }); }); + +describe('DemoShell — URL thread sync', () => { + beforeEach(() => { + localStorage.clear(); + TestBed.configureTestingModule({ + providers: [ + threadsAdapterProvider, + provideRouter([ + { path: 'embed', component: DemoShell }, + { path: 'popup', component: DemoShell }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, + ]), + ], + }); + }); + + it('does not clear an agent-created thread id while URL navigation is still pending', () => { + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + threadIdSignal: { (): string | null; set(value: string | null): void }; + }; + + expect(cmp.threadIdSignal()).toBeNull(); + cmp.threadIdSignal.set('thread-created-by-agent'); + fx.detectChanges(); + + expect(cmp.threadIdSignal()).toBe('thread-created-by-agent'); + }); + + it('persists an agent-created thread id for bare mode route fallback', () => { + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + threadIdSignal: { set(value: string | null): void }; + }; + + cmp.threadIdSignal.set('thread-created-by-agent'); + fx.detectChanges(); + + const raw = localStorage.getItem('ngaf-chat-demo:palette'); + expect(raw ? JSON.parse(raw).threadId : null).toBe('thread-created-by-agent'); + }); + + it('falls back to the persisted active thread on bare mode routes', async () => { + localStorage.setItem( + 'ngaf-chat-demo:palette', + JSON.stringify({ threadId: 'persisted-thread' }), + ); + const router = TestBed.inject(Router); + await router.navigateByUrl('/popup'); + + const fx = TestBed.createComponent(DemoShell); + fx.detectChanges(); + + const cmp = fx.componentInstance as unknown as { + threadIdSignal: { (): string | null }; + }; + expect(cmp.threadIdSignal()).toBe('persisted-thread'); + }); +}); diff --git a/examples/chat/angular/src/app/shell/demo-shell.component.ts b/examples/chat/angular/src/app/shell/demo-shell.component.ts index da9bed0e..4bd74d6d 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -8,6 +8,7 @@ import { effect, signal, inject, + untracked, } from '@angular/core'; import { Router, RouterOutlet, NavigationEnd } from '@angular/router'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; @@ -106,24 +107,27 @@ export class DemoShell { } }); - // Refresh threads list whenever the active thread changes (e.g. after - // create or switch) so the panel stays up to date. The effect also - // covers the initial load (fires synchronously on first reactive read). + // Persist and refresh whenever the active thread changes (e.g. after + // create or switch) so bare mode routes can restore the current thread + // and the panel stays up to date. effect(() => { - void this.threadIdSignal(); + const id = this.threadIdSignal(); + this.persistence.write('threadId', id); void this.threadsSvc.refresh(); }); - // URL → signal. When the URL's threadId changes (paste link, back/ - // forward, programmatic navigation), reflect it into threadIdSignal. + // URL → signal. Explicit URL thread ids win (paste link, back/forward, + // programmatic navigation). Bare mode URLs fall back to the persisted + // active thread, which keeps conversations attached across mode routes. // The compare-and-set guard breaks the obvious URL→signal→URL loop: // by the time the signal→URL effect below fires, both values match // and `router.navigate` is skipped. - // URL → signal sync. effect(() => { const urlId = this.urlThreadId(); - if (urlId !== this.threadIdSignal()) { - this.threadIdSignal.set(urlId); + const nextId = urlId ?? untracked(() => this.persistence.read('threadId') ?? null); + const currentId = untracked(() => this.threadIdSignal()); + if (nextId !== currentId) { + this.threadIdSignal.set(nextId); } }); @@ -307,11 +311,12 @@ export class DemoShell { { value: 'material-light', label: 'Material light' }, ]); - /** Active thread id. URL is the source of truth (see urlState above); - * this signal initialises from the URL on construction and is kept in - * sync by the bidirectional effects in the constructor. The agent - * watches this signal directly. */ - protected readonly threadIdSignal = signal(parseUrl(this.router.url).threadId); + /** Active thread id. URL is the source of truth when it contains an + * explicit thread id; bare mode URLs fall back to the last active + * thread so mode switches like `/embed` -> `/popup` preserve context. */ + protected readonly threadIdSignal = signal( + parseUrl(this.router.url).threadId ?? this.persistence.read('threadId') ?? null, + ); /** Title of the currently-selected thread, or 'New chat' if none. The * Python graph writes thread.metadata.title from the first user message @@ -425,6 +430,10 @@ export class DemoShell { private async validateUrlThreadId(threadId: string): Promise { const thread = await this.threadsSvc.getThread(threadId); if (thread) return; + if (this.threadIdSignal() === threadId) { + this.threadIdSignal.set(null); + this.persistence.write('threadId', null); + } await this.router.navigate(['/', this.mode()], { replaceUrl: true }); }