floating chat

This commit is contained in:
Bastian Wagner
2026-06-15 13:58:40 +02:00
parent 86e85520a8
commit b979a3b097
6 changed files with 151 additions and 92 deletions

View File

@@ -104,12 +104,12 @@
<main class="app-main"> <main class="app-main">
<router-outlet /> <router-outlet />
</main> </main>
<app-assistant-chat class="assistant-sidebar" />
</div> </div>
</mat-sidenav-content> </mat-sidenav-content>
</mat-sidenav-container> </mat-sidenav-container>
<app-assistant-chat />
<nav class="bottom-nav" aria-label="Mobile Hauptnavigation"> <nav class="bottom-nav" aria-label="Mobile Hauptnavigation">
<a <a
class="bottom-nav-link" class="bottom-nav-link"

View File

@@ -125,10 +125,6 @@
min-height: calc(100dvh - 56px); min-height: calc(100dvh - 56px);
} }
.assistant-sidebar {
min-width: 0;
}
.shell .app-main { .shell .app-main {
padding-bottom: calc(76px + env(safe-area-inset-bottom)); padding-bottom: calc(76px + env(safe-area-inset-bottom));
} }
@@ -232,16 +228,3 @@
display: none; display: none;
} }
} }
@media (min-width: 1041px) {
.shell-workspace {
grid-template-columns: minmax(0, 1fr) 360px;
align-items: start;
}
.assistant-sidebar {
position: sticky;
top: 64px;
min-height: calc(100dvh - 64px);
}
}

View File

@@ -1,70 +1,95 @@
<section class="assistant-shell" aria-label="Listify-Assistent"> <div class="assistant-floating">
<header class="assistant-header"> @if (open()) {
<div> <section class="assistant-shell" aria-label="Listify-Assistent">
<h2>Assistent</h2> <header class="assistant-header">
<p>{{ onlineStatus.online() ? 'Listen planen und erstellen' : 'Nur online verfuegbar' }}</p> <div>
</div> <h2>Assistent</h2>
<mat-icon aria-hidden="true">{{ onlineStatus.online() ? 'auto_awesome' : 'cloud_off' }}</mat-icon> <p>{{ onlineStatus.online() ? 'Listen planen und erstellen' : 'Nur online verfuegbar' }}</p>
</header> </div>
<button
mat-icon-button
type="button"
aria-label="Assistent schliessen"
(click)="close()"
>
<mat-icon aria-hidden="true">close</mat-icon>
</button>
</header>
<div class="message-list" aria-live="polite"> <div class="message-list" aria-live="polite">
@for (message of messages(); track $index) { @for (message of messages(); track $index) {
<article class="message" [class.user-message]="message.role === 'user'"> <article class="message" [class.user-message]="message.role === 'user'">
<div <div
class="message-content" class="message-content"
[innerHTML]="formatMessage(message.content)" [innerHTML]="formatMessage(message.content)"
></div> ></div>
@if (message.actions?.length) { @if (message.actions?.length) {
<div class="action-list"> <div class="action-list">
@for (action of message.actions; track action.type + action.listId) { @for (action of message.actions; track action.type + action.listId) {
<a mat-stroked-button [routerLink]="['/lists', action.listId]"> <a mat-stroked-button [routerLink]="['/lists', action.listId]">
<mat-icon aria-hidden="true">open_in_new</mat-icon> <mat-icon aria-hidden="true">open_in_new</mat-icon>
{{ actionLabel(action) }} {{ actionLabel(action) }}
</a> </a>
}
</div>
} }
</article>
}
@if (sending()) {
<div class="assistant-loading">
<mat-progress-spinner mode="indeterminate" diameter="22" />
<span>Antwort wird erstellt</span>
</div> </div>
} }
</article>
}
@if (sending()) {
<div class="assistant-loading">
<mat-progress-spinner mode="indeterminate" diameter="22" />
<span>Antwort wird erstellt</span>
</div> </div>
}
</div>
@if (errorMessage()) { @if (errorMessage()) {
<p class="assistant-error"> <p class="assistant-error">
<mat-icon aria-hidden="true">error</mat-icon> <mat-icon aria-hidden="true">error</mat-icon>
{{ errorMessage() }} {{ errorMessage() }}
</p> </p>
}
<div class="composer">
<mat-form-field appearance="outline">
<mat-label>Nachricht</mat-label>
<textarea
matInput
rows="3"
[value]="draft()"
[disabled]="!onlineStatus.online()"
(input)="draft.set($any($event.target).value)"
(keydown.enter)="handleEnter($event)"
></textarea>
</mat-form-field>
<button
mat-flat-button
type="button"
[disabled]="!canSend()"
(click)="send()"
aria-label="Nachricht senden"
>
<mat-icon aria-hidden="true">send</mat-icon>
Senden
</button>
</div>
</section>
} }
<div class="composer"> <button
<mat-form-field appearance="outline"> mat-fab
<mat-label>Nachricht</mat-label> type="button"
<textarea class="assistant-launcher"
matInput [class.offline]="!onlineStatus.online()"
rows="3" [attr.aria-expanded]="open()"
[value]="draft()" [attr.aria-label]="open() ? 'Assistent schliessen' : 'Assistent oeffnen'"
[disabled]="!onlineStatus.online()" (click)="toggleOpen()"
(input)="draft.set($any($event.target).value)" >
(keydown.enter)="handleEnter($event)" <mat-icon aria-hidden="true">
></textarea> {{ open() ? 'close' : (onlineStatus.online() ? 'auto_awesome' : 'cloud_off') }}
</mat-form-field> </mat-icon>
</button>
<button </div>
mat-flat-button
type="button"
[disabled]="!canSend()"
(click)="send()"
aria-label="Nachricht senden"
>
<mat-icon aria-hidden="true">send</mat-icon>
Senden
</button>
</div>
</section>

View File

@@ -1,18 +1,32 @@
:host { :host {
display: block; display: contents;
min-width: 0; }
.assistant-floating {
position: fixed;
right: 1rem;
bottom: calc(76px + env(safe-area-inset-bottom));
z-index: 35;
display: grid;
justify-items: end;
gap: 0.75rem;
pointer-events: none;
} }
.assistant-shell { .assistant-shell {
display: grid; display: grid;
grid-template-rows: auto minmax(220px, 1fr) auto auto; grid-template-rows: auto minmax(220px, 1fr) auto auto;
gap: 0.75rem; gap: 0.75rem;
width: 100%; width: min(360px, calc(100vw - 2rem));
min-height: 420px; height: min(640px, calc(100dvh - 166px));
max-height: calc(100dvh - 88px); min-height: 360px;
padding: 0.85rem; padding: 0.85rem;
border-left: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent);
background: color-mix(in srgb, var(--mat-sys-surface) 94%, transparent); border-radius: 8px;
background: color-mix(in srgb, var(--mat-sys-surface) 96%, transparent);
box-shadow: 0 18px 48px color-mix(in srgb, var(--mat-sys-shadow) 20%, transparent);
pointer-events: auto;
backdrop-filter: blur(14px);
} }
.assistant-header { .assistant-header {
@@ -44,6 +58,10 @@
color: var(--mat-sys-primary); color: var(--mat-sys-primary);
} }
.assistant-header button {
flex: 0 0 auto;
}
.message-list { .message-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -158,10 +176,34 @@
justify-self: end; justify-self: end;
} }
@media (max-width: 1040px) { .assistant-launcher {
pointer-events: auto;
box-shadow: 0 12px 28px color-mix(in srgb, var(--mat-sys-shadow) 24%, transparent);
}
.assistant-launcher.offline {
--mdc-fab-container-color: var(--mat-sys-error-container);
--mat-fab-foreground-color: var(--mat-sys-on-error-container);
}
@media (min-width: 801px) {
.assistant-floating {
bottom: 1rem;
}
.assistant-shell { .assistant-shell {
max-height: none; height: min(640px, calc(100dvh - 6rem));
border-top: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 72%, transparent); }
border-left: 0; }
@media (max-width: 480px) {
.assistant-floating {
right: 0.75rem;
left: 0.75rem;
}
.assistant-shell {
width: 100%;
height: min(620px, calc(100dvh - 154px));
} }
} }

View File

@@ -47,6 +47,7 @@ export class AssistantChatComponent {
protected readonly draft = signal(''); protected readonly draft = signal('');
protected readonly sending = signal(false); protected readonly sending = signal(false);
protected readonly errorMessage = signal<string | null>(null); protected readonly errorMessage = signal<string | null>(null);
protected readonly open = signal(false);
protected readonly canSend = computed( protected readonly canSend = computed(
() => () =>
this.draft().trim().length > 0 && this.draft().trim().length > 0 &&
@@ -109,6 +110,14 @@ export class AssistantChatComponent {
}); });
} }
protected toggleOpen(): void {
this.open.update((open) => !open);
}
protected close(): void {
this.open.set(false);
}
private resolveContext(): Observable<AssistantPageContext> { private resolveContext(): Observable<AssistantPageContext> {
const route = this.router.url || '/'; const route = this.router.url || '/';
const path = route.split(/[?#]/)[0] || '/'; const path = route.split(/[?#]/)[0] || '/';

View File

@@ -63,7 +63,7 @@ export class ListsComponent implements OnInit {
protected readonly errorMessage = signal<string | null>(null); protected readonly errorMessage = signal<string | null>(null);
protected readonly searchTerm = signal(''); protected readonly searchTerm = signal('');
protected readonly kindFilter = signal<ListKindFilter>('all'); protected readonly kindFilter = signal<ListKindFilter>('all');
protected readonly statusFilter = signal<ListStatusFilter>('all'); protected readonly statusFilter = signal<ListStatusFilter>('open');
protected readonly sortOption = signal<ListSortOption>('updated-desc'); protected readonly sortOption = signal<ListSortOption>('updated-desc');
protected readonly hasLists = computed(() => this.lists().length > 0); protected readonly hasLists = computed(() => this.lists().length > 0);
protected readonly visibleLists = computed(() => { protected readonly visibleLists = computed(() => {