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
44 changes: 44 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2982,6 +2982,12 @@
"description": "Element the menu anchors against (positions just below its bottom-right corner).",
"optional": false
},
{
"name": "anchorPos",
"type": "InputSignal<object | null>",
"description": "Alternative anchor: explicit viewport coordinates (e.g. cursor position\n from a right-click). Takes precedence over `anchor` when set.",
"optional": false
},
{
"name": "closed",
"type": "OutputEmitterRef<void>",
Expand Down Expand Up @@ -3900,6 +3906,12 @@
"description": "",
"optional": false
},
{
"name": "menuAnchorPos",
"type": "WritableSignal<object | null>",
"description": "Cursor-anchored position when the menu was opened via right-click.\n Mutually exclusive with `menuAnchor` — set one, null the other.",
"optional": false
},
{
"name": "menuOpenForId",
"type": "WritableSignal<string | null>",
Expand Down Expand Up @@ -4000,6 +4012,19 @@
}
]
},
{
"name": "initialOf",
"signature": "initialOf(title: string)",
"description": "First grapheme of a title rendered as an uppercase initial for the\n collapsed sidenav. Falls back to \"?\" for empty/whitespace titles.",
"params": [
{
"name": "title",
"type": "string",
"description": "",
"optional": false
}
]
},
{
"name": "onDragEnd",
"signature": "onDragEnd()",
Expand Down Expand Up @@ -4121,6 +4146,25 @@
}
]
},
{
"name": "onRowContextMenu",
"signature": "onRowContextMenu(threadId: string, event: MouseEvent)",
"description": "Right-click on a row opens the same overflow menu anchored at the\n cursor. Always prevents the native context menu — including when the\n adapter exposes no row actions (in which case we open nothing rather\n than confusing the user with the OS menu on what looks like a custom\n list).",
"params": [
{
"name": "threadId",
"type": "string",
"description": "",
"optional": false
},
{
"name": "event",
"type": "MouseEvent",
"description": "",
"optional": false
}
]
},
{
"name": "openMenu",
"signature": "openMenu(threadId: string, anchor: HTMLElement)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,18 @@ export class ChatOverflowMenuComponent {
readonly items = input<OverflowMenuItem[]>([]);
/** Element the menu anchors against (positions just below its bottom-right corner). */
readonly anchor = input<HTMLElement | null>(null);
/** Alternative anchor: explicit viewport coordinates (e.g. cursor position
* from a right-click). Takes precedence over `anchor` when set. */
readonly anchorPos = input<{ x: number; y: number } | null>(null);
readonly itemSelected = output<string>();
readonly closed = output<void>();

protected readonly position = computed<{ top: number; left: number }>(() => {
if (!this.open()) return { top: 0, left: 0 };
const pos = this.anchorPos();
if (pos) {
return { top: pos.y + 4, left: Math.max(pos.x, 8) };
}
const el = this.anchor();
if (!el) {
const vw = typeof window === 'undefined' ? 0 : window.innerWidth;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -708,4 +708,44 @@ describe('ChatThreadListComponent', () => {
expect(spy).toHaveBeenCalledWith('p1', null);
});
});

describe('row context menu', () => {
it('right-click on a row opens the overflow menu and suppresses the native menu', () => {
const fixture = render({ actions: { rename: noop, delete: noop } });
const wrap = fixture.nativeElement.querySelector('.chat-thread-list__item-wrap') as HTMLElement;
const evt = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 120, clientY: 80 });
const dispatched = wrap.dispatchEvent(evt);
fixture.detectChanges();
// preventDefault was called → dispatchEvent returns false.
expect(dispatched).toBe(false);
const items = document.querySelectorAll('.chat-overflow-menu__item');
const labels = Array.from(items).map((el) => (el as HTMLElement).textContent?.trim());
expect(labels).toContain('Rename');
expect(labels).toContain('Delete');
});

it('right-click does nothing (but still preventDefaults) when there is no adapter', () => {
const fixture = render({ actions: null });
const wrap = fixture.nativeElement.querySelector('.chat-thread-list__item-wrap') as HTMLElement;
const evt = new MouseEvent('contextmenu', { bubbles: true, cancelable: true, clientX: 10, clientY: 10 });
const dispatched = wrap.dispatchEvent(evt);
fixture.detectChanges();
expect(dispatched).toBe(false);
expect(document.querySelector('.chat-overflow-menu')).toBeNull();
});

it('renders the per-thread initial circle', () => {
const fixture = render({ threads: [{ id: 't1', title: 'Hello world' }] });
const initial = fixture.nativeElement.querySelector('.chat-thread-list__initial') as HTMLElement;
expect(initial).not.toBeNull();
expect(initial.textContent?.trim()).toBe('H');
});

it('initialOf falls back to "?" for empty titles', () => {
const fixture = render({ threads: [{ id: 't1', title: '' }] });
// title falls back to id "t1" via threadLabel, so initial should be "T".
const initial = fixture.nativeElement.querySelector('.chat-thread-list__initial') as HTMLElement;
expect(initial.textContent?.trim()).toBe('T');
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export interface ThreadActionAdapter {
(dragleave)="onDragLeave($event, thread.id)"
(drop)="onDrop($event, thread.id)"
(dragend)="onDragEnd()"
(contextmenu)="onRowContextMenu(thread.id, $event)"
>
@if (templateRef()) {
<ng-container
Expand Down Expand Up @@ -129,10 +130,12 @@ export interface ThreadActionAdapter {
<button
type="button"
class="chat-thread-list__item"
[attr.title]="threadLabel(thread)"
[attr.data-active]="thread.id === activeThreadId() ? 'true' : null"
[attr.aria-current]="thread.id === activeThreadId() ? 'true' : null"
(click)="selectThread(thread.id)"
>
<span class="chat-thread-list__initial" aria-hidden="true">{{ initialOf(threadLabel(thread)) }}</span>
<span class="chat-thread-list__item-title">
@if (thread.pinned) {
<svg class="chat-thread-list__item-pin" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
Expand Down Expand Up @@ -166,6 +169,7 @@ export interface ThreadActionAdapter {
[open]="menuOpenForId() !== null"
[items]="currentMenuItems()"
[anchor]="menuAnchor()"
[anchorPos]="menuAnchorPos()"
(itemSelected)="onMenuAction($event)"
(closed)="menuOpenForId.set(null)"
/>
Expand All @@ -174,6 +178,7 @@ export interface ThreadActionAdapter {
[open]="moveMenuOpenForId() !== null"
[items]="moveMenuItems()"
[anchor]="menuAnchor()"
[anchorPos]="menuAnchorPos()"
(itemSelected)="onMoveMenuAction($event)"
(closed)="moveMenuOpenForId.set(null)"
/>
Expand Down Expand Up @@ -206,6 +211,9 @@ export class ChatThreadListComponent {
protected readonly editingValue = signal<string>('');
protected readonly menuOpenForId = signal<string | null>(null);
protected readonly menuAnchor = signal<HTMLElement | null>(null);
/** Cursor-anchored position when the menu was opened via right-click.
* Mutually exclusive with `menuAnchor` — set one, null the other. */
protected readonly menuAnchorPos = signal<{ x: number; y: number } | null>(null);
protected readonly confirmDeleteId = signal<string | null>(null);

protected readonly moveMenuOpenForId = signal<string | null>(null);
Expand Down Expand Up @@ -333,9 +341,33 @@ export class ChatThreadListComponent {

protected openMenu(threadId: string, anchor: HTMLElement): void {
this.menuAnchor.set(anchor);
this.menuAnchorPos.set(null);
this.menuOpenForId.set(threadId);
}

/** Right-click on a row opens the same overflow menu anchored at the
* cursor. Always prevents the native context menu — including when the
* adapter exposes no row actions (in which case we open nothing rather
* than confusing the user with the OS menu on what looks like a custom
* list). */
protected onRowContextMenu(threadId: string, event: MouseEvent): void {
event.preventDefault();
if (!this.showKebab()) return;
if (this.editingThreadId() !== null) return;
this.menuAnchor.set(null);
this.menuAnchorPos.set({ x: event.clientX, y: event.clientY });
this.menuOpenForId.set(threadId);
}

/** First grapheme of a title rendered as an uppercase initial for the
* collapsed sidenav. Falls back to "?" for empty/whitespace titles. */
protected initialOf(title: string): string {
const trimmed = (title ?? '').trim();
if (!trimmed) return '?';
const first = Array.from(trimmed)[0];
return first.toUpperCase ? first.toUpperCase() : first;
}

protected onMenuAction(id: string): void {
const threadId = this.menuOpenForId();
this.menuOpenForId.set(null);
Expand Down
26 changes: 25 additions & 1 deletion libs/chat/src/lib/styles/chat-sidenav.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,11 @@ export const CHAT_SIDENAV_STYLES = `
border-bottom: 1px solid var(--ngaf-chat-separator);
}
:host([data-mode="collapsed"]) .chat-sidenav__topbar {
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--ngaf-chat-space-2);
gap: 4px;
padding: var(--ngaf-chat-space-2) 0;
}
.chat-sidenav__topbar .chat-sidenav__action {
width: 36px;
Expand Down Expand Up @@ -206,4 +209,25 @@ export const CHAT_SIDENAV_STYLES = `
:host([data-mode="collapsed"]) .chat-sidenav__archived { display: none; }
.chat-sidenav__projects { flex-shrink: 0; }
:host([data-mode="collapsed"]) .chat-sidenav__projects { display: none; }

/* Collapsed-mode thread row presentation: hide labels, kebab and grip;
* surface the per-thread initial circle so the strip reads as a list of
* threads rather than an empty column. */
:host([data-mode="collapsed"]) .chat-thread-list__initial {
display: inline-flex;
}
:host([data-mode="collapsed"]) .chat-thread-list__item-title { display: none; }
:host([data-mode="collapsed"]) .chat-thread-list__item-time { display: none; }
:host([data-mode="collapsed"]) .chat-thread-list__kebab { display: none; }
:host([data-mode="collapsed"]) .chat-thread-list__grip { display: none; }
:host([data-mode="collapsed"]) .chat-thread-list__item-wrap {
justify-content: center;
}
:host([data-mode="collapsed"]) .chat-thread-list__item {
padding: 6px;
justify-content: center;
align-items: center;
flex-direction: row;
}
:host([data-mode="collapsed"]) .chat-thread-list__new { display: none; }
`;
13 changes: 13 additions & 0 deletions libs/chat/src/lib/styles/chat-thread-list.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ export const CHAT_THREAD_LIST_STYLES = `
outline: 2px solid var(--ngaf-chat-primary);
outline-offset: 2px;
}
.chat-thread-list__initial {
display: none;
width: 28px;
height: 28px;
border-radius: 50%;
align-items: center;
justify-content: center;
background: var(--ngaf-chat-surface-alt);
color: var(--ngaf-chat-text);
font-weight: 500;
font-size: 13px;
flex-shrink: 0;
}
.chat-thread-list__item-pin {
width: 11px;
height: 11px;
Expand Down
Loading