pluralchat / src /lib /components /NavConversationItem.svelte
Andrew
feat(tree): Add ELK port-based layout and persona-specific branching
cb5990d
<script lang="ts">
import { base } from "$app/paths";
import { page } from "$app/state";
import { goto } from "$app/navigation";
import CarbonCheckmark from "~icons/carbon/checkmark";
import CarbonTrashCan from "~icons/carbon/trash-can";
import CarbonClose from "~icons/carbon/close";
import CarbonEdit from "~icons/carbon/edit";
import CarbonChevronDown from "~icons/carbon/chevron-down";
import CarbonChevronRight from "~icons/carbon/chevron-right";
import type { ConvSidebar } from "$lib/types/ConvSidebar";
import { MessageRole } from "$lib/types/Message";
import EditConversationModal from "$lib/components/EditConversationModal.svelte";
import ConversationTreeGraph from "$lib/components/ConversationTreeGraph.svelte";
import { conversationTree } from "$lib/stores/conversationTree";
import { treeVisibility } from "$lib/stores/treeVisibility";
import type { TreeLayoutNode } from "$lib/utils/tree/layout";
import { buildTreeWithPositions } from "$lib/utils/tree/layout";
import { onDestroy } from "svelte";
interface Props {
conv: ConvSidebar;
readOnly?: true;
ondeleteConversation?: (id: string) => void;
oneditConversationTitle?: (payload: { id: string; title: string }) => void;
}
let { conv, readOnly, ondeleteConversation, oneditConversationTitle }: Props = $props();
let confirmDelete = $state(false);
let renameOpen = $state(false);
// Check if this is the active conversation with tree data
let isActiveWithTree = $derived(
conv.id === page.params.id && $conversationTree.conversationId === conv.id
);
// Initialize visibility for active conversation if not set
$effect(() => {
if (isActiveWithTree && $treeVisibility[conv.id.toString()] === undefined) {
// Default to visible for active conversation
treeVisibility.setVisible(conv.id.toString(), true);
}
});
let isVisible = $derived($treeVisibility[conv.id.toString()] ?? false);
let treeData = $state<{ nodes: TreeLayoutNode[]; width: number; height: number }>({
nodes: [],
width: 0,
height: 0
});
let treeUpdateTimeout: ReturnType<typeof setTimeout>;
// Update tree data when conversation or messages change
// Only update after messages are complete (have content)
// DEBOUNCED to prevent layout thrashing during streaming
$effect(() => {
if (isActiveWithTree && isVisible && $conversationTree.messages.length > 0) {
clearTimeout(treeUpdateTimeout);
treeUpdateTimeout = setTimeout(() => {
// Filter to only messages with content (streaming complete)
// For assistant messages with personas, check personaResponses
const completeMessages = $conversationTree.messages.filter(m => {
// Exclude system messages from tree visualization
if (m.from === MessageRole.System) {
return false;
}
if (m.from === MessageRole.Assistant && m.personaResponses && m.personaResponses.length > 0) {
// Check if at least one persona has content
return m.personaResponses.some(pr => pr.content && pr.content.trim().length > 0);
}
// For user messages, check main content field
return m.content && m.content.trim().length > 0;
});
if (completeMessages.length > 0) {
buildTreeWithPositions(
completeMessages,
$conversationTree.activeMessageId || undefined,
new Set($conversationTree.activePath)
).then((data) => {
treeData = data;
// Set CSS variable for sidebar width (tree width + padding)
// Allow expansion up to 800px for very wide trees
const sidebarWidth = Math.max(290, Math.min(800, data.width + 60)); // +60 for padding
document.documentElement.style.setProperty('--sidebar-width', `${sidebarWidth}px`);
}).catch((err) => {
console.error('Error building tree:', err);
});
}
}, 300); // 300ms debounce
} else {
treeData = { nodes: [], width: 0, height: 0 };
// Reset to default width if this was the active conversation
if (isActiveWithTree) {
document.documentElement.style.setProperty('--sidebar-width', '290px');
}
}
});
onDestroy(() => {
if (treeUpdateTimeout) clearTimeout(treeUpdateTimeout);
});
function handleTreeNodeClick(messageId: string, personaId?: string) {
const clickedMessage = $conversationTree.messages.find(m => m.id === messageId);
const personaParam = personaId ? `&personaId=${personaId}` : '';
if (!clickedMessage) {
console.error('Clicked message not found:', messageId);
goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true${personaParam}`);
return;
}
const currentBranchState = $conversationTree.branchedFrom;
if (currentBranchState) {
const currentActivePath = new Set($conversationTree.activePath);
if (currentActivePath.has(messageId)) {
// Message is in active branch; preserve state
goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true&keepBranch=true${personaParam}`);
return;
}
}
goto(`${base}/conversation/${conv.id}?msgId=${messageId}&scrollTo=true${personaParam}`);
}
</script>
<div class="flex flex-col">
<a
data-sveltekit-noscroll
onmouseleave={() => {
confirmDelete = false;
}}
onclick={(e) => {
// If clicking the active conversation, ensure tree is visible
if (isActiveWithTree && !isVisible) {
treeVisibility.setVisible(conv.id.toString(), true);
}
// Navigation happens automatically via href
}}
href="{base}/conversation/{conv.id}"
class="group flex h-[2.15rem] flex-none items-center gap-1.5 rounded-lg pl-2.5 pr-2 text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700 max-sm:h-10
{conv.id === page.params.id ? 'bg-gray-100 dark:bg-gray-700' : ''}"
>
<div class="my-2 min-w-0 flex-1 truncate first-letter:uppercase">
<span>
{#if confirmDelete}
<span class="mr-1 font-semibold"> Delete? </span>
{/if}
{conv.title}
</span>
</div>
{#if !readOnly}
{#if confirmDelete}
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
title="Cancel delete action"
onclick={(e) => {
e.preventDefault();
confirmDelete = false;
}}
>
<CarbonClose class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
title="Confirm delete action"
onclick={(e) => {
e.preventDefault();
confirmDelete = false;
ondeleteConversation?.(conv.id.toString());
}}
>
<CarbonCheckmark
class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
/>
</button>
{:else}
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
title={isVisible ? "Hide tree" : "Show tree"}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
if (isActiveWithTree) {
// Only toggle locally if it's the active conversation
treeVisibility.toggle(conv.id.toString());
} else {
// If inactive, navigate to it (Option A)
// This will naturally trigger the visibility effect to set it to true
goto(`${base}/conversation/${conv.id}`);
}
}}
>
{#if isActiveWithTree && isVisible}
<CarbonChevronDown class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
{:else}
<CarbonChevronRight class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
{/if}
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
title="Edit conversation title"
onclick={(e) => {
e.preventDefault();
renameOpen = true;
}}
>
<CarbonEdit class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" />
</button>
<button
type="button"
class="flex h-5 w-5 items-center justify-center rounded md:hidden md:group-hover:flex"
title="Delete conversation"
onclick={(event) => {
event.preventDefault();
if (event.shiftKey) {
ondeleteConversation?.(conv.id.toString());
} else {
confirmDelete = true;
}
}}
>
<CarbonTrashCan
class="text-xs text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
/>
</button>
{/if}
{/if}
</a>
<!-- Tree view (only shown for active conversation) -->
{#if isActiveWithTree && treeData.nodes.length > 0}
<ConversationTreeGraph
{treeData}
onNodeClick={handleTreeNodeClick}
/>
{/if}
</div>
<!-- Edit title modal -->
{#if renameOpen}
<EditConversationModal
open={renameOpen}
title={conv.title}
onclose={() => (renameOpen = false)}
onsave={(payload) => {
renameOpen = false;
oneditConversationTitle?.({ id: conv.id.toString(), title: payload.title });
}}
/>
{/if}