Andrew commited on
Commit
9cdcacf
·
1 Parent(s): 257862e

Refactor ChatMessage to render persona responses inline with expand/collapse and focus modes

Browse files
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -8,7 +8,10 @@
8
  import IconLoading from "../icons/IconLoading.svelte";
9
  import CarbonRotate360 from "~icons/carbon/rotate-360";
10
  import CarbonBranch from "~icons/carbon/branch";
11
-
 
 
 
12
  import CarbonPen from "~icons/carbon/pen";
13
  import UploadedFile from "./UploadedFile.svelte";
14
 
@@ -20,9 +23,11 @@
20
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
21
  import Alternatives from "./Alternatives.svelte";
22
  import MessageAvatar from "./MessageAvatar.svelte";
23
- import PersonaResponseCarousel from "./PersonaResponseCarousel.svelte";
24
  import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
25
  import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
 
 
 
26
 
27
  interface Props {
28
  message: Message;
@@ -63,10 +68,19 @@
63
  }: Props = $props();
64
 
65
  let contentEl: HTMLElement | undefined = $state();
66
- let isCopied = $state(false);
67
  let messageWidth: number = $state(0);
68
  let messageInfoWidth: number = $state(0);
69
  let isBranching = $state(false);
 
 
 
 
 
 
 
 
 
 
70
 
71
  $effect(() => {
72
  // referenced to appease linter for currently-unused props
@@ -107,16 +121,32 @@ let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
107
  message.reasoning.trim().length > 0
108
  );
109
  let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
110
- let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
111
 
112
- $effect(() => {
113
- if (isCopied) {
114
- setTimeout(() => {
115
- isCopied = false;
116
- }, 1000);
 
 
 
117
  }
 
 
 
 
 
 
 
 
 
 
 
118
  });
119
 
 
 
 
120
  let editMode = $derived(editMsdgId === message.id);
121
  $effect(() => {
122
  if (editMode) {
@@ -170,6 +200,57 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
170
  handleBranchClick();
171
  }
172
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  </script>
174
 
175
  {#if message.from === "assistant"}
@@ -179,8 +260,8 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
179
  messageInfoWidth >= messageWidth
180
  ? 'mb-1'
181
  : ''}"
182
- class:w-full={hasPersonaResponses}
183
- class:w-fit={!hasPersonaResponses}
184
  data-message-id={message.id}
185
  data-message-role="assistant"
186
  role="presentation"
@@ -192,94 +273,169 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
192
  animating={isLast && loading}
193
  />
194
 
195
- {#if message.personaResponses && message.personaResponses.length > 0}
196
- <!-- Multi-persona mode: no outer container, just carousel -->
197
- <div bind:this={contentEl} class="flex-1 min-w-0">
198
- <PersonaResponseCarousel
199
- personaResponses={message.personaResponses}
200
- loading={isLast && loading}
201
- onretry={(personaId: string) => onretry?.({ id: message.id, content: undefined, personaId })}
202
- messageId={message.id}
203
- onbranch={onbranch}
204
- messageBranches={messageBranches}
205
- onopenbranchmodal={onopenbranchmodal}
206
- />
207
- </div>
208
- {:else}
209
- <div
210
- class="relative flex min-w-[60px] flex-col gap-2 break-words rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 prose-pre:my-2 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300"
211
- >
212
- <!-- Persona Name Header (for single-persona mode) -->
213
- {#if personaName}
214
- <div class="mb-2 flex items-start justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
215
- <div>
216
- <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
217
- {personaName}
218
- </h3>
219
- {#if personaOccupation || personaStance}
220
- <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
221
- {#if personaOccupation}<span>{personaOccupation}</span>{/if}{#if personaOccupation && personaStance}<span class="mx-1">•</span>{/if}{#if personaStance}<span>{personaStance}</span>{/if}
222
- </div>
223
- {/if}
224
- </div>
225
- </div>
226
  {/if}
227
-
228
- {#if message.files?.length}
229
- <div class="flex h-fit flex-wrap gap-x-5 gap-y-2">
230
- {#each message.files as file (file.value)}
231
- <UploadedFile {file} canClose={false} />
232
- {/each}
233
- </div>
 
 
234
  {/if}
235
-
236
- {#if hasServerReasoning && loading && message.content.length === 0}
237
- <!-- Show loading indicator while reasoning is in progress -->
238
- <ThinkingPlaceholder />
239
  {/if}
240
-
241
- <div bind:this={contentEl}>
242
- {#if isLast && loading && message.content.length === 0 && !hasServerReasoning}
243
- <IconLoading classNames="loading inline ml-2 first:ml-0" />
 
 
 
 
244
  {/if}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
 
246
- {#if hasClientThink}
247
- {#each thinkSegments as part, _i}
248
- {#if part && part.startsWith("<think>")}
249
- {@const trimmed = part.trimEnd()}
250
- {@const isClosed = trimmed.endsWith("</think>")}
 
 
 
251
 
252
- {#if isClosed}
253
- <!-- Skip closed think tags - don't show reasoning content -->
254
- {:else}
255
  <ThinkingPlaceholder />
256
  {/if}
257
- {:else if part && part.trim().length > 0}
258
- <div
259
- 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"
 
 
 
260
  >
261
- <MarkdownRenderer content={part} loading={isLast && loading} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  {/if}
264
- {/each}
265
- {:else}
266
- <div
267
- 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"
268
- >
269
- <MarkdownRenderer content={message.content} loading={isLast && loading} />
270
  </div>
271
- {/if}
 
272
  </div>
273
 
274
- </div>
275
-
276
- <!-- Action bar outside the message border -->
277
- {#if !isLast || !loading}
278
- <div class="mt-1.5 flex items-center justify-end gap-1 px-2">
279
- {#if onbranch && personaName}
280
- {@const branchCount = personaBranches.length}
281
- {@const hasExistingBranches = branchCount > 0}
282
-
283
  <button
284
  type="button"
285
  class="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50 {isBranching ? 'animate-pulse' : ''}"
@@ -292,18 +448,9 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
292
  <span>({branchCount})</span>
293
  {/if}
294
  </button>
295
- {/if}
296
- <CopyToClipBoardBtn
297
- onClick={() => {
298
- isCopied = true;
299
- }}
300
- classNames="btn rounded-md p-2 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50"
301
- value={message.content}
302
- iconClassNames="text-xs"
303
- />
304
- </div>
305
- {/if}
306
- {/if}
307
 
308
  {#if message.routerMetadata && (!isLast || !loading)}
309
  <div
@@ -445,4 +592,43 @@ let hasPersonaResponses = $derived((message.personaResponses?.length ?? 0) > 0);
445
  stroke-dashoffset: 122.9;
446
  }
447
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
448
  </style>
 
8
  import IconLoading from "../icons/IconLoading.svelte";
9
  import CarbonRotate360 from "~icons/carbon/rotate-360";
10
  import CarbonBranch from "~icons/carbon/branch";
11
+ import CarbonChevronDown from "~icons/carbon/chevron-down";
12
+ import CarbonChevronUp from "~icons/carbon/chevron-up";
13
+ import CarbonChevronLeft from "~icons/carbon/chevron-left";
14
+ import CarbonChevronRight from "~icons/carbon/chevron-right";
15
  import CarbonPen from "~icons/carbon/pen";
16
  import UploadedFile from "./UploadedFile.svelte";
17
 
 
23
  import MarkdownRenderer from "./MarkdownRenderer.svelte";
24
  import Alternatives from "./Alternatives.svelte";
25
  import MessageAvatar from "./MessageAvatar.svelte";
 
26
  import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
27
  import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
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;
 
68
  }: Props = $props();
69
 
70
  let contentEl: HTMLElement | undefined = $state();
 
71
  let messageWidth: number = $state(0);
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
 
121
  message.reasoning.trim().length > 0
122
  );
123
  let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
 
124
 
125
+ // Check if using persona-based response structure (vs legacy message structure)
126
+ // Check for existence of the property, not length - empty array [] means "loading personas"
127
+ let isPersonaMode = $derived(message.personaResponses !== undefined);
128
+
129
+ // Unified responses array: use personaResponses if available, otherwise wrap message as single response
130
+ let responses = $derived.by((): PersonaResponse[] => {
131
+ if (isPersonaMode) {
132
+ return message.personaResponses!;
133
  }
134
+ // Legacy mode: convert message to PersonaResponse format for unified rendering
135
+ return [{
136
+ personaId: 'single',
137
+ personaName: personaName || '',
138
+ personaOccupation,
139
+ personaStance,
140
+ content: message.content,
141
+ reasoning: message.reasoning,
142
+ updates: message.updates,
143
+ routerMetadata: message.routerMetadata,
144
+ }];
145
  });
146
 
147
+ // Multiple cards need horizontal scroll layout
148
+ let hasMultipleCards = $derived(responses.length > 1);
149
+
150
  let editMode = $derived(editMsdgId === message.id);
151
  $effect(() => {
152
  if (editMode) {
 
200
  handleBranchClick();
201
  }
202
  }
203
+
204
+ // Unified helper functions for card rendering
205
+ function toggleExpanded(personaId: string) {
206
+ const isCurrentlyExpanded = expandedStates[personaId];
207
+
208
+ // In multi-card view, "Show less" should collapse all cards
209
+ if (hasMultipleCards && isCurrentlyExpanded) {
210
+ responses.forEach(r => {
211
+ expandedStates[r.personaId] = false;
212
+ });
213
+ focusedPersonaId = null;
214
+ } else {
215
+ // Otherwise, just toggle the individual card's state
216
+ expandedStates[personaId] = !isCurrentlyExpanded;
217
+ }
218
+ }
219
+
220
+ function setFocus(personaId: string) {
221
+ if (hasMultipleCards) {
222
+ // Enter focused mode and ensure the card is expanded
223
+ focusedPersonaId = personaId;
224
+ expandedStates[personaId] = true;
225
+ }
226
+ }
227
+
228
+ function navigateFocused(direction: 'prev' | 'next') {
229
+ if (!focusedPersonaId || !hasMultipleCards) return;
230
+
231
+ const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId);
232
+ if (currentIndex === -1) return;
233
+
234
+ const nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1;
235
+ if (nextIndex >= 0 && nextIndex < responses.length) {
236
+ focusedPersonaId = responses[nextIndex].personaId;
237
+ expandedStates[focusedPersonaId] = true;
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"}
 
260
  messageInfoWidth >= messageWidth
261
  ? 'mb-1'
262
  : ''}"
263
+ class:w-full={isPersonaMode}
264
+ class:w-fit={!isPersonaMode}
265
  data-message-id={message.id}
266
  data-message-role="assistant"
267
  role="presentation"
 
273
  animating={isLast && loading}
274
  />
275
 
276
+ <div class="flex-1 min-w-0 relative">
277
+ <!-- Focused mode carousel navigation arrows -->
278
+ {#if focusedPersonaId && hasMultipleCards}
279
+ {@const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId)}
280
+ {@const hasPrev = currentIndex > 0}
281
+ {@const hasNext = currentIndex < responses.length - 1}
282
+
283
+ {#if hasPrev}
284
+ <button
285
+ onclick={() => navigateFocused('prev')}
286
+ class="absolute -left-12 top-1/2 z-10 -translate-y-1/2 rounded-full p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 transition-all"
287
+ aria-label="Previous persona"
288
+ >
289
+ <CarbonChevronLeft class="text-3xl" />
290
+ </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  {/if}
292
+
293
+ {#if hasNext}
294
+ <button
295
+ onclick={() => navigateFocused('next')}
296
+ class="absolute -right-12 top-1/2 z-10 -translate-y-1/2 rounded-full p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 transition-all"
297
+ aria-label="Next persona"
298
+ >
299
+ <CarbonChevronRight class="text-3xl" />
300
+ </button>
301
  {/if}
 
 
 
 
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">
309
+ <IconLoading classNames="loading inline ml-2" />
310
+ </div>
311
  {/if}
312
+ {#each responses as response (response.personaId)}
313
+ {@const isExpanded = expandedStates[response.personaId]}
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 -->
320
+ <div
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
328
+ type="button"
329
+ class="truncate text-left text-lg font-semibold text-gray-700 hover:text-gray-900 dark:text-gray-200 dark:hover:text-gray-50 transition-colors"
330
+ onclick={() => openPersonaSettings(response.personaId)}
331
+ aria-label="Open settings for {displayName}"
332
+ title="View {displayName} settings"
333
+ >
334
+ {displayName}
335
+ </button>
336
+ {:else}
337
+ <h3 class="truncate text-lg font-semibold text-gray-700 dark:text-gray-200">
338
+ {displayName}
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) -->
349
+ {#if !isPersonaMode && message.files?.length}
350
+ <div class="flex h-fit flex-wrap gap-x-5 gap-y-2 mb-2">
351
+ {#each message.files as file (file.value)}
352
+ <UploadedFile {file} canClose={false} />
353
+ {/each}
354
+ </div>
355
+ {/if}
356
 
357
+ <!-- Thinking indicator for server reasoning (legacy mode only) -->
358
+ {#if !isPersonaMode && hasServerReasoning && loading && message.content.length === 0}
 
359
  <ThinkingPlaceholder />
360
  {/if}
361
+
362
+ <!-- Content -->
363
+ <div
364
+ bind:this={contentElements[response.personaId]}
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}
385
+ <div
386
+ 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"
387
+ >
388
+ <MarkdownRenderer content={part} loading={isLast && loading} />
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}
401
+ <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
402
+ <span class="font-medium">{response.routerMetadata.route}</span>
403
+ <span class="mx-1">•</span>
404
+ <span>{response.routerMetadata.model}</span>
405
+ </div>
406
+ {/if}
407
  </div>
408
+
409
+ <!-- Expand/Collapse button for cards with overflow -->
410
+ {#if hasOverflow(response.personaId)}
411
+ <button
412
+ onclick={() => {
413
+ // In multi-card view, "Show more" enters focus mode
414
+ // "Show less" collapses all and exits focus
415
+ !isExpanded && hasMultipleCards ? setFocus(response.personaId) : toggleExpanded(response.personaId);
416
+ }}
417
+ class="mt-3 flex w-full items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
418
+ >
419
+ {#if isExpanded}
420
+ <CarbonChevronUp class="text-base" />
421
+ <span>Show less</span>
422
+ {:else}
423
+ <CarbonChevronDown class="text-base" />
424
+ <span>Show more</span>
425
+ {/if}
426
+ </button>
427
  {/if}
 
 
 
 
 
 
428
  </div>
429
+ {/if}
430
+ {/each}
431
  </div>
432
 
433
+ <!-- Branch button for legacy mode (outside card border) -->
434
+ {#if !isPersonaMode && (!isLast || !loading) && onbranch && personaName}
435
+ {@const branchCount = personaBranches.length}
436
+ {@const hasExistingBranches = branchCount > 0}
437
+
438
+ <div class="mt-1.5 flex items-center justify-end gap-1 px-2">
 
 
 
439
  <button
440
  type="button"
441
  class="flex items-center gap-1 rounded-md px-2 py-1.5 text-sm text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50 {isBranching ? 'animate-pulse' : ''}"
 
448
  <span>({branchCount})</span>
449
  {/if}
450
  </button>
451
+ </div>
452
+ {/if}
453
+ </div>
 
 
 
 
 
 
 
 
 
454
 
455
  {#if message.routerMetadata && (!isLast || !loading)}
456
  <div
 
592
  stroke-dashoffset: 122.9;
593
  }
594
  }
595
+
596
+ .persona-card {
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>
src/lib/components/chat/PersonaResponseCards.svelte DELETED
@@ -1,190 +0,0 @@
1
- <script lang="ts">
2
- import type { PersonaResponse } from "$lib/types/Message";
3
- import MarkdownRenderer from "./MarkdownRenderer.svelte";
4
- import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
5
- import CarbonRotate360 from "~icons/carbon/rotate-360";
6
- import CarbonChevronDown from "~icons/carbon/chevron-down";
7
- import CarbonChevronUp from "~icons/carbon/chevron-up";
8
- import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
9
- import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
10
- import { goto } from "$app/navigation";
11
- import { base } from "$app/paths";
12
-
13
- interface Props {
14
- personaResponses: PersonaResponse[];
15
- loading?: boolean;
16
- onretry?: (personaId: string) => void;
17
- }
18
-
19
- let { personaResponses, loading = false, onretry }: Props = $props();
20
-
21
- // Track expanded state for each persona
22
- let expandedStates = $state<Record<string, boolean>>({});
23
-
24
- // Track content elements for overflow detection
25
- let contentElements = $state<Record<string, HTMLElement | null>>({});
26
- const MAX_COLLAPSED_HEIGHT = 400;
27
-
28
- function toggleExpanded(personaId: string) {
29
- expandedStates[personaId] = !expandedStates[personaId];
30
- }
31
-
32
- // Check if content has <think> blocks
33
- function hasClientThink(content: string | undefined): boolean {
34
- return content ? hasThinkSegments(content) : false;
35
- }
36
-
37
- // Check if content has overflow
38
- function hasOverflow(personaId: string): boolean {
39
- const element = contentElements[personaId];
40
- if (!element) return false;
41
- return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
42
- }
43
-
44
- // Navigate to persona settings
45
- function openPersonaSettings(personaId: string) {
46
- goto(`${base}/settings/personas/${personaId}`);
47
- }
48
- </script>
49
-
50
- <!-- Horizontal scrollable cards -->
51
- <div class="flex gap-3 overflow-x-auto pb-2">
52
- {#each personaResponses as response (response.personaId)}
53
- {@const isExpanded = expandedStates[response.personaId]}
54
-
55
- <div
56
- class="persona-card flex-shrink-0 rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-850"
57
- style="min-width: 300px; max-width: {isExpanded ? '600px' : '400px'};"
58
- >
59
- <!-- Persona Header -->
60
- <div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
61
- <button
62
- type="button"
63
- class="font-semibold text-gray-900 hover:text-gray-700 dark:text-gray-100 dark:hover:text-gray-300 transition-colors"
64
- onclick={() => openPersonaSettings(response.personaId)}
65
- aria-label="Open persona settings"
66
- >
67
- {response.personaName}
68
- </button>
69
- <div class="flex items-center gap-1">
70
- <CopyToClipBoardBtn
71
- classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
72
- value={response.content}
73
- />
74
- <!-- Regenerate button commented out - regeneration disabled -->
75
- <!-- {#if onretry}
76
- <button
77
- type="button"
78
- class="rounded-md p-1.5 text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
79
- onclick={() => onretry?.(response.personaId)}
80
- aria-label="Regenerate response"
81
- >
82
- <CarbonRotate360 class="text-base" />
83
- </button>
84
- {/if} -->
85
- </div>
86
- </div>
87
-
88
- <!-- Persona Content -->
89
- <div
90
- bind:this={contentElements[response.personaId]}
91
- class="mt-2"
92
- style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
93
- >
94
- {#if hasClientThink(response.content)}
95
- {@const segments = splitThinkSegments(response.content ?? "")}
96
- {#each segments as part, _i}
97
- {#if part && part.startsWith("<think>")}
98
- {@const trimmed = part.trimEnd()}
99
- {@const isClosed = trimmed.endsWith("</think>")}
100
-
101
- {#if isClosed}
102
- <!-- Skip closed think tags - don't show reasoning content -->
103
- {:else}
104
- <ThinkingPlaceholder />
105
- {/if}
106
- {:else if part && part.trim().length > 0}
107
- <div
108
- 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"
109
- >
110
- <MarkdownRenderer content={part} {loading} />
111
- </div>
112
- {/if}
113
- {/each}
114
- {:else}
115
- <div
116
- 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"
117
- >
118
- <MarkdownRenderer content={response.content} {loading} />
119
- </div>
120
- {/if}
121
-
122
- {#if response.routerMetadata}
123
- <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
124
- <span class="font-medium">{response.routerMetadata.route}</span>
125
- <span class="mx-1">•</span>
126
- <span>{response.routerMetadata.model}</span>
127
- </div>
128
- {/if}
129
- </div>
130
-
131
- <!-- Expand/Collapse button - only show if overflow exists -->
132
- {#if hasOverflow(response.personaId)}
133
- <button
134
- onclick={() => toggleExpanded(response.personaId)}
135
- class="mt-3 flex w-full items-center justify-center gap-1 rounded-md border border-gray-200 bg-gray-50 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700"
136
- >
137
- {#if isExpanded}
138
- <CarbonChevronUp class="text-base" />
139
- <span>Show less</span>
140
- {:else}
141
- <CarbonChevronDown class="text-base" />
142
- <span>Show more</span>
143
- {/if}
144
- </button>
145
- {/if}
146
- </div>
147
- {/each}
148
- </div>
149
-
150
- <style>
151
- .persona-card {
152
- transition: all 0.3s ease;
153
- }
154
-
155
- /* Smooth scrollbar styling */
156
- .overflow-x-auto {
157
- scrollbar-width: thin;
158
- scrollbar-color: rgb(209 213 219) transparent;
159
- }
160
-
161
- .overflow-x-auto::-webkit-scrollbar {
162
- height: 8px;
163
- }
164
-
165
- .overflow-x-auto::-webkit-scrollbar-track {
166
- background: transparent;
167
- }
168
-
169
- .overflow-x-auto::-webkit-scrollbar-thumb {
170
- background-color: rgb(209 213 219);
171
- border-radius: 4px;
172
- }
173
-
174
- .overflow-x-auto::-webkit-scrollbar-thumb:hover {
175
- background-color: rgb(156 163 175);
176
- }
177
-
178
- :global(.dark) .overflow-x-auto {
179
- scrollbar-color: rgb(75 85 99) transparent;
180
- }
181
-
182
- :global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb {
183
- background-color: rgb(75 85 99);
184
- }
185
-
186
- :global(.dark) .overflow-x-auto::-webkit-scrollbar-thumb:hover {
187
- background-color: rgb(107 114 128);
188
- }
189
- </style>
190
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/lib/components/chat/PersonaResponseCarousel.svelte DELETED
@@ -1,447 +0,0 @@
1
- <script lang="ts">
2
- import type { PersonaResponse } from "$lib/types/Message";
3
- import CarbonChevronLeft from "~icons/carbon/chevron-left";
4
- import CarbonChevronRight from "~icons/carbon/chevron-right";
5
- import CarbonChevronDown from "~icons/carbon/chevron-down";
6
- import CarbonChevronUp from "~icons/carbon/chevron-up";
7
- import MarkdownRenderer from "./MarkdownRenderer.svelte";
8
- import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
9
- import CarbonRotate360 from "~icons/carbon/rotate-360";
10
- import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
11
- import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
12
-
13
- interface Props {
14
- personaResponses: PersonaResponse[];
15
- loading?: boolean;
16
- onretry?: (personaId: string) => void;
17
- }
18
-
19
- let { personaResponses, loading = false, onretry }: Props = $props();
20
-
21
- let currentIndex = $state(0);
22
- let expandedStates = $state<Record<string, boolean>>({});
23
- let isDragging = $state(false);
24
- let startX = $state(0);
25
- let currentX = $state(0);
26
- let dragOffset = $state(0);
27
-
28
- // Detect if device has touch/coarse pointer (mobile/tablet)
29
- let isTouchDevice = $state(false);
30
-
31
- $effect(() => {
32
- if (typeof window !== 'undefined') {
33
- // Check if device has coarse pointer (touchscreen) or no pointer (touch-only)
34
- isTouchDevice = window.matchMedia('(pointer: coarse)').matches ||
35
- window.matchMedia('(pointer: none)').matches ||
36
- 'ontouchstart' in window;
37
- }
38
- });
39
-
40
- // Track content heights for overflow detection
41
- let contentElements = $state<Record<string, HTMLElement | null>>({});
42
- const MAX_COLLAPSED_HEIGHT = 400;
43
-
44
- // Track which version of each persona's response is being shown
45
- let personaVersionIndices = $state<Record<string, number>>({});
46
-
47
- // Get the currently displayed version of a persona response
48
- function getDisplayedResponse(response: PersonaResponse): PersonaResponse {
49
- const versionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0;
50
-
51
- if (versionIndex === 0 || !response.children || response.children.length === 0) {
52
- return response; // Show current response
53
- }
54
-
55
- // Show a previous version from children
56
- const childIndex = versionIndex - 1;
57
- return response.children[childIndex] ?? response;
58
- }
59
-
60
- // Get all versions of a persona response (current + children)
61
- function getAllVersions(response: PersonaResponse): PersonaResponse[] {
62
- const versions = [response];
63
- if (response.children && response.children.length > 0) {
64
- versions.push(...response.children);
65
- }
66
- return versions;
67
- }
68
-
69
- // Navigate to a different version of a persona's response
70
- function navigateToVersion(personaId: string, versionIndex: number) {
71
- personaVersionIndices[personaId] = versionIndex;
72
- }
73
-
74
- function next() {
75
- if (currentIndex < personaResponses.length - 1) {
76
- // Collapse current card if expanded before navigating
77
- const currentPersonaId = personaResponses[currentIndex]?.personaId;
78
- if (currentPersonaId && expandedStates[currentPersonaId]) {
79
- expandedStates[currentPersonaId] = false;
80
- }
81
- currentIndex = currentIndex + 1;
82
- }
83
- }
84
-
85
- function previous() {
86
- if (currentIndex > 0) {
87
- // Collapse current card if expanded before navigating
88
- const currentPersonaId = personaResponses[currentIndex]?.personaId;
89
- if (currentPersonaId && expandedStates[currentPersonaId]) {
90
- expandedStates[currentPersonaId] = false;
91
- }
92
- currentIndex = currentIndex - 1;
93
- }
94
- }
95
-
96
- function goToIndex(index: number) {
97
- // Collapse current card if expanded before navigating
98
- const currentPersonaId = personaResponses[currentIndex]?.personaId;
99
- if (currentPersonaId && expandedStates[currentPersonaId]) {
100
- expandedStates[currentPersonaId] = false;
101
- }
102
- currentIndex = index;
103
- }
104
-
105
- function handleDragStart(event: MouseEvent | TouchEvent) {
106
- // Only allow mouse dragging on touch devices
107
- if (!isTouchDevice && !('touches' in event)) {
108
- return;
109
- }
110
-
111
- isDragging = true;
112
- startX = 'touches' in event ? event.touches[0].clientX : event.clientX;
113
- currentX = startX;
114
- }
115
-
116
- function handleDragMove(event: MouseEvent | TouchEvent) {
117
- if (!isDragging) return;
118
-
119
- // Only allow mouse dragging on touch devices
120
- if (!isTouchDevice && !('touches' in event)) {
121
- return;
122
- }
123
-
124
- event.preventDefault();
125
- currentX = 'touches' in event ? event.touches[0].clientX : event.clientX;
126
- dragOffset = currentX - startX;
127
- }
128
-
129
- function handleDragEnd() {
130
- if (!isDragging) return;
131
-
132
- isDragging = false;
133
- const threshold = 50;
134
-
135
- if (dragOffset < -threshold && currentIndex < personaResponses.length - 1) {
136
- next();
137
- } else if (dragOffset > threshold && currentIndex > 0) {
138
- previous();
139
- }
140
-
141
- dragOffset = 0;
142
- }
143
-
144
- function toggleExpanded(personaId: string) {
145
- expandedStates[personaId] = !expandedStates[personaId];
146
- }
147
-
148
- // Check if content has overflow
149
- function hasOverflow(personaId: string): boolean {
150
- const element = contentElements[personaId];
151
- if (!element) return false;
152
- return element.scrollHeight > MAX_COLLAPSED_HEIGHT;
153
- }
154
-
155
- let currentResponse = $derived(personaResponses[currentIndex]);
156
-
157
- // Check if content has <think> blocks
158
- function hasClientThink(content: string | undefined): boolean {
159
- return content ? hasThinkSegments(content) : false;
160
- }
161
-
162
- let showLeftArrow = $derived(currentIndex > 0);
163
- let showRightArrow = $derived(currentIndex < personaResponses.length - 1);
164
- let showPositionIndicator = $derived(personaResponses.length > 3);
165
- let personaCount = $derived(Math.max(personaResponses.length, 1));
166
- let cardWidthPercent = $derived(100 / personaCount);
167
- let trackWidthPercent = $derived(personaCount * 100);
168
- let trackTranslatePercent = $derived(currentIndex * cardWidthPercent);
169
- </script>
170
-
171
- <!-- Outer wrapper for arrows -->
172
- <div class="relative w-full">
173
- <!-- Left Navigation Arrow - positioned outside cards -->
174
- {#if personaResponses.length > 1 && showLeftArrow}
175
- <button
176
- onclick={previous}
177
- class="absolute left-2 top-1/2 z-20 -translate-y-1/2 p-2 text-gray-600 transition-all hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
178
- aria-label="Previous persona"
179
- >
180
- <CarbonChevronLeft class="text-3xl" />
181
- </button>
182
- {/if}
183
-
184
- <!-- Right Navigation Arrow - positioned outside cards -->
185
- {#if personaResponses.length > 1 && showRightArrow}
186
- <button
187
- onclick={next}
188
- class="absolute right-2 top-1/2 z-20 -translate-y-1/2 p-2 text-gray-600 transition-all hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
189
- aria-label="Next persona"
190
- >
191
- <CarbonChevronRight class="text-3xl" />
192
- </button>
193
- {/if}
194
-
195
- <!-- svelte-ignore a11y_no_static_element_interactions -->
196
- <div
197
- class="carousel-container relative w-full overflow-hidden transition-all duration-300"
198
- onmousedown={handleDragStart}
199
- onmousemove={handleDragMove}
200
- onmouseup={handleDragEnd}
201
- onmouseleave={handleDragEnd}
202
- ontouchstart={handleDragStart}
203
- ontouchmove={handleDragMove}
204
- ontouchend={handleDragEnd}
205
- >
206
- <div
207
- class="carousel-track flex transition-all duration-300 ease-out"
208
- class:dragging={isDragging}
209
- style={`width: ${trackWidthPercent}%; transform: translateX(calc(-${trackTranslatePercent}% + ${dragOffset}px));`}
210
- >
211
- {#each personaResponses as response, index (response.personaId)}
212
- {@const isActive = index === currentIndex}
213
- {@const isPrevious = index === currentIndex - 1}
214
- {@const isNext = index === currentIndex + 1}
215
- {@const isExpanded = expandedStates[response.personaId]}
216
- {@const displayedResponse = getDisplayedResponse(response)}
217
- {@const allVersions = getAllVersions(response)}
218
- {@const currentVersionIndex = personaVersionIndices[response.personaId] ?? response.currentChildIndex ?? 0}
219
-
220
- <div
221
- class="carousel-card flex-shrink-0 transition-all duration-300"
222
- class:active={isActive}
223
- class:peek={!isActive}
224
- style={`width: ${cardWidthPercent}%;`}
225
- >
226
- <div
227
- class="relative w-full rounded-2xl border border-gray-100 bg-gradient-to-br from-gray-50 px-5 py-3.5 text-gray-600 dark:border-gray-800 dark:from-gray-800/80 dark:text-gray-300"
228
- class:pointer-events-none={!isActive}
229
- >
230
- <!-- Persona Name at Top Left -->
231
- <div class="mb-4 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
232
- <div>
233
- <h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
234
- {response.personaName}
235
- </h3>
236
- {#if response.personaOccupation || response.personaStance}
237
- <div class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
238
- {#if response.personaOccupation}<span>{response.personaOccupation}</span>{/if}{#if response.personaOccupation && response.personaStance}<span class="mx-1">•</span>{/if}{#if response.personaStance}<span>{response.personaStance}</span>{/if}
239
- </div>
240
- {/if}
241
- </div>
242
-
243
- <!-- Position Indicator Dots (inside card) -->
244
- {#if personaResponses.length > 1}
245
- <div class="flex items-center gap-2">
246
- {#if showPositionIndicator}
247
- <!-- Text indicator for N > 3 -->
248
- <div class="text-sm text-gray-600 dark:text-gray-400">
249
- {currentIndex + 1} of {personaResponses.length}
250
- </div>
251
- {/if}
252
-
253
- <!-- Dot indicator -->
254
- <div class="flex gap-1.5">
255
- {#each personaResponses as _, idx}
256
- <button
257
- onclick={() => goToIndex(idx)}
258
- class="size-2 rounded-full transition-all {idx === currentIndex
259
- ? 'bg-gray-700 dark:bg-gray-300'
260
- : 'bg-gray-400 dark:bg-gray-600 hover:bg-gray-500 dark:hover:bg-gray-500'}"
261
- aria-label={`Go to ${personaResponses[idx].personaName}`}
262
- ></button>
263
- {/each}
264
- </div>
265
- </div>
266
- {/if}
267
- </div>
268
-
269
- <!-- Persona Content -->
270
- <div
271
- bind:this={contentElements[response.personaId]}
272
- class="content-wrapper relative"
273
- style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
274
- >
275
- {#if hasClientThink(displayedResponse.content)}
276
- {@const segments = splitThinkSegments(displayedResponse.content ?? "")}
277
- {#each segments as part, _i}
278
- {#if part && part.startsWith("<think>")}
279
- {@const trimmed = part.trimEnd()}
280
- {@const isClosed = trimmed.endsWith("</think>")}
281
-
282
- {#if isClosed}
283
- <!-- Skip closed think tags - don't show reasoning content -->
284
- {:else}
285
- <ThinkingPlaceholder />
286
- {/if}
287
- {:else if part && part.trim().length > 0}
288
- <div
289
- 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"
290
- >
291
- <MarkdownRenderer content={part} {loading} />
292
- </div>
293
- {/if}
294
- {/each}
295
- {:else}
296
- <div
297
- 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"
298
- >
299
- <MarkdownRenderer content={displayedResponse.content} {loading} />
300
- </div>
301
- {/if}
302
-
303
- {#if displayedResponse.routerMetadata}
304
- <div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
305
- <span class="font-medium">{displayedResponse.routerMetadata.route}</span>
306
- <span class="mx-1">•</span>
307
- <span>{displayedResponse.routerMetadata.model}</span>
308
- </div>
309
- {/if}
310
- </div>
311
-
312
- <!-- Bottom Actions Row -->
313
- <div class="mt-4 flex items-center justify-between">
314
- <!-- Left Side: Show More Button or Version Navigation -->
315
- <div class="flex items-center gap-2">
316
- {#if hasOverflow(response.personaId)}
317
- <button
318
- onclick={() => toggleExpanded(response.personaId)}
319
- class="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm text-gray-500 transition-colors hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700/50"
320
- >
321
- {#if isExpanded}
322
- <CarbonChevronUp class="text-base" />
323
- <span>Show less</span>
324
- {:else}
325
- <CarbonChevronDown class="text-base" />
326
- <span>Show more</span>
327
- {/if}
328
- </button>
329
- {/if}
330
-
331
- <!-- Version Navigation (if multiple versions exist) -->
332
- {#if allVersions.length > 1}
333
- <div class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400">
334
- <button
335
- class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed"
336
- onclick={() => navigateToVersion(response.personaId, Math.max(0, currentVersionIndex - 1))}
337
- disabled={currentVersionIndex === 0 || loading}
338
- aria-label="Previous version"
339
- >
340
- <CarbonChevronLeft class="text-base" />
341
- </button>
342
- <span class="text-xs">
343
- {currentVersionIndex + 1} / {allVersions.length}
344
- </span>
345
- <button
346
- class="rounded-md p-1 hover:bg-gray-100 dark:hover:bg-gray-700/50 disabled:opacity-30 disabled:cursor-not-allowed"
347
- onclick={() => navigateToVersion(response.personaId, Math.min(allVersions.length - 1, currentVersionIndex + 1))}
348
- disabled={currentVersionIndex === allVersions.length - 1 || loading}
349
- aria-label="Next version"
350
- >
351
- <CarbonChevronRight class="text-base" />
352
- </button>
353
- </div>
354
- {/if}
355
- </div>
356
-
357
- <!-- Copy and Regenerate Icons (Bottom Right) -->
358
- <div class="flex items-center gap-1">
359
- <CopyToClipBoardBtn
360
- classNames="!rounded-md !p-2 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-700/50"
361
- value={displayedResponse.content}
362
- />
363
- <!-- Regenerate button commented out - regeneration disabled -->
364
- <!-- {#if onretry}
365
- <button
366
- type="button"
367
- class="rounded-md p-2 text-gray-600 hover:bg-gray-200/50 dark:text-gray-400 dark:hover:bg-gray-700/50"
368
- onclick={() => onretry?.(response.personaId)}
369
- aria-label="Regenerate response"
370
- >
371
- <CarbonRotate360 class="text-base" />
372
- </button>
373
- {/if} -->
374
- </div>
375
- </div>
376
- </div>
377
- </div>
378
- {/each}
379
- </div>
380
- </div>
381
- </div>
382
-
383
- <style>
384
- .carousel-container {
385
- touch-action: pan-y pinch-zoom;
386
- -ms-overflow-style: none;
387
- scrollbar-width: none;
388
- user-select: none;
389
- -webkit-user-select: none;
390
- }
391
-
392
- /* Only show grab cursor on touch devices */
393
- @media (pointer: coarse) {
394
- .carousel-container {
395
- cursor: grab;
396
- }
397
-
398
- .carousel-container:active {
399
- cursor: grabbing;
400
- }
401
- }
402
-
403
- .carousel-container::-webkit-scrollbar {
404
- display: none;
405
- }
406
-
407
- .carousel-track {
408
- display: flex;
409
- gap: 0;
410
- transition: transform 0.3s ease-out, height 0.3s ease-out;
411
- }
412
-
413
- .carousel-track.dragging {
414
- transition: height 0.3s ease-out;
415
- }
416
-
417
- .carousel-card {
418
- transition: opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease;
419
- }
420
-
421
- .carousel-card.active {
422
- opacity: 1;
423
- transform: scale(1);
424
- z-index: 10;
425
- }
426
-
427
- .carousel-card.peek {
428
- opacity: 1;
429
- transform: scale(0.92);
430
- pointer-events: none;
431
- }
432
-
433
- /* Additional styling for better peeking effect - subtle dimming */
434
- .carousel-card.peek > div {
435
- filter: brightness(0.92) saturate(0.9);
436
- }
437
-
438
- :global(.dark) .carousel-card.peek > div {
439
- filter: brightness(0.75) saturate(0.9);
440
- }
441
-
442
- /* Content wrapper auto-sizing */
443
- .content-wrapper {
444
- transition: max-height 0.3s ease;
445
- }
446
- </style>
447
-