Andrew commited on
Commit
44320ea
·
1 Parent(s): 36505b3

Add fade effect and auto-hiding scrollbar to persona response carousel

Browse files
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -28,6 +28,7 @@
28
  import { goto } from "$app/navigation";
29
  import { base } from "$app/paths";
30
  import type { PersonaResponse } from "$lib/types/Message";
 
31
 
32
  interface Props {
33
  message: Message;
@@ -41,6 +42,11 @@
41
  personaName?: string;
42
  personaOccupation?: string;
43
  personaStance?: string;
 
 
 
 
 
44
  onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
45
  onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
46
  onbranch?: (messageId: string, personaId: string) => void;
@@ -60,6 +66,7 @@
60
  personaName,
61
  personaOccupation,
62
  personaStance,
 
63
  onretry,
64
  onshowAlternateMsg,
65
  onbranch,
@@ -72,15 +79,15 @@
72
  let messageInfoWidth: number = $state(0);
73
  let isBranching = $state(false);
74
 
75
- // Track expanded state for each persona card
76
  let expandedStates = $state<Record<string, boolean>>({});
77
-
78
- // Track which persona is currently "focused" (full-width carousel mode)
79
  let focusedPersonaId = $state<string | null>(null);
80
-
81
- // Track content elements for overflow detection
82
  let contentElements = $state<Record<string, HTMLElement | null>>({});
83
  const MAX_COLLAPSED_HEIGHT = 400;
 
 
 
 
84
 
85
  $effect(() => {
86
  // referenced to appease linter for currently-unused props
@@ -238,19 +245,29 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
238
  }
239
  }
240
 
241
- function hasClientThinkInContent(content: string | undefined): boolean {
242
- return content ? hasThinkSegments(content) : false;
243
- }
244
-
 
 
 
 
 
 
245
  function hasOverflow(personaId: string): boolean {
246
- const element = contentElements[personaId];
247
- if (!element) return false;
248
- return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
249
  }
250
 
251
  function openPersonaSettings(personaId: string) {
252
  goto(`${base}/settings/personas/${personaId}`);
253
  }
 
 
 
 
 
 
254
  </script>
255
 
256
  {#if message.from === "assistant"}
@@ -302,7 +319,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
302
  {/if}
303
 
304
  <!-- Container: horizontal scroll for multiple cards (unless focused), single card otherwise -->
305
- <div class="{hasMultipleCards && !focusedPersonaId ? 'flex gap-3 overflow-x-auto pb-2' : ''}">
306
  {#if isPersonaMode && responses.length === 0 && isLast && loading}
307
  <!-- Loading state: waiting for personas to start responding -->
308
  <div class="rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-4 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300">
@@ -314,6 +331,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
314
  {@const displayName = response.personaName || personaName || 'Assistant'}
315
  {@const isFocused = focusedPersonaId === response.personaId}
316
  {@const shouldHide = focusedPersonaId && !isFocused}
 
317
 
318
  {#if !shouldHide}
319
  <!-- Card: ALL use gradient bubble styling for consistency -->
@@ -321,7 +339,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
321
  class="rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-4 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300 {hasMultipleCards && !focusedPersonaId ? 'persona-card flex-shrink-0' : ''}"
322
  style={hasMultipleCards && !focusedPersonaId ? `min-width: 320px; max-width: ${isExpanded ? '600px' : '420px'};` : ''}
323
  >
324
- <!-- Persona Header: persona name + copy button (simplified, consistent for all) -->
325
  <div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
326
  {#if isPersonaMode}
327
  <button
@@ -339,10 +357,53 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
339
  </h3>
340
  {/if}
341
 
342
- <CopyToClipBoardBtn
343
- classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
344
- value={response.content}
345
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
  </div>
347
 
348
  <!-- File attachments: only for legacy mode (message-level, not persona-level) -->
@@ -365,20 +426,17 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
365
  class="mt-2"
366
  style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
367
  >
368
- {#if isLast && loading && message.content.length === 0 && !hasServerReasoning}
 
369
  <IconLoading classNames="loading inline ml-2 first:ml-0" />
370
- {/if}
371
-
372
- {#if hasClientThinkInContent(response.content)}
373
  {@const segments = splitThinkSegments(response.content ?? "")}
374
  {#each segments as part, _i}
375
  {#if part && part.startsWith("<think>")}
376
  {@const trimmed = part.trimEnd()}
377
  {@const isClosed = trimmed.endsWith("</think>")}
378
-
379
- {#if isClosed}
380
- <!-- Skip closed think tags - don't show reasoning content -->
381
- {:else}
382
  <ThinkingPlaceholder />
383
  {/if}
384
  {:else if part && part.trim().length > 0}
@@ -389,12 +447,6 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
389
  </div>
390
  {/if}
391
  {/each}
392
- {:else}
393
- <div
394
- class="prose max-w-none dark:prose-invert max-sm:prose-sm prose-headings:font-semibold prose-h1:text-lg prose-h2:text-base prose-h3:text-base prose-pre:bg-gray-800 dark:prose-pre:bg-gray-900"
395
- >
396
- <MarkdownRenderer content={response.content} loading={isLast && loading} />
397
- </div>
398
  {/if}
399
 
400
  {#if response.routerMetadata}
@@ -597,38 +649,69 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
597
  transition: all 0.3s ease;
598
  }
599
 
600
- /* Smooth scrollbar styling for multi-persona horizontal scroll */
601
- .overflow-x-auto {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
602
  scrollbar-width: thin;
603
- scrollbar-color: rgb(209 213 219) transparent;
604
  }
605
 
606
- .overflow-x-auto::-webkit-scrollbar {
607
- height: 8px;
608
  }
609
 
610
- .overflow-x-auto::-webkit-scrollbar-track {
611
  background: transparent;
 
 
 
 
 
 
 
612
  }
613
 
614
- .overflow-x-auto::-webkit-scrollbar-thumb {
615
- background-color: rgb(209 213 219);
616
- border-radius: 4px;
617
  }
618
 
619
- .overflow-x-auto::-webkit-scrollbar-thumb:hover {
620
- background-color: rgb(156 163 175);
621
  }
622
 
623
- :global(.dark) .overflow-x-auto {
624
- scrollbar-color: rgb(75 85 99) transparent;
 
625
  }
626
 
627
- :global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb {
628
- background-color: rgb(75 85 99);
629
  }
630
 
631
- :global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb:hover {
632
- background-color: rgb(107 114 128);
633
  }
634
  </style>
 
28
  import { goto } from "$app/navigation";
29
  import { base } from "$app/paths";
30
  import type { PersonaResponse } from "$lib/types/Message";
31
+ import { onDestroy } from "svelte";
32
 
33
  interface Props {
34
  message: Message;
 
42
  personaName?: string;
43
  personaOccupation?: string;
44
  personaStance?: string;
45
+ branchState?: {
46
+ messageId: string;
47
+ personaId: string;
48
+ personaName: string;
49
+ } | null;
50
  onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
51
  onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
52
  onbranch?: (messageId: string, personaId: string) => void;
 
66
  personaName,
67
  personaOccupation,
68
  personaStance,
69
+ branchState,
70
  onretry,
71
  onshowAlternateMsg,
72
  onbranch,
 
79
  let messageInfoWidth: number = $state(0);
80
  let isBranching = $state(false);
81
 
 
82
  let expandedStates = $state<Record<string, boolean>>({});
 
 
83
  let focusedPersonaId = $state<string | null>(null);
84
+
 
85
  let contentElements = $state<Record<string, HTMLElement | null>>({});
86
  const MAX_COLLAPSED_HEIGHT = 400;
87
+
88
+ // Track which branch button was just clicked for animation
89
+ let branchClickedPersonaId = $state<string | null>(null);
90
+ let branchClickTimeout: ReturnType<typeof setTimeout> | undefined;
91
 
92
  $effect(() => {
93
  // referenced to appease linter for currently-unused props
 
245
  }
246
  }
247
 
248
+ // Reactive overflow detection - updates during streaming
249
+ let overflowStates = $derived.by(() => {
250
+ const states: Record<string, boolean> = {};
251
+ responses.forEach(r => {
252
+ const element = contentElements[r.personaId];
253
+ states[r.personaId] = element ? element.scrollHeight > MAX_COLLAPSED_HEIGHT : false;
254
+ });
255
+ return states;
256
+ });
257
+
258
  function hasOverflow(personaId: string): boolean {
259
+ return overflowStates[personaId] || false;
 
 
260
  }
261
 
262
  function openPersonaSettings(personaId: string) {
263
  goto(`${base}/settings/personas/${personaId}`);
264
  }
265
+
266
+ onDestroy(() => {
267
+ if (branchClickTimeout) {
268
+ clearTimeout(branchClickTimeout);
269
+ }
270
+ });
271
  </script>
272
 
273
  {#if message.from === "assistant"}
 
319
  {/if}
320
 
321
  <!-- Container: horizontal scroll for multiple cards (unless focused), single card otherwise -->
322
+ <div class="{hasMultipleCards && !focusedPersonaId ? 'persona-scroll-container flex gap-3 overflow-x-auto pb-2 px-12' : ''}">
323
  {#if isPersonaMode && responses.length === 0 && isLast && loading}
324
  <!-- Loading state: waiting for personas to start responding -->
325
  <div class="rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-4 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300">
 
331
  {@const displayName = response.personaName || personaName || 'Assistant'}
332
  {@const isFocused = focusedPersonaId === response.personaId}
333
  {@const shouldHide = focusedPersonaId && !isFocused}
334
+ {@const isGenerating = isLast && loading && (!response.content || response.content.length === 0)}
335
 
336
  {#if !shouldHide}
337
  <!-- Card: ALL use gradient bubble styling for consistency -->
 
339
  class="rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-4 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300 {hasMultipleCards && !focusedPersonaId ? 'persona-card flex-shrink-0' : ''}"
340
  style={hasMultipleCards && !focusedPersonaId ? `min-width: 320px; max-width: ${isExpanded ? '600px' : '420px'};` : ''}
341
  >
342
+ <!-- Persona Header: persona name + action buttons -->
343
  <div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
344
  {#if isPersonaMode}
345
  <button
 
357
  </h3>
358
  {/if}
359
 
360
+ <div class="flex items-center gap-1">
361
+ {#if !loading && onretry}
362
+ <button
363
+ type="button"
364
+ class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
365
+ onclick={(e) => {
366
+ e.stopPropagation();
367
+ onretry?.({ id: message.id, personaId: response.personaId });
368
+ }}
369
+ aria-label="Regenerate {displayName}'s response"
370
+ title="Regenerate this response"
371
+ >
372
+ <CarbonRotate360 class="text-base" />
373
+ </button>
374
+ {/if}
375
+ {#if !loading && onbranch}
376
+ {@const isBranchClicked = branchClickedPersonaId === response.personaId}
377
+ <button
378
+ type="button"
379
+ class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
380
+ onclick={(e) => {
381
+ e.stopPropagation();
382
+
383
+ // Trigger animation
384
+ branchClickedPersonaId = response.personaId;
385
+ if (branchClickTimeout) {
386
+ clearTimeout(branchClickTimeout);
387
+ }
388
+ branchClickTimeout = setTimeout(() => {
389
+ branchClickedPersonaId = null;
390
+ }, 500);
391
+
392
+ onbranch?.(message.id, response.personaId);
393
+ }}
394
+ aria-label="Branch conversation with {displayName}"
395
+ title="Start private conversation with {displayName}"
396
+ >
397
+ <div class="relative transition-transform duration-200 {isBranchClicked ? 'scale-125' : 'scale-100'}">
398
+ <CarbonBranch class="text-base" />
399
+ </div>
400
+ </button>
401
+ {/if}
402
+ <CopyToClipBoardBtn
403
+ classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
404
+ value={response.content}
405
+ />
406
+ </div>
407
  </div>
408
 
409
  <!-- File attachments: only for legacy mode (message-level, not persona-level) -->
 
426
  class="mt-2"
427
  style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
428
  >
429
+ {#if isGenerating}
430
+ <!-- Loading state: show dots while generating -->
431
  <IconLoading classNames="loading inline ml-2 first:ml-0" />
432
+ {:else}
433
+ <!-- Content with think tag parsing -->
 
434
  {@const segments = splitThinkSegments(response.content ?? "")}
435
  {#each segments as part, _i}
436
  {#if part && part.startsWith("<think>")}
437
  {@const trimmed = part.trimEnd()}
438
  {@const isClosed = trimmed.endsWith("</think>")}
439
+ {#if !isClosed}
 
 
 
440
  <ThinkingPlaceholder />
441
  {/if}
442
  {:else if part && part.trim().length > 0}
 
447
  </div>
448
  {/if}
449
  {/each}
 
 
 
 
 
 
450
  {/if}
451
 
452
  {#if response.routerMetadata}
 
649
  transition: all 0.3s ease;
650
  }
651
 
652
+ /* Fade effect for horizontal scroll container */
653
+ .persona-scroll-container {
654
+ position: relative;
655
+ /* Add gradient mask to fade out cards at edges */
656
+ mask-image: linear-gradient(
657
+ to right,
658
+ transparent 0%,
659
+ black 40px,
660
+ black calc(100% - 40px),
661
+ transparent 100%
662
+ );
663
+ -webkit-mask-image: linear-gradient(
664
+ to right,
665
+ transparent 0%,
666
+ black 40px,
667
+ black calc(100% - 40px),
668
+ transparent 100%
669
+ );
670
+ }
671
+
672
+ .persona-scroll-container {
673
+ scrollbar-width: none;
674
+ }
675
+
676
+ .persona-scroll-container:hover {
677
  scrollbar-width: thin;
678
+ scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
679
  }
680
 
681
+ .persona-scroll-container::-webkit-scrollbar {
682
+ height: 6px;
683
  }
684
 
685
+ .persona-scroll-container::-webkit-scrollbar-track {
686
  background: transparent;
687
+ margin: 0 48px; /* Match the px-12 padding (3rem = 48px) */
688
+ }
689
+
690
+ .persona-scroll-container::-webkit-scrollbar-thumb {
691
+ background-color: transparent;
692
+ border-radius: 10px;
693
+ transition: background-color 0.3s ease;
694
  }
695
 
696
+ /* Show scrollbar thumb on hover */
697
+ .persona-scroll-container:hover::-webkit-scrollbar-thumb {
698
+ background-color: rgba(156, 163, 175, 0.5);
699
  }
700
 
701
+ .persona-scroll-container::-webkit-scrollbar-thumb:hover {
702
+ background-color: rgba(107, 114, 128, 0.7);
703
  }
704
 
705
+ /* Dark mode */
706
+ :global(.dark) .persona-scroll-container:hover {
707
+ scrollbar-color: rgba(107, 114, 128, 0.5) transparent;
708
  }
709
 
710
+ :global(.dark) .persona-scroll-container:hover::-webkit-scrollbar-thumb {
711
+ background-color: rgba(107, 114, 128, 0.5);
712
  }
713
 
714
+ :global(.dark) .persona-scroll-container::-webkit-scrollbar-thumb:hover {
715
+ background-color: rgba(156, 163, 175, 0.7);
716
  }
717
  </style>