pluralchat / src /lib /components /chat /ChatMessage.svelte
Andrew
feat(tree): Add ELK port-based layout and persona-specific branching
cb5990d
<script lang="ts">
import type { Message, MetacognitiveEventType } from "$lib/types/Message";
import { tick } from "svelte";
import { usePublicConfig } from "$lib/utils/PublicConfig.svelte";
const publicConfig = usePublicConfig();
import CopyToClipBoardBtn from "../CopyToClipBoardBtn.svelte";
import IconLoading from "../icons/IconLoading.svelte";
import CarbonRotate360 from "~icons/carbon/rotate-360";
import CarbonBranch from "~icons/carbon/branch";
import CarbonChevronLeft from "~icons/carbon/chevron-left";
import CarbonChevronRight from "~icons/carbon/chevron-right";
import CarbonMaximize from "~icons/carbon/maximize";
import CarbonMinimize from "~icons/carbon/minimize";
import CarbonPen from "~icons/carbon/pen";
import UploadedFile from "./UploadedFile.svelte";
import {
MessageUpdateType,
type MessageReasoningUpdate,
MessageReasoningUpdateType,
} from "$lib/types/MessageUpdate";
import MarkdownRenderer from "./MarkdownRenderer.svelte";
import Alternatives from "./Alternatives.svelte";
import MessageAvatar from "./MessageAvatar.svelte";
import ThinkingPlaceholder from "./ThinkingPlaceholder.svelte";
import { hasThinkSegments, splitThinkSegments } from "$lib/utils/stripThinkBlocks";
import { goto } from "$app/navigation";
import { base } from "$app/paths";
import type { PersonaResponse } from "$lib/types/Message";
import { onDestroy } from "svelte";
import MetacognitivePrompt from "./MetacognitivePrompt.svelte";
type MetacognitivePromptData = {
type: MetacognitiveEventType;
promptText: string;
triggerFrequency: number;
suggestedPersonaId?: string;
suggestedPersonaName?: string;
} | null;
interface Props {
message: Message;
loading?: boolean;
isAuthor?: boolean;
readOnly?: boolean;
isTapped?: boolean;
alternatives?: Message["id"][];
editMsdgId?: Message["id"] | null;
isLast?: boolean;
personaName?: string;
personaOccupation?: string;
personaStance?: string;
branchState?: {
messageId: string;
personaId: string;
personaName: string;
} | null;
branchPersonas?: string[];
metacognitivePrompt?: MetacognitivePromptData;
onretry?: (payload: { id: Message["id"]; content?: string; personaId?: string }) => void;
onshowAlternateMsg?: (payload: { id: Message["id"] }) => void;
onbranch?: (messageId: string, personaId: string) => void;
onmetacognitiveaction?: () => void;
messageBranches?: any[]; // Branches originating from this message
onopenbranchmodal?: (messageId: string, personaId: string, branches: any[]) => void;
}
let {
message,
loading = false,
isAuthor: _isAuthor = true,
readOnly: _readOnly = false,
isTapped = $bindable(false),
branchPersonas = [],
alternatives = [],
editMsdgId = $bindable(null),
isLast = false,
personaName,
personaOccupation,
personaStance,
branchState,
metacognitivePrompt,
onretry,
onshowAlternateMsg,
onbranch,
onmetacognitiveaction,
messageBranches = [],
onopenbranchmodal,
}: Props = $props();
let contentEl: HTMLElement | undefined = $state();
let messageWidth: number = $state(0);
let messageInfoWidth: number = $state(0);
let isBranching = $state(false);
let expandedStates = $state<Record<string, boolean>>({});
let focusedPersonaId = $state<string | null>(null);
let contentElements = $state<Record<string, HTMLElement | null>>({});
const MAX_COLLAPSED_HEIGHT = 400;
// Track which branch button was just clicked for animation
let branchClickedPersonaId = $state<string | null>(null);
let branchClickTimeout: ReturnType<typeof setTimeout> | undefined;
$effect(() => {
// referenced to appease linter for currently-unused props
void _isAuthor;
void _readOnly;
});
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
editFormEl?.requestSubmit();
}
if (e.key === "Escape") {
editMsdgId = null;
}
}
let editContentEl: HTMLTextAreaElement | undefined = $state();
let editFormEl: HTMLFormElement | undefined = $state();
let reasoningUpdates = $derived(
(message.updates?.filter(({ type }) => type === MessageUpdateType.Reasoning) ??
[]) as MessageReasoningUpdate[]
);
let thinkSegments = $derived.by(() => splitThinkSegments(message.content));
let hasServerReasoning = $derived(
reasoningUpdates &&
reasoningUpdates.length > 0 &&
!!message.reasoning &&
message.reasoning.trim().length > 0
);
let hasClientThink = $derived(!hasServerReasoning && hasThinkSegments(message.content));
// Check if using persona-based response structure
let isPersonaMode = $derived(message.personaResponses !== undefined);
// Unified responses array: use personaResponses if available, otherwise wrap message as single response
let responses = $derived.by((): PersonaResponse[] => {
if (isPersonaMode) {
return message.personaResponses!;
}
// Legacy mode: convert message to PersonaResponse format for unified rendering
return [{
personaId: 'single',
personaName: personaName || '',
personaOccupation,
personaStance,
content: message.content,
reasoning: message.reasoning,
updates: message.updates,
routerMetadata: message.routerMetadata,
}];
});
// Use local or stored prompt
let storedMetacognitiveEvent = $derived(
message.metacognitiveEvents && message.metacognitiveEvents.length > 0
? message.metacognitiveEvents[message.metacognitiveEvents.length - 1]
: null
);
let isMetacognitiveEventAccepted = $derived(storedMetacognitiveEvent?.accepted ?? false);
let activeMetacognitivePrompt = $derived.by(() => {
if (metacognitivePrompt) return metacognitivePrompt;
if (storedMetacognitiveEvent) {
return {
type: storedMetacognitiveEvent.type,
promptText: storedMetacognitiveEvent.promptText,
triggerFrequency: storedMetacognitiveEvent.triggerFrequency,
suggestedPersonaId: storedMetacognitiveEvent.suggestedPersonaId,
suggestedPersonaName: storedMetacognitiveEvent.suggestedPersonaName,
} as MetacognitivePromptData;
}
return null;
});
// Multiple cards need horizontal scroll layout
let hasMultipleCards = $derived(responses.length > 1);
let editMode = $derived(editMsdgId === message.id);
$effect(() => {
if (editMode) {
tick();
if (editContentEl) {
editContentEl.value = message.content;
editContentEl?.focus();
}
}
});
// Get persona ID for single persona mode
function getPersonaId(): string | undefined {
// Try to get from message personaResponses first
if (message.personaResponses && message.personaResponses.length > 0) {
return message.personaResponses[0].personaId;
}
// Fallback: this won't work perfectly but it's a reasonable attempt
return undefined;
}
// Get branches for this message's persona
let personaBranches = $derived.by(() => {
const personaId = getPersonaId();
if (!personaId) return messageBranches;
return messageBranches.filter(b => b.branchedFromPersonaId === personaId);
});
// Handle branch creation with animation
async function handleBranchClick() {
const personaId = getPersonaId();
if (!personaId || !onbranch) return;
isBranching = true;
await onbranch(message.id, personaId);
setTimeout(() => {
isBranching = false;
}, 600);
}
// Handle branch button click
function handleBranchButtonClick() {
const personaId = getPersonaId();
if (!personaId) return;
const hasExistingBranches = personaBranches.length > 0;
if (hasExistingBranches) {
onopenbranchmodal?.(message.id, personaId, personaBranches);
} else {
handleBranchClick();
}
}
// Unified helper functions for card rendering
function toggleExpanded(personaId: string) {
const isCurrentlyExpanded = expandedStates[personaId];
// In multi-card view, "Show less" should collapse all cards
if (hasMultipleCards && isCurrentlyExpanded) {
responses.forEach(r => {
expandedStates[r.personaId] = false;
});
focusedPersonaId = null;
} else {
// Otherwise, just toggle the individual card's state
expandedStates[personaId] = !isCurrentlyExpanded;
// If expanding, scroll to show the bottom of the content
if (!isCurrentlyExpanded) {
tick().then(() => {
const element = contentElements[personaId];
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
});
}
}
}
function setFocus(personaId: string) {
if (hasMultipleCards) {
// Enter focused mode and ensure the card is expanded
focusedPersonaId = personaId;
expandedStates[personaId] = true;
}
}
function navigateFocused(direction: 'prev' | 'next') {
if (!focusedPersonaId || !hasMultipleCards) return;
const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId);
if (currentIndex === -1) return;
const nextIndex = direction === 'next' ? currentIndex + 1 : currentIndex - 1;
if (nextIndex >= 0 && nextIndex < responses.length) {
focusedPersonaId = responses[nextIndex].personaId;
expandedStates[focusedPersonaId] = true;
}
}
// Reactive overflow detection - updates during streaming
let overflowStates = $state<Record<string, boolean>>({});
// Effect to detect overflow states during rendering and expansion
$effect(() => {
// Recompute overflow states whenever responses change or elements are bound
const newStates: Record<string, boolean> = {};
responses.forEach(r => {
// Access content to make this reactive to content changes during streaming
const contentLength = r.content?.length || 0;
const element = contentElements[r.personaId];
const isExpanded = expandedStates[r.personaId];
// Only check overflow if we have content and an element
// If expanded, always recheck after DOM updates
if (element && contentLength > 0) {
newStates[r.personaId] = element.scrollHeight > MAX_COLLAPSED_HEIGHT;
} else {
newStates[r.personaId] = false;
}
});
overflowStates = newStates;
});
// Additional effect to force recheck after expansion state changes
$effect(() => {
// Track expanded states
const expandedKeys = Object.keys(expandedStates);
if (expandedKeys.length > 0) {
// Delay recheck to allow DOM to update
const timeout = setTimeout(() => {
const newStates: Record<string, boolean> = {};
responses.forEach(r => {
const element = contentElements[r.personaId];
if (element && r.content) {
newStates[r.personaId] = element.scrollHeight > MAX_COLLAPSED_HEIGHT;
}
});
overflowStates = { ...overflowStates, ...newStates };
}, 100);
return () => clearTimeout(timeout);
}
});
function hasOverflow(personaId: string): boolean {
return overflowStates[personaId] || false;
}
function openPersonaSettings(personaId: string) {
goto(`${base}/settings/personas/${personaId}`);
}
onDestroy(() => {
if (branchClickTimeout) {
clearTimeout(branchClickTimeout);
}
});
</script>
{#if message.from === "assistant"}
<div class="w-full">
<div
bind:offsetWidth={messageWidth}
class="group relative -mb-4 flex max-w-full items-start justify-start gap-4 pb-4 leading-relaxed max-sm:mb-1 {message.routerMetadata &&
messageInfoWidth >= messageWidth
? 'mb-1'
: ''}"
class:w-full={isPersonaMode}
class:w-fit={!isPersonaMode}
data-message-id={message.id}
data-message-role="assistant"
role="presentation"
onclick={() => (isTapped = !isTapped)}
onkeydown={() => (isTapped = !isTapped)}
>
<MessageAvatar
classNames="mt-5 size-3.5 flex-none select-none rounded-full shadow-lg max-sm:hidden"
animating={isLast && loading}
/>
<div class="flex-1 min-w-0 relative">
<!-- Focused mode carousel navigation arrows -->
{#if focusedPersonaId && hasMultipleCards}
{@const currentIndex = responses.findIndex(r => r.personaId === focusedPersonaId)}
{@const hasPrev = currentIndex > 0}
{@const hasNext = currentIndex < responses.length - 1}
{#if hasPrev}
<button
onclick={() => navigateFocused('prev')}
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"
aria-label="Previous persona"
>
<CarbonChevronLeft class="text-3xl" />
</button>
{/if}
{#if hasNext}
<button
onclick={() => navigateFocused('next')}
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"
aria-label="Next persona"
>
<CarbonChevronRight class="text-3xl" />
</button>
{/if}
{/if}
<!-- Container: horizontal scroll for multiple cards (unless focused), single card otherwise -->
<div class="{hasMultipleCards && !focusedPersonaId ? 'persona-scroll-container flex gap-3 overflow-x-auto pb-2' : ''}">
{#if isPersonaMode && responses.length === 0 && isLast && loading}
<!-- Loading state: waiting for personas to start responding -->
<IconLoading classNames="loading inline ml-2" />
{/if}
{#each responses as response (response.personaId)}
{@const isExpanded = expandedStates[response.personaId]}
{@const displayName = response.personaName || personaName || 'Assistant'}
{@const isFocused = focusedPersonaId === response.personaId}
{@const shouldHide = focusedPersonaId && !isFocused}
{@const isGenerating = isLast && loading && (!response.content || response.content.length === 0)}
{#if !shouldHide}
<!-- Card: ALL use gradient bubble styling for consistency -->
<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 {hasMultipleCards && !focusedPersonaId ? 'persona-card flex-shrink-0' : ''}"
style={hasMultipleCards && !focusedPersonaId ? `min-width: 320px; max-width: ${isExpanded ? '600px' : '420px'};` : ''}
data-persona-id={response.personaId}
>
<!-- Persona Header: persona name + action buttons -->
<div class="mb-3 flex items-center justify-between border-b border-gray-200 pb-2 dark:border-gray-700">
{#if isPersonaMode}
<button
type="button"
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"
onclick={() => openPersonaSettings(response.personaId)}
aria-label="Open settings for {displayName}"
title="View {displayName} settings"
>
{displayName}
</button>
{:else}
<h3 class="truncate text-lg font-semibold text-gray-700 dark:text-gray-200">
{displayName}
</h3>
{/if}
<div class="flex items-center gap-1">
{#if hasMultipleCards}
<button
type="button"
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors"
onclick={(e) => {
e.stopPropagation();
focusedPersonaId === response.personaId ? toggleExpanded(response.personaId) : setFocus(response.personaId);
}}
aria-label={focusedPersonaId === response.personaId ? "Exit focus mode" : "Focus this persona"}
title={focusedPersonaId === response.personaId ? "Show all cards" : "Focus on this card"}
>
{#if focusedPersonaId === response.personaId}
<CarbonMinimize class="text-base" />
{:else}
<CarbonMaximize class="text-base" />
{/if}
</button>
{/if}
{#if onretry}
<button
type="button"
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors {loading ? 'opacity-50 cursor-not-allowed' : ''}"
onclick={(e) => {
e.stopPropagation();
if (!loading) {
onretry?.({ id: message.id, personaId: response.personaId });
}
}}
disabled={loading}
aria-label="Regenerate {displayName}'s response"
title={loading ? "Please wait for current response to complete" : "Regenerate this response"}
>
<CarbonRotate360 class="text-base" />
</button>
{/if}
{#if onbranch}
{@const isBranchClicked = branchClickedPersonaId === response.personaId}
<button
type="button"
class="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 transition-colors {loading ? 'opacity-50 cursor-not-allowed' : ''}"
onclick={(e) => {
e.stopPropagation();
if (!loading) {
// Trigger animation
branchClickedPersonaId = response.personaId;
if (branchClickTimeout) {
clearTimeout(branchClickTimeout);
}
branchClickTimeout = setTimeout(() => {
branchClickedPersonaId = null;
}, 500);
onbranch?.(message.id, response.personaId);
}
}}
disabled={loading}
aria-label="Branch conversation with {displayName}"
title={loading ? "Please wait for current response to complete" : "Start private conversation with {displayName}"}
>
<div class="relative transition-transform duration-200 {isBranchClicked ? 'scale-125' : 'scale-100'}">
<CarbonBranch class="text-base" />
</div>
</button>
{/if}
<CopyToClipBoardBtn
classNames="!rounded-md !p-1.5 !text-gray-500 hover:!bg-gray-100 dark:!text-gray-400 dark:hover:!bg-gray-800 {loading ? 'opacity-50 cursor-not-allowed' : ''}"
value={response.content}
disabled={loading}
/>
</div>
</div>
<!-- File attachments: only for legacy mode (message-level, not persona-level) -->
{#if !isPersonaMode && message.files?.length}
<div class="flex h-fit flex-wrap gap-x-5 gap-y-2 mb-2">
{#each message.files as file (file.value)}
<UploadedFile {file} canClose={false} />
{/each}
</div>
{/if}
<!-- Thinking indicator for server reasoning (legacy mode only) -->
{#if !isPersonaMode && hasServerReasoning && loading && message.content.length === 0}
<ThinkingPlaceholder />
{/if}
<!-- Content -->
<div
bind:this={contentElements[response.personaId]}
class="mt-2"
style={isExpanded ? '' : `max-height: ${MAX_COLLAPSED_HEIGHT}px; overflow: hidden;`}
>
{#if isGenerating}
<!-- Loading state: show dots while generating -->
<IconLoading classNames="loading inline ml-2 first:ml-0" />
{:else}
<!-- Content with think tag parsing -->
{@const segments = splitThinkSegments(response.content ?? "")}
{#each segments as part, _i}
{#if part && part.startsWith("<think>")}
{@const trimmed = part.trimEnd()}
{@const isClosed = trimmed.endsWith("</think>")}
{#if !isClosed}
<ThinkingPlaceholder />
{/if}
{:else if part && part.trim().length > 0}
<div
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"
>
<MarkdownRenderer content={part} loading={isLast && loading} />
</div>
{/if}
{/each}
{/if}
{#if response.routerMetadata}
<div class="mt-2 text-xs text-gray-400 dark:text-gray-500">
<span class="font-medium">{response.routerMetadata.route}</span>
<span class="mx-1"></span>
<span>{response.routerMetadata.model}</span>
</div>
{/if}
</div>
</div>
{/if}
{/each}
</div>
<!-- Branch button for legacy mode (outside card border) -->
{#if !isPersonaMode && onbranch && personaName}
{@const branchCount = personaBranches.length}
{@const hasExistingBranches = branchCount > 0}
{@const isDisabled = isLast && loading}
<div class="mt-1.5 flex items-center justify-end gap-1 px-2">
<button
type="button"
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' : ''} {isDisabled ? 'opacity-50 cursor-not-allowed' : ''}"
onclick={() => !isDisabled && handleBranchButtonClick()}
disabled={isDisabled}
aria-label={hasExistingBranches ? "Branch options" : "Branch from this response"}
title={isDisabled ? "Please wait for current response to complete" : (hasExistingBranches ? "View or create branch" : "Branch from this response")}
>
<CarbonBranch class="text-xs" />
{#if hasExistingBranches}
<span>({branchCount})</span>
{/if}
</button>
</div>
{/if}
{#if message.routerMetadata && (!isLast || !loading)}
<div
class="absolute -bottom-3.5 {message.routerMetadata && messageInfoWidth > messageWidth
? 'left-1 pl-1 lg:pl-7'
: 'right-1'} flex max-w-[calc(100dvw-40px)] items-center gap-0.5 overflow-hidden"
bind:offsetWidth={messageInfoWidth}
>
<div
class="mr-2 flex items-center gap-1.5 truncate whitespace-nowrap text-[.65rem] text-gray-400 dark:text-gray-400 sm:text-xs"
>
<span class="truncate rounded bg-gray-100 px-1 font-mono dark:bg-gray-800 sm:py-px">
{message.routerMetadata.route}
</span>
<span class="text-gray-500">with</span>
{#if publicConfig.isHuggingChat}
<a
href="/chat/settings/models/{message.routerMetadata.model}"
class="truncate rounded bg-gray-100 px-1 font-mono hover:text-gray-500 dark:bg-gray-800 dark:hover:text-gray-300 sm:py-px"
>
{message.routerMetadata.model.split("/").pop()}
</a>
{:else}
<span class="truncate rounded bg-gray-100 px-1.5 font-mono dark:bg-gray-800 sm:py-px">
{message.routerMetadata.model.split("/").pop()}
</span>
{/if}
</div>
{#if alternatives.length > 1 && editMsdgId === null}
<Alternatives
{message}
{alternatives}
{loading}
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
/>
{/if}
</div>
{/if}
</div>
</div>
{#if activeMetacognitivePrompt}
<!-- Render below the response block -->
<!-- Only hide during initial loading (no stored event yet) -->
{#if !isLast || !loading || storedMetacognitiveEvent}
<div class="max-sm:pl-0 sm:pl-[1.875rem]">
<MetacognitivePrompt
promptType={activeMetacognitivePrompt.type}
promptText={activeMetacognitivePrompt.promptText}
suggestedPersonaName={activeMetacognitivePrompt.suggestedPersonaName}
onAction={() => onmetacognitiveaction?.()}
isClicked={isMetacognitiveEventAccepted}
/>
</div>
{/if}
{/if}
</div>
{/if}
{#if message.from === "user"}
<div
class="group relative {alternatives.length > 1 && editMsdgId === null
? 'mb-7'
: ''} w-full items-start justify-start gap-4 max-sm:text-sm"
data-message-id={message.id}
data-message-type="user"
role="presentation"
onclick={() => (isTapped = !isTapped)}
onkeydown={() => (isTapped = !isTapped)}
>
<div class="flex w-full flex-col gap-2">
{#if message.files?.length}
<div class="flex w-fit gap-4 px-5">
{#each message.files as file}
<UploadedFile {file} canClose={false} />
{/each}
</div>
{/if}
<div class="flex w-full flex-row flex-nowrap">
{#if !editMode}
<p
class="disabled w-full appearance-none whitespace-break-spaces text-wrap break-words bg-inherit px-5 py-3.5 text-gray-500 dark:text-gray-400"
>
{message.content.trim()}
</p>
{:else}
<form
class="mt-3 flex w-full flex-col"
bind:this={editFormEl}
onsubmit={(e) => {
e.preventDefault();
onretry?.({ content: editContentEl?.value, id: message.id });
editMsdgId = null;
}}
>
<textarea
class="w-full whitespace-break-spaces break-words rounded-xl bg-gray-100 px-5 py-3.5 text-gray-500 *:h-max focus:outline-none dark:bg-gray-800 dark:text-gray-400"
rows="5"
bind:this={editContentEl}
value={message.content.trim()}
onkeydown={handleKeyDown}
required
></textarea>
<div class="flex w-full flex-row flex-nowrap items-center justify-center gap-2 pt-2">
<button
type="submit"
class="btn rounded-lg px-3 py-1.5 text-sm
{loading
? 'bg-gray-300 text-gray-400 dark:bg-gray-700 dark:text-gray-600'
: 'bg-gray-200 text-gray-600 hover:text-gray-800 focus:ring-0 dark:bg-gray-800 dark:text-gray-300 dark:hover:text-gray-200'}
"
disabled={loading}
>
Send
</button>
<button
type="button"
class="btn rounded-sm p-2 text-sm text-gray-400 hover:text-gray-500 focus:ring-0 dark:text-gray-400 dark:hover:text-gray-300"
onclick={() => {
editMsdgId = null;
}}
>
Cancel
</button>
</div>
</form>
{/if}
</div>
<div class="absolute -bottom-4 ml-3.5 flex w-full gap-1.5">
{#if alternatives.length > 1 && editMsdgId === null}
<Alternatives
{message}
{alternatives}
{loading}
onshowAlternateMsg={(payload) => onshowAlternateMsg?.(payload)}
/>
{/if}
{#if (alternatives.length > 1 && editMsdgId === null) || (!loading && !editMode)}
{@const isRootMessage = message.from === "user" && (!message.ancestors || message.ancestors.length === 0)}
{#if !isRootMessage}
<button
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"
title="Edit"
type="button"
onclick={() => (editMsdgId = message.id)}
>
<CarbonPen />
Edit
</button>
{/if}
{/if}
</div>
</div>
</div>
{/if}
<style>
@keyframes loading {
to {
stroke-dashoffset: 122.9;
}
}
.persona-card {
transition: all 0.3s ease;
}
/* Fade effect for horizontal scroll container */
.persona-scroll-container {
position: relative;
}
.persona-scroll-container {
scrollbar-width: none;
}
.persona-scroll-container:hover {
scrollbar-width: thin;
scrollbar-color: rgba(156, 163, 175, 0.5) transparent;
}
.persona-scroll-container::-webkit-scrollbar {
height: 6px;
}
.persona-scroll-container::-webkit-scrollbar-track {
background: transparent;
margin: 0;
}
.persona-scroll-container::-webkit-scrollbar-thumb {
background-color: transparent;
border-radius: 10px;
transition: background-color 0.3s ease;
}
/* Show scrollbar thumb on hover */
.persona-scroll-container:hover::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
}
.persona-scroll-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.7);
}
/* Dark mode */
:global(.dark) .persona-scroll-container:hover {
scrollbar-color: rgba(107, 114, 128, 0.5) transparent;
}
:global(.dark) .persona-scroll-container:hover::-webkit-scrollbar-thumb {
background-color: rgba(107, 114, 128, 0.5);
}
:global(.dark) .persona-scroll-container::-webkit-scrollbar-thumb:hover {
background-color: rgba(156, 163, 175, 0.7);
}
</style>