Andrew commited on
Commit
3a8705d
·
1 Parent(s): e9dc149

refactor(ui): update ChatMessage to support multi-persona and branching

Browse files
src/lib/components/chat/ChatMessage.svelte CHANGED
@@ -8,10 +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
- 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
 
@@ -47,6 +47,7 @@
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;
@@ -60,6 +61,7 @@
60
  isAuthor: _isAuthor = true,
61
  readOnly: _readOnly = false,
62
  isTapped = $bindable(false),
 
63
  alternatives = [],
64
  editMsdgId = $bindable(null),
65
  isLast = false,
@@ -81,7 +83,7 @@
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
 
@@ -112,14 +114,6 @@
112
  []) as MessageReasoningUpdate[]
113
  );
114
 
115
- // const messageFinalAnswer = $derived(
116
- // message.updates?.find(
117
- // ({ type }) => type === MessageUpdateType.FinalAnswer
118
- // ) as MessageFinalAnswerUpdate
119
- // );
120
- // const urlNotTrailing = $derived(page.url.pathname.replace(/\/$/, ""));
121
- // let downloadLink = $derived(urlNotTrailing + `/message/${message.id}/prompt`);
122
-
123
  let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
124
  let hasServerReasoning = $derived(
125
  reasoningUpdates &&
@@ -129,8 +123,7 @@ let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
129
  );
130
  let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
131
 
132
- // Check if using persona-based response structure (vs legacy message structure)
133
- // Check for existence of the property, not length - empty array [] means "loading personas"
134
  let isPersonaMode = $derived(message.personaResponses !== undefined);
135
 
136
  // Unified responses array: use personaResponses if available, otherwise wrap message as single response
@@ -221,6 +214,16 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
221
  } else {
222
  // Otherwise, just toggle the individual card's state
223
  expandedStates[personaId] = !isCurrentlyExpanded;
 
 
 
 
 
 
 
 
 
 
224
  }
225
  }
226
 
@@ -246,15 +249,48 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
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
  }
@@ -322,9 +358,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
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">
326
  <IconLoading classNames="loading inline ml-2" />
327
- </div>
328
  {/if}
329
  {#each responses as response (response.personaId)}
330
  {@const isExpanded = expandedStates[response.personaId]}
@@ -358,51 +392,72 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
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
 
@@ -457,26 +512,6 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
457
  </div>
458
  {/if}
459
  </div>
460
-
461
- <!-- Expand/Collapse button for cards with overflow -->
462
- {#if hasOverflow(response.personaId)}
463
- <button
464
- onclick={() => {
465
- // In multi-card view, "Show more" enters focus mode
466
- // "Show less" collapses all and exits focus
467
- !isExpanded && hasMultipleCards ? setFocus(response.personaId) : toggleExpanded(response.personaId);
468
- }}
469
- 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"
470
- >
471
- {#if isExpanded}
472
- <CarbonChevronUp class="text-base" />
473
- <span>Show less</span>
474
- {:else}
475
- <CarbonChevronDown class="text-base" />
476
- <span>Show more</span>
477
- {/if}
478
- </button>
479
- {/if}
480
  </div>
481
  {/if}
482
  {/each}
@@ -623,6 +658,8 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
623
  />
624
  {/if}
625
  {#if (alternatives.length > 1 && editMsdgId === null) || (!loading && !editMode)}
 
 
626
  <button
627
  class="hidden cursor-pointer items-center gap-1 rounded-md border border-gray-200 px-1.5 py-0.5 text-xs text-gray-400 group-hover:flex hover:flex hover:text-gray-500 dark:border-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:-right-2"
628
  title="Edit"
@@ -632,6 +669,7 @@ let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.co
632
  <CarbonPen />
633
  Edit
634
  </button>
 
635
  {/if}
636
  </div>
637
  </div>
 
8
  import IconLoading from "../icons/IconLoading.svelte";
9
  import CarbonRotate360 from "~icons/carbon/rotate-360";
10
  import CarbonBranch from "~icons/carbon/branch";
 
 
11
  import CarbonChevronLeft from "~icons/carbon/chevron-left";
12
  import CarbonChevronRight from "~icons/carbon/chevron-right";
13
+ import CarbonMaximize from "~icons/carbon/maximize";
14
+ import CarbonMinimize from "~icons/carbon/minimize";
15
  import CarbonPen from "~icons/carbon/pen";
16
  import UploadedFile from "./UploadedFile.svelte";
17
 
 
47
  personaId: string;
48
  personaName: string;
49
  } | null;
50
+ branchPersonas?: string[];
51
  onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
52
  onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
53
  onbranch?: (messageId: string, personaId: string) => void;
 
61
  isAuthor: _isAuthor = true,
62
  readOnly: _readOnly = false,
63
  isTapped = $bindable(false),
64
+ branchPersonas = [],
65
  alternatives = [],
66
  editMsdgId = $bindable(null),
67
  isLast = false,
 
83
 
84
  let expandedStates = $state<Record<string, boolean>>({});
85
  let focusedPersonaId = $state<string | null>(null);
86
+
87
  let contentElements = $state<Record<string, HTMLElement | null>>({});
88
  const MAX_COLLAPSED_HEIGHT = 400;
89
 
 
114
  []) as MessageReasoningUpdate[]
115
  );
116
 
 
 
 
 
 
 
 
 
117
  let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
118
  let hasServerReasoning = $derived(
119
  reasoningUpdates &&
 
123
  );
124
  let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
125
 
126
+ // Check if using persona-based response structure
 
127
  let isPersonaMode = $derived(message.personaResponses !== undefined);
128
 
129
  // Unified responses array: use personaResponses if available, otherwise wrap message as single response
 
214
  } else {
215
  // Otherwise, just toggle the individual card's state
216
  expandedStates[personaId] = !isCurrentlyExpanded;
217
+
218
+ // If expanding, scroll to show the bottom of the content
219
+ if (!isCurrentlyExpanded) {
220
+ setTimeout(() => {
221
+ const element = contentElements[personaId];
222
+ if (element) {
223
+ element.scrollIntoView({ behavior: 'smooth', block: 'end' });
224
+ }
225
+ }, 50);
226
+ }
227
  }
228
  }
229
 
 
249
  }
250
 
251
  // Reactive overflow detection - updates during streaming
252
+ let overflowStates = $state<Record<string, boolean>>({});
253
+
254
+ // Effect to detect overflow states during rendering and expansion
255
+ $effect(() => {
256
+ // Recompute overflow states whenever responses change or elements are bound
257
+ const newStates: Record<string, boolean> = {};
258
  responses.forEach(r => {
259
+ // Access content to make this reactive to content changes during streaming
260
+ const contentLength = r.content?.length || 0;
261
  const element = contentElements[r.personaId];
262
+ const isExpanded = expandedStates[r.personaId];
263
+ // Only check overflow if we have content and an element
264
+ // If expanded, always recheck after DOM updates
265
+ if (element && contentLength > 0) {
266
+ newStates[r.personaId] = element.scrollHeight > MAX_COLLAPSED_HEIGHT;
267
+ } else {
268
+ newStates[r.personaId] = false;
269
+ }
270
  });
271
+ overflowStates = newStates;
272
  });
273
 
274
+ // Additional effect to force recheck after expansion state changes
275
+ $effect(() => {
276
+ // Track expanded states
277
+ const expandedKeys = Object.keys(expandedStates);
278
+ if (expandedKeys.length > 0) {
279
+ // Delay recheck to allow DOM to update
280
+ const timeout = setTimeout(() => {
281
+ const newStates: Record<string, boolean> = {};
282
+ responses.forEach(r => {
283
+ const element = contentElements[r.personaId];
284
+ if (element && r.content) {
285
+ newStates[r.personaId] = element.scrollHeight > MAX_COLLAPSED_HEIGHT;
286
+ }
287
+ });
288
+ overflowStates = { ...overflowStates, ...newStates };
289
+ }, 100);
290
+ return () => clearTimeout(timeout);
291
+ }
292
+ });
293
+
294
  function hasOverflow(personaId: string): boolean {
295
  return overflowStates[personaId] || false;
296
  }
 
358
  <div class="{hasMultipleCards && !focusedPersonaId ? 'persona-scroll-container flex gap-3 overflow-x-auto pb-2 px-12' : ''}">
359
  {#if isPersonaMode && responses.length === 0 && isLast && loading}
360
  <!-- Loading state: waiting for personas to start responding -->
 
361
  <IconLoading classNames="loading inline ml-2" />
 
362
  {/if}
363
  {#each responses as response (response.personaId)}
364
  {@const isExpanded = expandedStates[response.personaId]}
 
392
  {/if}
393
 
394
  <div class="flex items-center gap-1">
395
+ {#if hasMultipleCards}
396
  <button
397
  type="button"
398
  class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
399
  onclick={(e) => {
400
  e.stopPropagation();
401
+ focusedPersonaId === response.personaId ? toggleExpanded(response.personaId) : setFocus(response.personaId);
402
  }}
403
+ aria-label={focusedPersonaId === response.personaId ? "Exit focus mode" : "Focus this persona"}
404
+ title={focusedPersonaId === response.personaId ? "Show all cards" : "Focus on this card"}
405
  >
406
+ {#if focusedPersonaId === response.personaId}
407
+ <CarbonMinimize class="text-base" />
408
+ {:else}
409
+ <CarbonMaximize class="text-base" />
410
+ {/if}
411
  </button>
412
  {/if}
413
+
414
+ {#if !loading && onretry}
415
  <button
416
  type="button"
417
  class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
418
  onclick={(e) => {
419
  e.stopPropagation();
420
+ onretry?.({ id: message.id, personaId: response.personaId });
 
 
 
 
 
 
 
 
 
 
421
  }}
422
+ aria-label="Regenerate {displayName}'s response"
423
+ title="Regenerate this response"
424
  >
425
+ <CarbonRotate360 class="text-base" />
 
 
426
  </button>
427
  {/if}
428
+ {#if !loading && onbranch}
429
+ {@const isBranchClicked = branchClickedPersonaId === response.personaId}
430
+ <button
431
+ type="button"
432
+ class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
433
+ onclick={(e) => {
434
+ e.stopPropagation();
435
+
436
+ // Trigger animation
437
+ branchClickedPersonaId = response.personaId;
438
+ if (branchClickTimeout) {
439
+ clearTimeout(branchClickTimeout);
440
+ }
441
+ branchClickTimeout = setTimeout(() => {
442
+ branchClickedPersonaId = null;
443
+ }, 500);
444
+
445
+ onbranch?.(message.id, response.personaId);
446
+ }}
447
+ aria-label="Branch conversation with {displayName}"
448
+ title="Start private conversation with {displayName}"
449
+ >
450
+ <div class="relative transition-transform duration-200 {isBranchClicked ? 'scale-125' : 'scale-100'}">
451
+ <CarbonBranch class="text-base" />
452
+ </div>
453
+ </button>
454
+ {/if}
455
+ {#if !loading}
456
+ <CopyToClipBoardBtn
457
+ classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800"
458
+ value={response.content}
459
+ />
460
+ {/if}
461
  </div>
462
  </div>
463
 
 
512
  </div>
513
  {/if}
514
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  </div>
516
  {/if}
517
  {/each}
 
658
  />
659
  {/if}
660
  {#if (alternatives.length > 1 && editMsdgId === null) || (!loading && !editMode)}
661
+ {@const isRootMessage = message.from === "user" && (!message.ancestors || message.ancestors.length === 0)}
662
+ {#if !isRootMessage}
663
  <button
664
  class="hidden cursor-pointer items-center gap-1 rounded-md border border-gray-200 px-1.5 py-0.5 text-xs text-gray-400 group-hover:flex hover:flex hover:text-gray-500 dark:border-gray-700 dark:text-gray-400 dark:hover:text-gray-300 lg:-right-2"
665
  title="Edit"
 
669
  <CarbonPen />
670
  Edit
671
  </button>
672
+ {/if}
673
  {/if}
674
  </div>
675
  </div>