diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 75b3f4c8e..19ed84ff3 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1557,6 +1557,12 @@ "description": "", "optional": false }, + { + "name": "forkRequested", + "type": "OutputEmitterRef", + "description": "Bubbled from chat-message gutter markers when the user requests a checkpoint fork.", + "optional": false + }, { "name": "handlers", "type": "InputSignal>", @@ -1611,6 +1617,12 @@ "description": "", "optional": false }, + { + "name": "replayRequested", + "type": "OutputEmitterRef", + "description": "Bubbled from chat-message gutter markers when the user requests a checkpoint replay.", + "optional": false + }, { "name": "resolvedStore", "type": "Signal", @@ -1661,6 +1673,19 @@ } ], "methods": [ + { + "name": "checkpointFor", + "signature": "checkpointFor(msg: Message)", + "description": "Returns the checkpoint id associated with an AI message, if the\n underlying agent exposes messageCheckpoints().", + "params": [ + { + "name": "msg", + "type": "Message", + "description": "", + "optional": false + } + ] + }, { "name": "classifyMessage", "signature": "classifyMessage(content: string, message: object)", @@ -1853,6 +1878,12 @@ "description": "", "optional": false }, + { + "name": "forkRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "messageContent", "type": "object", @@ -1865,6 +1896,12 @@ "description": "", "optional": false }, + { + "name": "replayRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "selectedCheckpointIndex", "type": "WritableSignal", @@ -2406,12 +2443,24 @@ "description": "Close the popup on Escape (default true).", "optional": false }, + { + "name": "forkRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "open", "type": "ModelSignal", "description": "", "optional": false }, + { + "name": "replayRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "shortcut", "type": "InputSignal", @@ -2628,6 +2677,12 @@ "description": "", "optional": false }, + { + "name": "forkRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "open", "type": "ModelSignal", @@ -2640,6 +2695,12 @@ "description": "", "optional": false }, + { + "name": "replayRequested", + "type": "OutputEmitterRef", + "description": "", + "optional": false + }, { "name": "views", "type": "InputSignal>> | undefined>", diff --git a/examples/chat/angular/src/app/modes/embed-mode.component.ts b/examples/chat/angular/src/app/modes/embed-mode.component.ts index 7af299e31..352590622 100644 --- a/examples/chat/angular/src/app/modes/embed-mode.component.ts +++ b/examples/chat/angular/src/app/modes/embed-mode.component.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { ChatComponent, ChatWelcomeSuggestionComponent, a2uiBasicCatalog } from '@ngaf/chat'; +import { DemoShell } from '../shell/demo-shell.component'; import { DEMO_AGENT } from '../shell/shell-tokens'; import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; @@ -10,7 +11,12 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; imports: [ChatComponent, ChatWelcomeSuggestionComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: ` - +
@for (s of suggestions; track s.value) { when an AI message content begins with the diff --git a/examples/chat/angular/src/app/modes/popup-mode.component.ts b/examples/chat/angular/src/app/modes/popup-mode.component.ts index cc5343c79..902336f44 100644 --- a/examples/chat/angular/src/app/modes/popup-mode.component.ts +++ b/examples/chat/angular/src/app/modes/popup-mode.component.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { ChatPopupComponent, ChatWelcomeSuggestionComponent, a2uiBasicCatalog } from '@ngaf/chat'; +import { DemoShell } from '../shell/demo-shell.component'; import { DEMO_AGENT } from '../shell/shell-tokens'; import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; @@ -15,7 +16,12 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; Click the launcher button (bottom-right) to open the chat.

- +
@for (s of suggestions; track s.value) { . protected readonly catalog = a2uiBasicCatalog(); diff --git a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts index 4ff81d99d..8dedd5e70 100644 --- a/examples/chat/angular/src/app/modes/sidebar-mode.component.ts +++ b/examples/chat/angular/src/app/modes/sidebar-mode.component.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; import { ChatSidebarComponent, ChatWelcomeSuggestionComponent, a2uiBasicCatalog } from '@ngaf/chat'; +import { DemoShell } from '../shell/demo-shell.component'; import { DEMO_AGENT } from '../shell/shell-tokens'; import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; @@ -15,7 +16,12 @@ import { WELCOME_SUGGESTIONS } from './welcome-suggestions'; Click the launcher button (right edge) to slide in the chat panel.

- +
@for (s of suggestions; track s.value) { . protected readonly catalog = a2uiBasicCatalog(); diff --git a/examples/chat/angular/src/app/shell/control-palette.component.html b/examples/chat/angular/src/app/shell/control-palette.component.html index db66e9f1d..c8dbd0ef3 100644 --- a/examples/chat/angular/src/app/shell/control-palette.component.html +++ b/examples/chat/angular/src/app/shell/control-palette.component.html @@ -80,28 +80,6 @@ Debug {{ debugOpen() ? 'on' : 'off' }} - - - - - @if (threadsOpen()) { -
- -
- } + + + - @if (agent.interrupt && agent.interrupt()) { -
- -
- } - - @if (agent.subagents && agent.subagents().size > 0) { -
- -
- } - - @if (debugOpen()) { -
- -
- } - - @if (timelineOpen()) { -
- -
- } +
+ + @if (agent.interrupt && agent.interrupt()) { +
+ +
+ } + @if (agent.subagents && agent.subagents().size > 0) { +
+ +
+ } + @if (debugOpen()) { +
+ +
+ } +
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 498acb4a9..b2ec5bafe 100644 --- a/examples/chat/angular/src/app/shell/demo-shell.component.ts +++ b/examples/chat/angular/src/app/shell/demo-shell.component.ts @@ -3,6 +3,8 @@ import { Component, ChangeDetectionStrategy, DOCUMENT, + DestroyRef, + computed, effect, signal, inject, @@ -15,8 +17,8 @@ import { ChatDebugComponent, ChatInterruptPanelComponent, ChatSubagentsComponent, + ChatThreadDrawerComponent, ChatThreadListComponent, - ChatTimelineSliderComponent, type InterruptAction, } from '@ngaf/chat'; import { ControlPalette } from './control-palette.component'; @@ -42,8 +44,8 @@ function modeFromUrl(url: string): DemoMode { ChatDebugComponent, ChatInterruptPanelComponent, ChatSubagentsComponent, + ChatThreadDrawerComponent, ChatThreadListComponent, - ChatTimelineSliderComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './demo-shell.component.html', @@ -73,6 +75,25 @@ export class DemoShell { void this.threadIdSignal(); void this.threadsSvc.refresh(); }); + + // Refresh threads list when an agent run completes. The backend writes + // metadata.title on the first user message via _maybe_write_thread_title; + // a refresh after run-end picks up the new title in the drawer without + // needing a manual thread switch or reload. + let lastStatus = this.agent.status(); + effect(() => { + const status = this.agent.status(); + if (lastStatus === 'running' && status !== 'running') { + void this.threadsSvc.refresh(); + } + lastStatus = status; + }); + + if (typeof window !== 'undefined') { + const onResize = () => this.viewportWidth.set(window.innerWidth); + window.addEventListener('resize', onResize); + inject(DestroyRef).onDestroy(() => window.removeEventListener('resize', onResize)); + } } protected readonly mode = toSignal( @@ -110,7 +131,18 @@ export class DemoShell { protected readonly debugOpen = signal(this.persistence.read('debug') ?? false); - protected readonly timelineOpen = signal(this.persistence.read('timeline') ?? false); + /** Whether the threads drawer is open. Persisted across reloads. */ + protected readonly drawerOpen = signal(this.persistence.read('drawerOpen') ?? false); + + /** Viewport width, refreshed on window resize. Drives drawer push/overlay decision. */ + private readonly viewportWidth = signal( + typeof window !== 'undefined' ? window.innerWidth : 1440, + ); + + /** Computed drawer mode based on viewport width. */ + protected readonly drawerMode = computed<'push' | 'overlay'>(() => + this.viewportWidth() >= 1024 ? 'push' : 'overlay', + ); protected readonly modelOptions = signal([ { value: 'gpt-5', label: 'gpt-5' }, @@ -140,9 +172,6 @@ export class DemoShell { /** Persisted thread id (null on first run). Reactive so reload reconnects to the same thread. */ protected readonly threadIdSignal = signal(this.persistence.read('threadId') ?? null); - /** Whether the threads panel is open. Persisted across reloads. */ - protected readonly threadsOpen = signal(this.persistence.read('threads') ?? false); - /** * Shared agent instance. Patched submit injects state.model on every * submission so the graph picks up the latest model selection without @@ -211,16 +240,20 @@ export class DemoShell { this.persistence.write('debug', next); } - protected onTimelineChange(next: boolean): void { - this.timelineOpen.set(next); - this.persistence.write('timeline', next); + protected onDrawerOpenChange(next: boolean): void { + this.drawerOpen.set(next); + this.persistence.write('drawerOpen', next); } - protected onTimelineReplay(checkpointId: string): void { + protected toggleDrawer(): void { + this.onDrawerOpenChange(!this.drawerOpen()); + } + + onTimelineReplay(checkpointId: string): void { void this.agent.submit(null as never, { checkpointId } as never); } - protected async onTimelineFork(checkpointId: string): Promise { + async onTimelineFork(checkpointId: string): Promise { await fetch('http://localhost:2024/threads', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -234,11 +267,6 @@ export class DemoShell { }); } - protected onThreadsChange(next: boolean): void { - this.threadsOpen.set(next); - this.persistence.write('threads', next); - } - /** Switch to an existing thread selected from the threads panel. */ protected onThreadSelected(threadId: string): void { this.threadIdSignal.set(threadId); diff --git a/examples/chat/angular/src/app/shell/palette-persistence.service.ts b/examples/chat/angular/src/app/shell/palette-persistence.service.ts index 4c919b005..0a356df1d 100644 --- a/examples/chat/angular/src/app/shell/palette-persistence.service.ts +++ b/examples/chat/angular/src/app/shell/palette-persistence.service.ts @@ -11,8 +11,7 @@ interface PaletteState { debug?: boolean | null; threadId?: string | null; collapsed?: boolean | null; - timeline?: boolean | null; - threads?: boolean | null; + drawerOpen?: boolean | null; } type PaletteKey = keyof PaletteState; diff --git a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts index 01ec1308e..ee8771e89 100644 --- a/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts +++ b/libs/chat/src/lib/compositions/chat-debug/chat-debug.component.ts @@ -5,6 +5,7 @@ import { effect, input, inject, + output, signal, viewChild, ElementRef, @@ -26,6 +27,7 @@ import { DebugControlsComponent } from './debug-controls.component'; import { DebugSummaryComponent } from './debug-summary.component'; import type { DebugCheckpoint } from './debug-checkpoint-card.component'; import { toDebugCheckpoint, extractStateValues } from './debug-utils'; +import { ChatTimelineSliderComponent } from '../chat-timeline-slider/chat-timeline-slider.component'; @Component({ selector: 'chat-debug', @@ -40,6 +42,7 @@ import { toDebugCheckpoint, extractStateValues } from './debug-utils'; DebugDetailComponent, DebugControlsComponent, DebugSummaryComponent, + ChatTimelineSliderComponent, ], changeDetection: ChangeDetectionStrategy.OnPush, styles: [ @@ -206,6 +209,15 @@ import { toDebugCheckpoint, extractStateValues } from './debug-utils'; overflow-y: auto; } + .chat-debug__section { padding: 8px 12px; border-top: 1px solid var(--ngaf-chat-separator); } + .chat-debug__section-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--ngaf-chat-text-muted); + margin: 0 0 6px; + } + /* Markdown rendering */ :host ::ng-deep .chat-md p { margin: 0 0 0.75em; } :host ::ng-deep .chat-md p:last-child { margin-bottom: 0; } @@ -363,6 +375,16 @@ import { toDebugCheckpoint, extractStateValues } from './debug-utils'; /> } + + +
+

Legacy timeline slider

+ +
} @@ -373,6 +395,9 @@ export class ChatDebugComponent { readonly agent = input.required(); + readonly replayRequested = output(); + readonly forkRequested = output(); + readonly debugOpen = signal(true); readonly selectedCheckpointIndex = signal(-1); diff --git a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts index 83d0102f1..53bd5378f 100644 --- a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts +++ b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts @@ -1,6 +1,6 @@ // libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, model, DestroyRef, inject, DOCUMENT, effect } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, model, output, DestroyRef, inject, DOCUMENT, effect } from '@angular/core'; import type { Agent } from '../../agent'; import type { ViewRegistry } from '@ngaf/render'; import { ChatComponent } from '../chat/chat.component'; @@ -64,7 +64,12 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; - + @@ -77,6 +82,8 @@ export class ChatPopupComponent { * surface. Pass `a2uiBasicCatalog()` from `@ngaf/chat`. */ readonly views = input(undefined); readonly open = model(false); + readonly replayRequested = output(); + readonly forkRequested = output(); /** * Keyboard shortcut (single key) that toggles the popup with cmd (mac) * or ctrl (other). Set to `null` to disable. Default: 'k' — matches the diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts index 24f7eff87..cd27a4d3e 100644 --- a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts @@ -1,6 +1,6 @@ // libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts // SPDX-License-Identifier: MIT -import { Component, ChangeDetectionStrategy, input, model } from '@angular/core'; +import { Component, ChangeDetectionStrategy, input, model, output } from '@angular/core'; import type { Agent } from '../../agent'; import type { ViewRegistry } from '@ngaf/render'; import { ChatComponent } from '../chat/chat.component'; @@ -57,7 +57,12 @@ import { CHAT_HOST_TOKENS } from '../../styles/chat-tokens'; - + @@ -71,6 +76,8 @@ export class ChatSidebarComponent { readonly views = input(undefined); readonly open = model(false); readonly pushContent = input(false); + readonly replayRequested = output(); + readonly forkRequested = output(); toggle(): void { this.open.update((v) => !v); } openWindow(): void { this.open.set(true); } 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 index 2b12e6a04..4667196f0 100644 --- 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 @@ -29,18 +29,15 @@ export type ChatThreadDrawerMode = 'push' | 'overlay'; 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; + transition: left 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%; } } @@ -61,6 +58,7 @@ export type ChatThreadDrawerMode = 'push' | 'overlay'; tabindex="-1" [attr.data-open]="open() ? 'true' : 'false'" [attr.data-mode]="mode()" + [style.left.px]="open() ? 0 : -320" (keydown.escape)="openChange.emit(false)" > diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 1fc3a25a9..93c1b58d4 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -143,6 +143,9 @@ import type { ChatRenderEvent } from './chat-render-event'; [prevRole]="prevRole(i)" [streaming]="agent().isLoading() && i === agent().messages().length - 1" [current]="i === agent().messages().length - 1" + [checkpointId]="checkpointFor(message)" + (replayRequested)="replayRequested.emit($event)" + (forkRequested)="forkRequested.emit($event)" > @if (message.reasoning) { (); /** Emitted when the user copies an assistant message. */ readonly messageCopy = output<{ messageIndex: number; content: string }>(); + /** Bubbled from chat-message gutter markers when the user requests a checkpoint replay. */ + readonly replayRequested = output(); + /** Bubbled from chat-message gutter markers when the user requests a checkpoint fork. */ + readonly forkRequested = output(); private readonly _internalStore = signalStateStore({}); readonly resolvedStore = computed(() => { @@ -415,4 +422,14 @@ export class ChatComponent { const idx = this.agent().messages().indexOf(message as never); this.messageCopy.emit({ messageIndex: idx, content }); } + + /** Returns the checkpoint id associated with an AI message, if the + * underlying agent exposes messageCheckpoints(). */ + protected checkpointFor(msg: Message): string | undefined { + const id = (msg as unknown as { id?: string }).id; + if (typeof id !== 'string') return undefined; + const map = (this.agent() as unknown as { messageCheckpoints?: () => ReadonlyMap }) + .messageCheckpoints?.(); + return map?.get(id); + } }