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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
Reference in New Issue
Block a user