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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down
95 changes: 95 additions & 0 deletions examples/chat/angular/src/app/shell/demo-shell.component.spec.ts
Original file line number Diff line number Diff line change
@@ -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 },
Expand Down Expand Up @@ -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' },
Expand All @@ -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' },
Expand All @@ -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' },
Expand All @@ -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');
});
});
37 changes: 23 additions & 14 deletions examples/chat/angular/src/app/shell/demo-shell.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
}
});

Expand Down Expand Up @@ -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<string | null>(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<string | null>(
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
Expand Down Expand Up @@ -425,6 +430,10 @@ export class DemoShell {
private async validateUrlThreadId(threadId: string): Promise<void> {
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 });
}

Expand Down
Loading