feat(stella-helper): long-press close UX + minimum thinking animation

Close button now uses quick-click to dismiss and long-press to reveal
mute options. Chat stream emits 'start' immediately so the mascot
thinking animation plays during the HTTP wait with an 800ms minimum
duration. User preferences page gains a tutorial reset button.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
master
2026-04-07 15:33:47 +03:00
parent 1e84d98413
commit b07914936c
5 changed files with 159 additions and 16 deletions

View File

@@ -181,6 +181,10 @@ export class ChatService {
// Use the legacy conversation turns endpoint routed via Valkey RPC.
// The gateway maps /api/v1/advisory-ai/* to the backend via Valkey.
// HandleAddTurn returns JSON (not SSE), so we simulate streaming client-side.
// Emit 'start' immediately so the mascot thinking animation plays during the HTTP wait
this.streamEvents$.next({ event: 'start', data: {} } as any);
this.http.post<any>(`${API_BASE}/conversations/${conversationId}/turns`, {
content: message,
}).subscribe({
@@ -190,8 +194,6 @@ export class ChatService {
const words = content.split(' ');
let accumulated = '';
this.streamEvents$.next({ event: 'start', data: {} } as any);
let i = 0;
const interval = setInterval(() => {
if (i < words.length) {

View File

@@ -325,6 +325,14 @@ type LocaleSaveState = 'idle' | 'saving' | 'saved' | 'syncFailed';
</div>
<button type="button" class="prefs-btn" (click)="stellaPrefs.resetSeenPages()">Reset</button>
</div>
<div class="toggle-row">
<div class="toggle-row__info">
<span class="toggle-row__label">Reset page tutorials</span>
<span class="toggle-row__hint">Re-show "About this page" panels on all pages</span>
</div>
<button type="button" class="prefs-btn" (click)="stellaPrefs.resetPageHelpDismissals()">Reset</button>
</div>
</div>
}
}

View File

@@ -123,6 +123,8 @@ export class StellaAssistantService {
// ---- Chat animation state ----
readonly chatAnimationState = signal<'idle' | 'thinking' | 'typing' | 'done' | 'error'>('idle');
private chatStreamSub?: Subscription;
private thinkingStartedAt = 0;
private readonly MIN_THINKING_MS = 800;
// ---- Session persistence ----
readonly activeConversationId = signal<string | null>(null);
@@ -287,16 +289,25 @@ export class StellaAssistantService {
// Connect mascot animation to chat stream events
this.chatStreamSub?.unsubscribe();
this.chatAnimationState.set('idle');
this.thinkingStartedAt = 0;
this.chatStreamSub = this.chatService.streamEvents.subscribe((event) => {
const type = (event as any).event ?? '';
switch (type) {
case 'start':
case 'progress':
this.chatAnimationState.set('thinking');
this.thinkingStartedAt = Date.now();
break;
case 'token':
this.chatAnimationState.set('typing');
case 'token': {
// Enforce minimum thinking duration so the animation is visible
const elapsed = Date.now() - this.thinkingStartedAt;
if (this.chatAnimationState() === 'thinking' && elapsed < this.MIN_THINKING_MS) {
setTimeout(() => this.chatAnimationState.set('typing'), this.MIN_THINKING_MS - elapsed);
} else {
this.chatAnimationState.set('typing');
}
break;
}
case 'done':
this.chatAnimationState.set('done');
setTimeout(() => this.chatAnimationState.set('idle'), 2000);

View File

@@ -28,6 +28,7 @@ import {
const IDLE_ANIMATION_INTERVAL = 12_000; // wiggle every 12s
const AUTO_TIP_INTERVAL = 45_000; // suggest next tip every 45s
const LONG_PRESS_MS = 500; // hold threshold before showing mute menu
/**
* StellaHelperComponent — Clippy-style contextual help assistant.
@@ -92,19 +93,28 @@ const AUTO_TIP_INTERVAL = 45_000; // suggest next tip every 45s
<!-- Speech bubble -->
@if (bubbleOpen() && !assistantDrawer.isOpen()) {
<div class="helper__bubble" role="status" aria-live="polite">
<!-- Mute dropdown (replaces close button) -->
<!-- Close button: quick click = close bubble, hold = mute options -->
<div class="helper__mute-wrap">
<button
class="helper__bubble-close"
(click)="muteDropdownOpen.set(!muteDropdownOpen()); $event.stopPropagation()"
title="Mute options"
aria-label="Mute options"
[class.helper__bubble-close--holding]="holding()"
(mousedown)="onCloseDown($event)"
(touchstart)="onCloseDown($event)"
title="Click to close · Hold for options"
aria-label="Close tooltip"
[attr.aria-expanded]="muteDropdownOpen()"
>
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
@if (holding()) {
<svg class="helper__hold-ring" viewBox="0 0 36 36" aria-hidden="true">
<circle cx="18" cy="18" r="16" fill="none" stroke="var(--color-brand-primary, #F5A623)"
stroke-width="3" stroke-dasharray="100.5" stroke-dashoffset="100.5"
stroke-linecap="round" />
</svg>
}
</button>
@if (muteDropdownOpen()) {
<div class="helper__mute-menu" role="menu">
@@ -504,9 +514,10 @@ const AUTO_TIP_INTERVAL = 45_000; // suggest next tip every 45s
}
.helper__bubble-close {
position: absolute;
top: 6px;
right: 6px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: 1px solid transparent;
@@ -514,11 +525,10 @@ const AUTO_TIP_INTERVAL = 45_000; // suggest next tip every 45s
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
transition: background 0.15s, color 0.15s, border-color 0.15s;
z-index: 2;
-webkit-user-select: none;
user-select: none;
&:hover {
background: color-mix(in srgb, var(--color-status-error, #c62828) 10%, transparent);
@@ -527,6 +537,26 @@ const AUTO_TIP_INTERVAL = 45_000; // suggest next tip every 45s
}
}
.helper__bubble-close--holding {
border-color: var(--color-brand-primary, #F5A623);
}
/* Progress ring around close button during hold */
.helper__hold-ring {
position: absolute;
top: 50%; left: 50%;
width: 34px; height: 34px;
transform: translate(-50%, -50%) rotate(-90deg);
pointer-events: none;
}
.helper__hold-ring circle {
animation: helper-hold-ring-fill 0.5s linear forwards;
}
@keyframes helper-hold-ring-fill {
0% { stroke-dashoffset: 100.5; }
100% { stroke-dashoffset: 0; }
}
.helper__bubble-content {
padding: 14px 16px 10px;
}
@@ -1148,7 +1178,8 @@ const AUTO_TIP_INTERVAL = 45_000; // suggest next tip every 45s
.helper__thought-dots span,
.helper__search-zone,
.helper__bubble,
.helper__mascot-pulse {
.helper__mascot-pulse,
.helper__hold-ring circle {
animation: none !important;
}
}
@@ -1162,6 +1193,11 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
readonly assistantDrawer = inject(SearchAssistantDrawerService);
readonly stellaPrefs = inject(StellaPreferencesService);
readonly muteDropdownOpen = signal(false);
readonly holding = signal(false);
private holdTimer: ReturnType<typeof setTimeout> | null = null;
private holdStart = 0;
private menuJustOpened = false;
private readonly boundGlobalUp = (e: Event) => this.onCloseUp(e);
@ViewChild('mascotInput') mascotInputRef?: ElementRef<HTMLInputElement>;
private routerSub?: Subscription;
@@ -1305,6 +1341,56 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
this.routerSub?.unsubscribe();
if (this.idleTimer) clearInterval(this.idleTimer);
if (this.autoTipTimer) clearInterval(this.autoTipTimer);
this.clearHold();
document.removeEventListener('mouseup', this.boundGlobalUp);
document.removeEventListener('touchend', this.boundGlobalUp);
}
// ---- Close button: quick click vs long press (matches page-help-panel) ----
onCloseDown(e: Event): void {
e.preventDefault();
e.stopPropagation();
this.holdStart = Date.now();
this.holding.set(true);
document.addEventListener('mouseup', this.boundGlobalUp, { once: true });
document.addEventListener('touchend', this.boundGlobalUp, { once: true });
this.holdTimer = setTimeout(() => {
// Long press reached — show mute options
this.holding.set(false);
this.muteDropdownOpen.set(true);
this.menuJustOpened = true;
}, LONG_PRESS_MS);
}
private onCloseUp(e: Event): void {
e.preventDefault();
const elapsed = Date.now() - this.holdStart;
this.clearHold();
document.removeEventListener('mouseup', this.boundGlobalUp);
document.removeEventListener('touchend', this.boundGlobalUp);
if (this.muteDropdownOpen()) {
// Menu was opened by long-press — keep it open
setTimeout(() => this.menuJustOpened = false, 100);
return;
}
if (elapsed < LONG_PRESS_MS) {
// Quick click — close the bubble
this.onCloseBubble();
}
}
private clearHold(): void {
if (this.holdTimer) {
clearTimeout(this.holdTimer);
this.holdTimer = null;
}
this.holding.set(false);
}
// ---- Event handlers ----
@@ -1381,6 +1467,7 @@ export class StellaHelperComponent implements OnInit, OnDestroy {
@HostListener('document:click')
onDocumentClick(): void {
if (this.menuJustOpened) return;
if (this.muteDropdownOpen()) {
this.muteDropdownOpen.set(false);
}

View File

@@ -18,6 +18,8 @@ export interface StellaHelperPreferences {
dismissedBanners: string[];
seenHelpPages: string[];
pageHelpOpen: Record<string, boolean>;
pageHelpDismissedGlobal: boolean;
pageHelpDismissedPages: string[];
}
const STORAGE_KEY = 'stellaops.helper.preferences';
@@ -32,6 +34,8 @@ const DEFAULTS: StellaHelperPreferences = {
dismissedBanners: [],
seenHelpPages: [],
pageHelpOpen: {},
pageHelpDismissedGlobal: false,
pageHelpDismissedPages: [],
};
@Injectable({ providedIn: 'root' })
@@ -161,6 +165,35 @@ export class StellaPreferencesService {
}));
}
// ---- Page help tutorial dismiss tiers ----
dismissPageHelpGlobal(): void {
this.prefs.update(p => ({ ...p, pageHelpDismissedGlobal: true }));
}
dismissPageHelpForPage(pageKey: string): void {
this.prefs.update(p => ({
...p,
pageHelpDismissedPages: p.pageHelpDismissedPages.includes(pageKey)
? p.pageHelpDismissedPages
: [...p.pageHelpDismissedPages, pageKey],
}));
}
isPageHelpDismissed(pageKey: string): boolean {
const p = this.prefs();
return p.pageHelpDismissedGlobal || p.pageHelpDismissedPages.includes(pageKey);
}
resetPageHelpDismissals(): void {
this.prefs.update(p => ({
...p,
pageHelpDismissedGlobal: false,
pageHelpDismissedPages: [],
pageHelpOpen: {},
}));
}
// ---- Persistence ----
private load(): StellaHelperPreferences {
@@ -178,6 +211,8 @@ export class StellaPreferencesService {
dismissedBanners: Array.isArray(p.dismissedBanners) ? p.dismissedBanners : DEFAULTS.dismissedBanners,
seenHelpPages: Array.isArray(p.seenHelpPages) ? p.seenHelpPages : DEFAULTS.seenHelpPages,
pageHelpOpen: p.pageHelpOpen && typeof p.pageHelpOpen === 'object' ? p.pageHelpOpen : DEFAULTS.pageHelpOpen,
pageHelpDismissedGlobal: typeof p.pageHelpDismissedGlobal === 'boolean' ? p.pageHelpDismissedGlobal : DEFAULTS.pageHelpDismissedGlobal,
pageHelpDismissedPages: Array.isArray(p.pageHelpDismissedPages) ? p.pageHelpDismissedPages : DEFAULTS.pageHelpDismissedPages,
};
}
} catch { /* ignore */ }