Skip to content
Merged
12 changes: 12 additions & 0 deletions apps/website/content/docs/agent/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -928,6 +928,12 @@
"description": "Raw LangGraph tool calls (with run-state). Use `toolCalls` for chat rendering.",
"optional": false
},
{
"name": "messageCheckpoints",
"type": "Signal<ReadonlyMap<string, string>>",
"description": "Optional reactive map of `messageId → checkpointId`, computed by\nwalking history once: for each checkpoint, find the most recent\nassistant message present in its `values.messages` and pair them.\nUIs use this to anchor inline checkpoint markers on each assistant\nturn. Missing on adapters that don't compute it.",
"optional": true
},
{
"name": "messages",
"type": "Signal<Message[]>",
Expand Down Expand Up @@ -1292,6 +1298,12 @@
"description": "Raw LangGraph tool calls (with run-state). Use `toolCalls` for chat rendering.",
"optional": false
},
{
"name": "messageCheckpoints",
"type": "Signal<ReadonlyMap<string, string>>",
"description": "Optional reactive map of `messageId → checkpointId`, computed by\nwalking history once: for each checkpoint, find the most recent\nassistant message present in its `values.messages` and pair them.\nUIs use this to anchor inline checkpoint markers on each assistant\nturn. Missing on adapters that don't compute it.",
"optional": true
},
{
"name": "messages",
"type": "WritableSignal<Message[]>",
Expand Down
114 changes: 113 additions & 1 deletion apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,40 @@
],
"methods": []
},
{
"name": "ChatCheckpointMarkerComponent",
"kind": "class",
"description": "",
"params": [],
"examples": [],
"properties": [
{
"name": "checkpointId",
"type": "InputSignal<string>",
"description": "",
"optional": false
},
{
"name": "forkRequested",
"type": "OutputEmitterRef<string>",
"description": "",
"optional": false
},
{
"name": "isActive",
"type": "InputSignal<boolean>",
"description": "",
"optional": false
},
{
"name": "replayRequested",
"type": "OutputEmitterRef<string>",
"description": "",
"optional": false
}
],
"methods": []
},
{
"name": "ChatCitationCardTemplateDirective",
"kind": "class",
Expand Down Expand Up @@ -2236,6 +2270,18 @@
"description": "",
"optional": false
},
{
"name": "checkpointActive",
"type": "InputSignal<boolean>",
"description": "",
"optional": false
},
{
"name": "checkpointId",
"type": "InputSignal<string | undefined>",
"description": "Optional checkpoint id to anchor a gutter marker. When set, a\n chat-checkpoint-marker is rendered in the left gutter and emits\n bubble through this component's replayRequested / forkRequested outputs.",
"optional": false
},
{
"name": "current",
"type": "InputSignal<boolean>",
Expand All @@ -2248,6 +2294,12 @@
"description": "",
"optional": false
},
{
"name": "forkRequested",
"type": "OutputEmitterRef<string>",
"description": "",
"optional": false
},
{
"name": "message",
"type": "InputSignal<Message | undefined>",
Expand All @@ -2260,6 +2312,12 @@
"description": "",
"optional": false
},
{
"name": "replayRequested",
"type": "OutputEmitterRef<string>",
"description": "",
"optional": false
},
{
"name": "role",
"type": "InputSignal<ChatMessageRole>",
Expand Down Expand Up @@ -2728,6 +2786,34 @@
],
"methods": []
},
{
"name": "ChatThreadDrawerComponent",
"kind": "class",
"description": "",
"params": [],
"examples": [],
"properties": [
{
"name": "mode",
"type": "InputSignal<ChatThreadDrawerMode>",
"description": "",
"optional": false
},
{
"name": "open",
"type": "InputSignal<boolean>",
"description": "",
"optional": false
},
{
"name": "openChange",
"type": "OutputEmitterRef<boolean>",
"description": "",
"optional": false
}
],
"methods": []
},
{
"name": "ChatThreadListComponent",
"kind": "class",
Expand Down Expand Up @@ -2773,6 +2859,19 @@
}
],
"methods": [
{
"name": "relativeTime",
"signature": "relativeTime(epochMs: number)",
"description": "",
"params": [
{
"name": "epochMs",
"type": "number",
"description": "",
"optional": false
}
]
},
{
"name": "selectThread",
"signature": "selectThread(threadId: string)",
Expand Down Expand Up @@ -4140,7 +4239,7 @@
{
"name": "AgentWithHistory",
"kind": "interface",
"description": "Extends Agent with a required `history` signal.\n\nCompositions that need time-travel / checkpoint data (chat-timeline,\nchat-debug) take this richer contract. Adapters that cannot supply\nhistory should return plain Agent instead of stubbing an empty array.",
"description": "Extension of Agent that exposes checkpoint history for time-travel UIs.\n\nConcrete adapters that record per-node checkpoints (e.g. LangGraph) should\nimplement this. Pure request/response runtimes that don't have checkpoints\nshould implement plain Agent.",
"properties": [
{
"name": "error",
Expand Down Expand Up @@ -4172,6 +4271,12 @@
"description": "",
"optional": false
},
{
"name": "messageCheckpoints",
"type": "Signal<ReadonlyMap<string, string>>",
"description": "Optional reactive map of `messageId → checkpointId`, computed by\nwalking history once: for each checkpoint, find the most recent\nassistant message present in its `values.messages` and pair them.\nUIs use this to anchor inline checkpoint markers on each assistant\nturn. Missing on adapters that don't compute it.",
"optional": true
},
{
"name": "messages",
"type": "Signal<Message[]>",
Expand Down Expand Up @@ -4906,6 +5011,13 @@
"signature": "\"user\" | \"assistant\" | \"system\" | \"tool\"",
"examples": []
},
{
"name": "ChatThreadDrawerMode",
"kind": "type",
"description": "",
"signature": "\"push\" | \"overlay\"",
"examples": []
},
{
"name": "ContentBlock",
"kind": "type",
Expand Down
16 changes: 12 additions & 4 deletions libs/chat/src/lib/agent/agent-with-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@ import type { Agent } from './agent';
import type { AgentCheckpoint } from './agent-checkpoint';

/**
* Extends Agent with a required `history` signal.
* Extension of Agent that exposes checkpoint history for time-travel UIs.
*
* Compositions that need time-travel / checkpoint data (chat-timeline,
* chat-debug) take this richer contract. Adapters that cannot supply
* history should return plain Agent instead of stubbing an empty array.
* Concrete adapters that record per-node checkpoints (e.g. LangGraph) should
* implement this. Pure request/response runtimes that don't have checkpoints
* should implement plain Agent.
*/
export interface AgentWithHistory extends Agent {
history: Signal<AgentCheckpoint[]>;
/**
* Optional reactive map of `messageId → checkpointId`, computed by
* walking history once: for each checkpoint, find the most recent
* assistant message present in its `values.messages` and pair them.
* UIs use this to anchor inline checkpoint markers on each assistant
* turn. Missing on adapters that don't compute it.
*/
messageCheckpoints?: Signal<ReadonlyMap<string, string>>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.spec.ts
// SPDX-License-Identifier: MIT
import { describe, it, expect, beforeEach } from 'vitest';
import { TestBed } from '@angular/core/testing';
import { Component } from '@angular/core';
import { ChatThreadDrawerComponent } from './chat-thread-drawer.component';

@Component({
standalone: true,
imports: [ChatThreadDrawerComponent],
template: `<chat-thread-drawer
[open]="open"
[mode]="mode"
(openChange)="onOpenChange($event)">
<div data-testid="drawer-body">child content</div>
</chat-thread-drawer>`,
})
class HostComponent {
open = false;
mode: 'push' | 'overlay' = 'push';
changes: boolean[] = [];
onOpenChange(v: boolean): void { this.changes.push(v); }
}

describe('ChatThreadDrawerComponent', () => {
beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] }));

it('hides the drawer when open=false (translated off-screen)', () => {
const fx = TestBed.createComponent(HostComponent);
fx.detectChanges();
const drawer = fx.nativeElement.querySelector('.chat-thread-drawer') as HTMLElement;
expect(drawer.getAttribute('data-open')).toBe('false');
});

it('shows the drawer when open=true', () => {
const fx = TestBed.createComponent(HostComponent);
fx.componentInstance.open = true;
fx.detectChanges();
expect(fx.nativeElement.querySelector('.chat-thread-drawer').getAttribute('data-open')).toBe('true');
});

it('renders no scrim in push mode', () => {
const fx = TestBed.createComponent(HostComponent);
fx.componentInstance.open = true;
fx.componentInstance.mode = 'push';
fx.detectChanges();
expect(fx.nativeElement.querySelector('.chat-thread-drawer__scrim')).toBeNull();
});

it('renders a scrim in overlay mode when open', () => {
const fx = TestBed.createComponent(HostComponent);
fx.componentInstance.open = true;
fx.componentInstance.mode = 'overlay';
fx.detectChanges();
expect(fx.nativeElement.querySelector('.chat-thread-drawer__scrim')).toBeTruthy();
});

it('scrim click emits openChange(false)', () => {
const fx = TestBed.createComponent(HostComponent);
fx.componentInstance.open = true;
fx.componentInstance.mode = 'overlay';
fx.detectChanges();
(fx.nativeElement.querySelector('.chat-thread-drawer__scrim') as HTMLElement).click();
expect(fx.componentInstance.changes).toEqual([false]);
});

it('Escape keydown on drawer host emits openChange(false)', () => {
const fx = TestBed.createComponent(HostComponent);
fx.componentInstance.open = true;
fx.detectChanges();
const drawer = fx.nativeElement.querySelector('.chat-thread-drawer') as HTMLElement;
drawer.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(fx.componentInstance.changes).toEqual([false]);
});

it('projects child content into the drawer body', () => {
const fx = TestBed.createComponent(HostComponent);
fx.componentInstance.open = true;
fx.detectChanges();
expect(fx.nativeElement.querySelector('[data-testid="drawer-body"]')).toBeTruthy();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.ts
// SPDX-License-Identifier: MIT
import {
Component, ChangeDetectionStrategy, input, output,
} from '@angular/core';
import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens';

export type ChatThreadDrawerMode = 'push' | 'overlay';

@Component({
selector: 'chat-thread-drawer',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [CHAT_HOST_TOKENS, `
:host {
--chat-thread-drawer-width: 280px;
display: contents;
}
.chat-thread-drawer__scrim {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 1000;
border: 0;
padding: 0;
cursor: pointer;
}
.chat-thread-drawer {
position: fixed;
top: 0;
bottom: 0;
left: 0;
width: var(--chat-thread-drawer-width);
background: var(--ngaf-chat-bg);
border-right: 1px solid var(--ngaf-chat-separator);
z-index: 1001;
transform: translateX(-100%);
transition: transform 200ms ease;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.chat-thread-drawer[data-open="true"] { transform: translateX(0); }
@media (max-width: 767px) {
.chat-thread-drawer { width: 100%; }
}
`],
template: `
@if (open() && mode() === 'overlay') {
<button
type="button"
class="chat-thread-drawer__scrim"
aria-label="Close conversations"
(click)="openChange.emit(false)"
></button>
}
<aside
class="chat-thread-drawer"
role="dialog"
aria-label="Conversations"
tabindex="-1"
[attr.data-open]="open() ? 'true' : 'false'"
[attr.data-mode]="mode()"
(keydown.escape)="openChange.emit(false)"
>
<ng-content />
</aside>
`,
})
export class ChatThreadDrawerComponent {
readonly open = input.required<boolean>();
readonly mode = input<ChatThreadDrawerMode>('push');

readonly openChange = output<boolean>();
}
Loading
Loading