From a6c7da0396292b67ed5cfad5aee2ea12094034d4 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 08:42:52 -0700 Subject: [PATCH 01/10] feat(chat): chat-checkpoint-marker primitive Renders a 10px dot in a 14px gutter slot, with a hover/focus pill exposing Rewind + Fork actions. Replaces the right-side timeline slider as the primary time-travel surface for inline use in chat-message gutters. --- .../chat-checkpoint-marker.component.spec.ts | 58 ++++++++++ .../chat-checkpoint-marker.component.ts | 107 ++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.spec.ts create mode 100644 libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.ts diff --git a/libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.spec.ts b/libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.spec.ts new file mode 100644 index 000000000..28cc39a48 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.spec.ts @@ -0,0 +1,58 @@ +// libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.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 { ChatCheckpointMarkerComponent } from './chat-checkpoint-marker.component'; + +@Component({ + standalone: true, + imports: [ChatCheckpointMarkerComponent], + template: ``, +}) +class HostComponent { + cpId = 'cp-1'; + active = false; + replayed: string[] = []; + forked: string[] = []; + onReplay(id: string): void { this.replayed.push(id); } + onFork(id: string): void { this.forked.push(id); } +} + +describe('ChatCheckpointMarkerComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [HostComponent] })); + + it('renders a dot button labelled with the checkpoint id', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + const dot = fx.nativeElement.querySelector('.chat-checkpoint-marker__dot') as HTMLButtonElement; + expect(dot).toBeTruthy(); + expect(dot.getAttribute('aria-label')).toContain('cp-1'); + }); + + it('applies the active class when isActive=true', () => { + const fx = TestBed.createComponent(HostComponent); + fx.componentInstance.active = true; + fx.detectChanges(); + const dot = fx.nativeElement.querySelector('.chat-checkpoint-marker__dot'); + expect(dot.getAttribute('data-active')).toBe('true'); + }); + + it('emits replayRequested with the checkpointId when Rewind is clicked', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('[data-action="rewind"]') as HTMLButtonElement).click(); + expect(fx.componentInstance.replayed).toEqual(['cp-1']); + }); + + it('emits forkRequested with the checkpointId when Fork is clicked', () => { + const fx = TestBed.createComponent(HostComponent); + fx.detectChanges(); + (fx.nativeElement.querySelector('[data-action="fork"]') as HTMLButtonElement).click(); + expect(fx.componentInstance.forked).toEqual(['cp-1']); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.ts b/libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.ts new file mode 100644 index 000000000..f7d15a669 --- /dev/null +++ b/libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.ts @@ -0,0 +1,107 @@ +// libs/chat/src/lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component.ts +// SPDX-License-Identifier: MIT +import { + Component, ChangeDetectionStrategy, input, output, +} from '@angular/core'; +import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; + +@Component({ + selector: 'chat-checkpoint-marker', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + styles: [CHAT_HOST_TOKENS, ` + :host { + display: inline-flex; + align-items: center; + width: 14px; + height: 100%; + flex: 0 0 14px; + } + .chat-checkpoint-marker__dot { + width: 10px; + height: 10px; + border-radius: 50%; + padding: 0; + cursor: pointer; + background: transparent; + box-shadow: inset 0 0 0 1px var(--a2ui-primary, var(--ngaf-chat-primary)); + transition: background 120ms ease; + position: relative; + } + .chat-checkpoint-marker__dot:hover, + .chat-checkpoint-marker__dot:focus-visible { + background: var(--a2ui-primary, var(--ngaf-chat-primary)); + outline: none; + } + .chat-checkpoint-marker__dot[data-active="true"] { + background: var(--a2ui-primary, var(--ngaf-chat-primary)); + } + .chat-checkpoint-marker__pill { + position: absolute; + left: 18px; + top: 50%; + transform: translateY(-50%); + display: none; + gap: 6px; + padding: 4px 8px; + background: var(--ngaf-chat-surface-alt); + border: 1px solid var(--ngaf-chat-separator); + border-radius: var(--ngaf-chat-radius-button); + white-space: nowrap; + font-size: 11px; + z-index: 5; + } + .chat-checkpoint-marker__dot:hover + .chat-checkpoint-marker__pill, + .chat-checkpoint-marker__dot:focus-visible + .chat-checkpoint-marker__pill, + .chat-checkpoint-marker__pill:hover { + display: inline-flex; + } + @media (pointer: coarse) { + .chat-checkpoint-marker__dot:hover + .chat-checkpoint-marker__pill, + .chat-checkpoint-marker__dot:focus-visible + .chat-checkpoint-marker__pill { + display: none; + } + .chat-checkpoint-marker__dot[data-open="true"] + .chat-checkpoint-marker__pill { + display: inline-flex; + } + } + .chat-checkpoint-marker__action { + background: transparent; + border: 0; + color: var(--ngaf-chat-text); + cursor: pointer; + padding: 2px 4px; + font-size: 11px; + } + .chat-checkpoint-marker__action:hover { color: var(--a2ui-primary, var(--ngaf-chat-primary)); } + `], + template: ` + + + + + + `, +}) +export class ChatCheckpointMarkerComponent { + readonly checkpointId = input.required(); + readonly isActive = input(false); + + readonly replayRequested = output(); + readonly forkRequested = output(); +} From 361dac28f9765e690826337e7e192300c1ff775d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 08:47:11 -0700 Subject: [PATCH 02/10] feat(chat): chat-thread-drawer composition Slide-in container at left viewport edge, hosting projected content (typically chat-thread-list). Two modes: push (no scrim; host page reflows by setting padding-left on its main column) and overlay (scrim closes on click). Escape key closes. --- .../chat-thread-drawer.component.spec.ts | 82 +++++++++++++++++++ .../chat-thread-drawer.component.ts | 67 +++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.spec.ts create mode 100644 libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.ts diff --git a/libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.spec.ts b/libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.spec.ts new file mode 100644 index 000000000..642b9fd9c --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.spec.ts @@ -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: ` +
child content
+
`, +}) +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(); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.ts b/libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.ts new file mode 100644 index 000000000..fecf3502e --- /dev/null +++ b/libs/chat/src/lib/compositions/chat-thread-drawer/chat-thread-drawer.component.ts @@ -0,0 +1,67 @@ +// 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; + } + .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') { +
+ } + + `, +}) +export class ChatThreadDrawerComponent { + readonly open = input.required(); + readonly mode = input('push'); + + readonly openChange = output(); +} From 3ac3c04b97c92af8df86e9e8204b8f82f8b587b6 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 08:50:21 -0700 Subject: [PATCH 03/10] feat(chat): chat-thread-list default item template renders title + relative time Extends the Thread type with an optional updatedAt epoch-ms field. When present, the default item template renders a second line with a relative-time label (just now / 5 min ago / 2 hr ago / 3 day ago). Existing templateRef projection still wins when provided, so back-compat is preserved. --- .../chat-thread-list.component.spec.ts | 41 +++++++++++++++++++ .../chat-thread-list.component.ts | 26 +++++++++++- .../src/lib/styles/chat-thread-list.styles.ts | 26 +++++++++--- 3 files changed, 85 insertions(+), 8 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts index 6e86f76ce..7061f033d 100644 --- a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.spec.ts @@ -77,3 +77,44 @@ describe('ChatThreadListComponent — structure', () => { expect(threads$()).toHaveLength(3); }); }); + +describe('ChatThreadListComponent — default item template', () => { + // Helper function that mirrors the component's relativeTime method + const relativeTime = (epochMs: number): string => { + const delta = Date.now() - epochMs; + if (delta < 60_000) return 'just now'; + if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} min ago`; + if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} hr ago`; + return `${Math.floor(delta / 86_400_000)} day ago`; + }; + + it('Thread type includes optional updatedAt field', () => { + const threadWithTime: Thread = { id: 'a', title: 'Test', updatedAt: Date.now() }; + const threadWithoutTime: Thread = { id: 'b', title: 'Test' }; + expect(threadWithTime.updatedAt).toBeDefined(); + expect(threadWithoutTime.updatedAt).toBeUndefined(); + }); + + it('relativeTime returns "just now" for < 60s delta', () => { + const now = Date.now(); + expect(relativeTime(now - 30_000)).toBe('just now'); + }); + + it('relativeTime returns "X min ago" for < 1h delta', () => { + const now = Date.now(); + const result = relativeTime(now - 300_000); // 5 min ago + expect(result).toMatch(/\d+ min ago/); + }); + + it('relativeTime returns "X hr ago" for < 1d delta', () => { + const now = Date.now(); + const result = relativeTime(now - 7_200_000); // 2 hr ago + expect(result).toMatch(/\d+ hr ago/); + }); + + it('relativeTime returns "X day ago" for >= 1d delta', () => { + const now = Date.now(); + const result = relativeTime(now - 172_800_000); // 2 day ago + expect(result).toMatch(/\d+ day ago/); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts index bd781bea8..40911eedc 100644 --- a/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts +++ b/libs/chat/src/lib/primitives/chat-thread-list/chat-thread-list.component.ts @@ -12,7 +12,16 @@ import { NgTemplateOutlet } from '@angular/common'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_THREAD_LIST_STYLES } from '../../styles/chat-thread-list.styles'; -export type Thread = { id: string; [key: string]: unknown }; +export type Thread = { + id: string; + /** Optional human-friendly label. Falls back to a slice of the id. */ + title?: string; + /** Optional epoch-ms timestamp used by the default item template to + * render a relative-time line ("just now" / "5 min ago"). When absent + * the default template omits the second line. */ + updatedAt?: number; + [key: string]: unknown; +}; @Component({ selector: 'chat-thread-list', @@ -39,7 +48,12 @@ export type Thread = { id: string; [key: string]: unknown }; [attr.data-active]="thread.id === activeThreadId() ? 'true' : null" [attr.aria-current]="thread.id === activeThreadId() ? 'true' : null" (click)="selectThread(thread.id)" - >{{ threadLabel(thread) }} + > + {{ threadLabel(thread) }} + @if (thread.updatedAt != null) { + {{ relativeTime(thread.updatedAt) }} + } + } } @@ -65,4 +79,12 @@ export class ChatThreadListComponent { if (typeof title === 'string' && title.length > 0) return title; return thread.id; } + + protected relativeTime(epochMs: number): string { + const delta = Date.now() - epochMs; + if (delta < 60_000) return 'just now'; + if (delta < 3_600_000) return `${Math.floor(delta / 60_000)} min ago`; + if (delta < 86_400_000) return `${Math.floor(delta / 3_600_000)} hr ago`; + return `${Math.floor(delta / 86_400_000)} day ago`; + } } diff --git a/libs/chat/src/lib/styles/chat-thread-list.styles.ts b/libs/chat/src/lib/styles/chat-thread-list.styles.ts index 080186b47..0d6221198 100644 --- a/libs/chat/src/lib/styles/chat-thread-list.styles.ts +++ b/libs/chat/src/lib/styles/chat-thread-list.styles.ts @@ -3,16 +3,15 @@ export const CHAT_THREAD_LIST_STYLES = ` :host { display: block; padding: var(--ngaf-chat-space-2); } .chat-thread-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2px; } .chat-thread-list__item { - display: block; - height: 36px; + display: flex; + flex-direction: column; + gap: 2px; + min-height: 36px; padding: 8px 12px; border-radius: var(--ngaf-chat-radius-button); cursor: pointer; color: var(--ngaf-chat-text); font-size: var(--ngaf-chat-font-size-sm); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; background: transparent; border: 0; text-align: left; @@ -21,7 +20,22 @@ export const CHAT_THREAD_LIST_STYLES = ` transition: background-color 150ms ease; } .chat-thread-list__item:hover { background: color-mix(in srgb, var(--ngaf-chat-text) 5%, transparent); } - .chat-thread-list__item[data-active="true"] { background: var(--ngaf-chat-surface-alt); font-weight: 500; } + .chat-thread-list__item[data-active="true"] { + background: var(--ngaf-chat-surface-alt); + font-weight: 500; + box-shadow: inset 2px 0 0 var(--a2ui-primary, var(--ngaf-chat-primary)); + } + .chat-thread-list__item-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + } + .chat-thread-list__item-time { + font-size: 11px; + color: var(--ngaf-chat-text-muted); + display: block; + } .chat-thread-list__new { display: block; width: 100%; From bcd5a41379bb8ea2b410377f9a22af1ebe8998c9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 08:52:51 -0700 Subject: [PATCH 04/10] feat(chat): chat-message gutter slot + checkpoint marker wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New optional [checkpointId] input mounts a chat-checkpoint-marker in a left gutter (14px). Bubbles replayRequested + forkRequested as message-level outputs so consumers can wire to time-travel handlers. Gutter collapses to zero width when checkpointId is unset — back-compat preserved for non-time-travel consumers. --- .../chat-message.component.spec.ts | 53 +++++++++++++++++++ .../chat-message/chat-message.component.ts | 53 ++++++++++++++----- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts index 0b6eb2198..92b7305fe 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.spec.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MIT import { describe, it, expect } from 'vitest'; import { TestBed } from '@angular/core/testing'; +import { Component } from '@angular/core'; import { ChatMessageComponent } from './chat-message.component'; import { CitationsResolverService } from '../../markdown/citations-resolver.service'; @@ -15,3 +16,55 @@ describe('ChatMessageComponent', () => { expect(component).toBeTruthy(); }); }); + +@Component({ + standalone: true, + imports: [ChatMessageComponent], + template: `Hello`, +}) +class GutterHost { + cpId: string | undefined = undefined; + replayed: string[] = []; + forked: string[] = []; +} + +describe('ChatMessageComponent — gutter checkpoint marker', () => { + it('does not render a marker when checkpointId is unset', () => { + TestBed.configureTestingModule({ imports: [GutterHost] }); + const fx = TestBed.createComponent(GutterHost); + fx.detectChanges(); + expect(fx.nativeElement.querySelector('chat-checkpoint-marker')).toBeNull(); + }); + + it('renders a marker in the gutter when checkpointId is set', () => { + TestBed.configureTestingModule({ imports: [GutterHost] }); + const fx = TestBed.createComponent(GutterHost); + fx.componentInstance.cpId = 'cp-99'; + fx.detectChanges(); + const marker = fx.nativeElement.querySelector('chat-checkpoint-marker'); + expect(marker).toBeTruthy(); + expect(marker.querySelector('[aria-label]').getAttribute('aria-label')).toContain('cp-99'); + }); + + it('bubbles replayRequested from the marker as a message-level output', () => { + TestBed.configureTestingModule({ imports: [GutterHost] }); + const fx = TestBed.createComponent(GutterHost); + fx.componentInstance.cpId = 'cp-99'; + fx.detectChanges(); + (fx.nativeElement.querySelector('[data-action="rewind"]') as HTMLButtonElement).click(); + expect(fx.componentInstance.replayed).toEqual(['cp-99']); + }); + + it('bubbles forkRequested from the marker as a message-level output', () => { + TestBed.configureTestingModule({ imports: [GutterHost] }); + const fx = TestBed.createComponent(GutterHost); + fx.componentInstance.cpId = 'cp-99'; + fx.detectChanges(); + (fx.nativeElement.querySelector('[data-action="fork"]') as HTMLButtonElement).click(); + expect(fx.componentInstance.forked).toEqual(['cp-99']); + }); +}); diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts index d2c83b614..3a547e5b7 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts @@ -1,9 +1,10 @@ // libs/chat/src/lib/primitives/chat-message/chat-message.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, computed, effect, inject } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, output, computed, effect, inject } from '@angular/core'; import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; import { CHAT_MESSAGE_STYLES } from '../../styles/chat-message.styles'; import { ChatCitationsComponent } from '../chat-citations/chat-citations.component'; +import { ChatCheckpointMarkerComponent } from '../chat-checkpoint-marker/chat-checkpoint-marker.component'; import { CitationsResolverService } from '../../markdown/citations-resolver.service'; import type { Message } from '../../agent/message'; @@ -12,9 +13,14 @@ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; @Component({ selector: 'chat-message', standalone: true, - imports: [ChatCitationsComponent], + imports: [ChatCitationsComponent, ChatCheckpointMarkerComponent], changeDetection: ChangeDetectionStrategy.OnPush, - styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES], + styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES, ` + .chat-message__layout { display: flex; gap: 8px; align-items: flex-start; } + .chat-message__gutter { flex: 0 0 14px; display: flex; align-items: flex-start; padding-top: 4px; } + .chat-message__gutter:empty { flex-basis: 0; } + .chat-message__main { flex: 1; min-width: 0; } + `], providers: [CitationsResolverService], host: { '[attr.data-role]': 'role()', @@ -23,15 +29,29 @@ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; '[attr.data-prev-role]': 'prevRole() ?? null', }, template: ` -
- - -
- @if (message()?.role === 'assistant' && message(); as msg) { - - } -
- +
+
+ @if (checkpointId(); as cpId) { + + } +
+
+
+ + +
+ @if (message()?.role === 'assistant' && message(); as msg) { + + } +
+ +
+
`, }) @@ -42,6 +62,15 @@ export class ChatMessageComponent { readonly prevRole = input(undefined); readonly message = input(undefined); + /** Optional checkpoint id to anchor a gutter marker. When set, a + * chat-checkpoint-marker is rendered in the left gutter and emits + * bubble through this component's replayRequested / forkRequested outputs. */ + readonly checkpointId = input(undefined); + readonly checkpointActive = input(false); + + readonly replayRequested = output(); + readonly forkRequested = output(); + private readonly resolver = inject(CitationsResolverService); constructor() { From c65bef73bed01bdf2310f97e21e9d4ef646a4d85 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 08:56:18 -0700 Subject: [PATCH 05/10] feat(langgraph): agent.messageCheckpoints() helper Reactive ReadonlyMap computed from history(). Each AIMessage pairs with the most recent checkpoint where it is the tail message. Consumed by chat-message gutter markers to anchor inline time-travel actions on each assistant turn. --- libs/chat/src/lib/agent/agent-with-history.ts | 18 ++++-- libs/langgraph/src/lib/agent.fn.spec.ts | 60 +++++++++++++++++++ libs/langgraph/src/lib/agent.fn.ts | 39 +++++++++++- 3 files changed, 111 insertions(+), 6 deletions(-) diff --git a/libs/chat/src/lib/agent/agent-with-history.ts b/libs/chat/src/lib/agent/agent-with-history.ts index dca742c20..d84e530ae 100644 --- a/libs/chat/src/lib/agent/agent-with-history.ts +++ b/libs/chat/src/lib/agent/agent-with-history.ts @@ -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 { +export interface AgentWithHistory extends Agent { history: Signal; + /** + * 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>; } diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index f7d20e801..daf8e8fa8 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -851,3 +851,63 @@ describe('agent', () => { }); }); }); + +import { computeMessageCheckpoints } from './agent.fn'; + +describe('computeMessageCheckpoints', () => { + it('returns an empty map when history is empty', () => { + expect(computeMessageCheckpoints([])).toEqual(new Map()); + }); + + it('pairs each AIMessage with the most recent checkpoint containing it', () => { + const history = [ + { + checkpoint: { checkpoint_id: 'cp-1' }, + values: { + messages: [ + { id: 'h-1', _getType: () => 'human' }, + { id: 'a-1', _getType: () => 'ai' }, + ], + }, + }, + { + checkpoint: { checkpoint_id: 'cp-2' }, + values: { + messages: [ + { id: 'h-1', _getType: () => 'human' }, + { id: 'a-1', _getType: () => 'ai' }, + { id: 'h-2', _getType: () => 'human' }, + { id: 'a-2', _getType: () => 'ai' }, + ], + }, + }, + ] as unknown as ThreadState[]; + + const map = computeMessageCheckpoints(history); + expect(map.get('a-1')).toBe('cp-1'); + expect(map.get('a-2')).toBe('cp-2'); + expect(map.size).toBe(2); + }); + + it('skips checkpoints with no AIMessage in scope', () => { + const history = [ + { + checkpoint: { checkpoint_id: 'cp-start' }, + values: { messages: [{ id: 'h-1', _getType: () => 'human' }] }, + }, + ] as unknown as ThreadState[]; + + expect(computeMessageCheckpoints(history).size).toBe(0); + }); + + it('skips checkpoints with no checkpoint_id', () => { + const history = [ + { + checkpoint: {}, + values: { messages: [{ id: 'a-1', _getType: () => 'ai' }] }, + }, + ] as unknown as ThreadState[]; + + expect(computeMessageCheckpoints(history).size).toBe(0); + }); +}); diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 25f5b61c7..0936b0eb6 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -62,6 +62,39 @@ import { createStreamManagerBridge } from './internals/stream-manager.bridge'; import { buildBranchTree } from './internals/branch-tree'; import { extractCitations } from './internals/extract-citations'; +/** + * Walk LangGraph history (newest-first) and pair each AIMessage id with + * the most recent checkpoint that contains it as the tail message in + * `values.messages`. + * + * Implementation: iterate oldest → newest (i.e. reverse the input array) + * so later writes overwrite earlier ones; the final map has each + * AIMessage paired with the newest containing checkpoint where it is + * still the tail. Checkpoints with no AIMessage in scope are skipped. + * Checkpoints with no checkpoint_id are skipped. + */ +export function computeMessageCheckpoints( + history: ReadonlyArray>, +): ReadonlyMap { + const out = new Map(); + for (let i = history.length - 1; i >= 0; i--) { + const state = history[i]; + const cpId = state.checkpoint?.checkpoint_id; + if (typeof cpId !== 'string' || cpId.length === 0) continue; + const values = state.values as { messages?: unknown[] } | undefined; + const msgs = Array.isArray(values?.messages) ? values.messages : []; + for (let j = msgs.length - 1; j >= 0; j--) { + const m = msgs[j] as { id?: string; _getType?: () => string; type?: string }; + const type = typeof m._getType === 'function' ? m._getType() : m.type; + if (type === 'ai' && typeof m.id === 'string') { + out.set(m.id, cpId); + break; + } + } + } + return out; +} + /** * Creates a LangGraph-backed Angular agent. * @@ -243,6 +276,9 @@ export function agent< const historyNeutral = computed(() => historySig().map(toCheckpoint), ); + const messageCheckpointsSig = computed>(() => + computeMessageCheckpoints(historySig() as ThreadState[]), + ); const experimentalBranchTree = computed(() => buildBranchTree(historySig() as ThreadState[]), ); @@ -260,7 +296,8 @@ export function agent< interrupt: interruptNeutral, subagents: subagentsNeutral, events$, - history: historyNeutral, + history: historyNeutral, + messageCheckpoints: messageCheckpointsSig, submit: (input: AgentSubmitInput | null | undefined, opts?: AgentSubmitOptions & LangGraphSubmitOptions) => { const request = buildSubmitRequest(input, opts); return manager.submit(request.payload, request.options); From d7bd8bde4ffd3dd3e9266b7b270e1ea8fbe3cbaa Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 09:25:45 -0700 Subject: [PATCH 06/10] feat(chat): export ChatCheckpointMarker + ChatThreadDrawer --- libs/chat/src/public-api.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/libs/chat/src/public-api.ts b/libs/chat/src/public-api.ts index cee7f6c66..e1dafc065 100644 --- a/libs/chat/src/public-api.ts +++ b/libs/chat/src/public-api.ts @@ -54,6 +54,7 @@ export type { ChatToolCallTemplateContext } from './lib/primitives/chat-tool-cal export { ChatSubagentsComponent } from './lib/primitives/chat-subagents/chat-subagents.component'; export { ChatThreadListComponent } from './lib/primitives/chat-thread-list/chat-thread-list.component'; export type { Thread } from './lib/primitives/chat-thread-list/chat-thread-list.component'; +export { ChatCheckpointMarkerComponent } from './lib/primitives/chat-checkpoint-marker/chat-checkpoint-marker.component'; export { ChatTimelineComponent } from './lib/primitives/chat-timeline/chat-timeline.component'; export { ChatGenerativeUiComponent } from './lib/primitives/chat-generative-ui/chat-generative-ui.component'; export { ChatWelcomeComponent } from './lib/primitives/chat-welcome/chat-welcome.component'; @@ -73,6 +74,8 @@ export { ChatPopupComponent } from './lib/compositions/chat-popup/chat-popup.com export { ChatSidebarComponent } from './lib/compositions/chat-sidebar/chat-sidebar.component'; export { ChatDebugComponent } from './lib/compositions/chat-debug/chat-debug.component'; export { ChatTimelineSliderComponent } from './lib/compositions/chat-timeline-slider/chat-timeline-slider.component'; +export { ChatThreadDrawerComponent } from './lib/compositions/chat-thread-drawer/chat-thread-drawer.component'; +export type { ChatThreadDrawerMode } from './lib/compositions/chat-thread-drawer/chat-thread-drawer.component'; export { ChatInterruptPanelComponent } from './lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component'; export type { InterruptAction } from './lib/compositions/chat-interrupt-panel/chat-interrupt-panel.component'; export { ChatToolCallCardComponent } from './lib/compositions/chat-tool-call-card/chat-tool-call-card.component'; From 72c30f6f919dedeeed75864ecbfaf147e00819a8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 09:25:47 -0700 Subject: [PATCH 07/10] chore: regenerate api-docs for nav v2 primitives --- .../content/docs/chat/api/api-docs.json | 120 +++++++++++++++++- 1 file changed, 116 insertions(+), 4 deletions(-) diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 0f4904792..5adcd0fce 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1438,6 +1438,40 @@ ], "methods": [] }, + { + "name": "ChatCheckpointMarkerComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "checkpointId", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "forkRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, + { + "name": "isActive", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "replayRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + } + ], + "methods": [] + }, { "name": "ChatCitationCardTemplateDirective", "kind": "class", @@ -1803,7 +1837,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>", "description": "", "optional": false }, @@ -2236,6 +2270,18 @@ "description": "", "optional": false }, + { + "name": "checkpointActive", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "checkpointId", + "type": "InputSignal", + "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", @@ -2248,6 +2294,12 @@ "description": "", "optional": false }, + { + "name": "forkRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "message", "type": "InputSignal", @@ -2260,6 +2312,12 @@ "description": "", "optional": false }, + { + "name": "replayRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "role", "type": "InputSignal", @@ -2728,6 +2786,34 @@ ], "methods": [] }, + { + "name": "ChatThreadDrawerComponent", + "kind": "class", + "description": "", + "params": [], + "examples": [], + "properties": [ + { + "name": "mode", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "open", + "type": "InputSignal", + "description": "", + "optional": false + }, + { + "name": "openChange", + "type": "OutputEmitterRef", + "description": "", + "optional": false + } + ], + "methods": [] + }, { "name": "ChatThreadListComponent", "kind": "class", @@ -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)", @@ -2810,7 +2909,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>", "description": "", "optional": false }, @@ -2858,7 +2957,7 @@ "properties": [ { "name": "agent", - "type": "InputSignal", + "type": "InputSignal>", "description": "", "optional": false }, @@ -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", @@ -4172,6 +4271,12 @@ "description": "", "optional": false }, + { + "name": "messageCheckpoints", + "type": "Signal>", + "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", @@ -4906,6 +5011,13 @@ "signature": "\"user\" | \"assistant\" | \"system\" | \"tool\"", "examples": [] }, + { + "name": "ChatThreadDrawerMode", + "kind": "type", + "description": "", + "signature": "\"push\" | \"overlay\"", + "examples": [] + }, { "name": "ContentBlock", "kind": "type", From 30c146c117daf096c9b0b065be796a866647d688 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 09:26:07 -0700 Subject: [PATCH 08/10] chore: regenerate api-docs for agent.messageCheckpoints() --- apps/website/content/docs/agent/api/api-docs.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/website/content/docs/agent/api/api-docs.json b/apps/website/content/docs/agent/api/api-docs.json index ebf10cbe1..cb1309e9f 100644 --- a/apps/website/content/docs/agent/api/api-docs.json +++ b/apps/website/content/docs/agent/api/api-docs.json @@ -928,6 +928,12 @@ "description": "Raw LangGraph tool calls (with run-state). Use `toolCalls` for chat rendering.", "optional": false }, + { + "name": "messageCheckpoints", + "type": "Signal>", + "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", @@ -1292,6 +1298,12 @@ "description": "Raw LangGraph tool calls (with run-state). Use `toolCalls` for chat rendering.", "optional": false }, + { + "name": "messageCheckpoints", + "type": "Signal>", + "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", From 4212fa7f120ab3e69ba1294512878ad10df8c252 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 11 May 2026 09:38:41 -0700 Subject: [PATCH 09/10] =?UTF-8?q?fix(chat):=20nav-v2=20lint=20errors=20?= =?UTF-8?q?=E2=80=94=20scrim=20a11y=20+=20eqeqeq=20+=20unused=20generic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three lint errors flagged by CI on PR #241: - chat-thread-drawer scrim was a
with a (click) and no key handler / focus support. Change to }