← back to Exo
dashboard/src/lib/components/ChatSidebar.svelte
629 lines
<script lang="ts">
import {
conversations,
activeConversationId,
loadConversation,
deleteConversation,
deleteAllConversations,
renameConversation,
clearChat,
instances,
debugMode,
toggleDebugMode,
topologyOnlyMode,
toggleTopologyOnlyMode,
} from "$lib/stores/app.svelte";
interface Props {
class?: string;
onNewChat?: () => void;
onSelectConversation?: () => void;
isMobileDrawer?: boolean;
isOpen?: boolean;
onClose?: () => void;
}
let {
class: className = "",
onNewChat,
onSelectConversation,
isMobileDrawer = false,
isOpen = false,
onClose,
}: Props = $props();
const conversationList = $derived(conversations());
const activeId = $derived(activeConversationId());
const instanceData = $derived(instances());
const debugEnabled = $derived(debugMode());
const topologyOnlyEnabled = $derived(topologyOnlyMode());
let searchQuery = $state("");
let editingId = $state<string | null>(null);
let editingName = $state("");
let deleteConfirmId = $state<string | null>(null);
let showDeleteAllConfirm = $state(false);
const filteredConversations = $derived(
searchQuery.trim()
? conversationList.filter((c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: conversationList,
);
function handleNewChat() {
onNewChat?.();
}
function handleSelectConversation(id: string) {
onSelectConversation?.();
loadConversation(id);
// Close mobile drawer when selecting a conversation
if (isMobileDrawer && isOpen) {
onClose?.();
}
}
function handleStartEdit(id: string, name: string, event: MouseEvent) {
event.stopPropagation();
editingId = id;
editingName = name;
}
function handleSaveEdit() {
if (editingId && editingName.trim()) {
renameConversation(editingId, editingName.trim());
}
editingId = null;
editingName = "";
}
function handleCancelEdit() {
editingId = null;
editingName = "";
}
function handleEditKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
handleSaveEdit();
} else if (event.key === "Escape") {
handleCancelEdit();
}
}
function handleDeleteClick(id: string, event: MouseEvent) {
event.stopPropagation();
deleteConfirmId = id;
}
function handleConfirmDelete() {
if (deleteConfirmId) {
deleteConversation(deleteConfirmId);
deleteConfirmId = null;
}
}
function handleCancelDelete() {
deleteConfirmId = null;
}
function handleDeleteAllClick() {
showDeleteAllConfirm = true;
}
function handleConfirmDeleteAll() {
deleteAllConversations();
showDeleteAllConfirm = false;
}
function handleCancelDeleteAll() {
showDeleteAllConfirm = false;
}
function formatDate(timestamp: number): string {
const date = new Date(timestamp);
const now = new Date();
const diffDays = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffDays === 0) {
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
} else if (diffDays === 1) {
return "Yesterday";
} else if (diffDays < 7) {
return date.toLocaleDateString("en-US", { weekday: "short" });
} else {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}
}
function getLastAssistantStats(
conversation: (typeof conversationList)[0],
): { ttftMs?: number; tps?: number } | null {
// Find the last assistant message with stats
for (let i = conversation.messages.length - 1; i >= 0; i--) {
const msg = conversation.messages[i];
if (msg.role === "assistant" && (msg.ttftMs || msg.tps)) {
return { ttftMs: msg.ttftMs, tps: msg.tps };
}
}
return null;
}
function formatModelName(modelId: string | null | undefined): string {
if (!modelId) return "Unknown Model";
const parts = modelId.split("/");
const tail = parts[parts.length - 1] || modelId;
return tail || modelId;
}
function formatStrategy(
sharding: string | null | undefined,
instanceType: string | null | undefined,
): string {
const shardLabel = sharding ?? "Unknown";
const typeLabel = instanceType ?? null;
return typeLabel ? `${shardLabel} (${typeLabel})` : shardLabel;
}
function getTaggedValue(obj: unknown): [string | null, unknown] {
if (!obj || typeof obj !== "object") return [null, null];
const keys = Object.keys(obj as Record<string, unknown>);
if (keys.length === 1) {
return [keys[0], (obj as Record<string, unknown>)[keys[0]]];
}
return [null, null];
}
function extractInstanceModelId(instanceWrapped: unknown): string | null {
const [, instance] = getTaggedValue(instanceWrapped);
if (!instance || typeof instance !== "object") return null;
const inst = instance as { shardAssignments?: { modelId?: string } };
return inst.shardAssignments?.modelId ?? null;
}
function describeInstance(instanceWrapped: unknown): {
sharding: string | null;
instanceType: string | null;
} {
const [instanceTag, instance] = getTaggedValue(instanceWrapped);
if (!instance || typeof instance !== "object") {
return { sharding: null, instanceType: null };
}
let instanceType: string | null = null;
if (instanceTag === "MlxRingInstance") instanceType = "MLX Ring";
else if (instanceTag === "MlxJacclInstance") instanceType = "MLX RDMA";
let sharding: string | null = null;
const inst = instance as {
shardAssignments?: { runnerToShard?: Record<string, unknown> };
};
const runnerToShard = inst.shardAssignments?.runnerToShard || {};
const firstShardWrapped = Object.values(runnerToShard)[0];
if (firstShardWrapped) {
const [shardTag] = getTaggedValue(firstShardWrapped);
if (shardTag === "PipelineShardMetadata") sharding = "Pipeline";
else if (shardTag === "TensorShardMetadata") sharding = "Tensor";
else if (shardTag === "PrefillDecodeShardMetadata")
sharding = "Prefill/Decode";
}
return { sharding, instanceType };
}
function resolveConversationInfo(
conversation: (typeof conversationList)[0],
): { modelLabel: string; strategyLabel: string } {
// Attempt to match conversation model to an instance
let matchedInstance: unknown = null;
let modelId = conversation.modelId ?? null;
if (modelId) {
for (const [, instanceWrapper] of Object.entries(instanceData)) {
const candidate = extractInstanceModelId(instanceWrapper);
if (candidate === modelId) {
matchedInstance = instanceWrapper;
break;
}
}
}
// Fallback: use the first available instance if no explicit match
if (!matchedInstance) {
const firstInstance = Object.values(instanceData)[0];
if (firstInstance) {
matchedInstance = firstInstance;
modelId = modelId ?? extractInstanceModelId(firstInstance);
}
}
const instanceDetails = matchedInstance
? describeInstance(matchedInstance)
: { sharding: null, instanceType: null };
const displayModel = modelId ?? conversation.modelId ?? null;
const sharding =
conversation.sharding ?? instanceDetails.sharding ?? "Unknown";
const instanceType =
conversation.instanceType ?? instanceDetails.instanceType;
return {
modelLabel: formatModelName(displayModel),
strategyLabel: formatStrategy(sharding, instanceType),
};
}
</script>
{#snippet sidebarContent()}
<!-- Header -->
<div class="p-4">
<button
onclick={handleNewChat}
class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-transparent border border-exo-yellow/30 text-exo-yellow text-xs font-mono tracking-wider uppercase hover:border-exo-yellow/50 transition-all cursor-pointer"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
NEW CHAT
</button>
</div>
<!-- Search -->
<div class="px-4 py-3">
<div class="relative">
<svg
class="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/50"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
<input
type="text"
bind:value={searchQuery}
placeholder="Search conversations..."
class="w-full bg-exo-black/40 border border-exo-medium-gray/30 rounded px-3 py-2 pl-9 text-xs text-white/90 placeholder:text-white/40 focus:outline-none focus:border-exo-yellow/30"
/>
</div>
</div>
<!-- Conversation List -->
<div class="flex-1 overflow-y-auto">
{#if filteredConversations.length > 0}
<div class="py-2">
<div class="px-4 py-2">
<span
class="text-xs text-exo-light-gray font-mono tracking-wider uppercase"
>
{searchQuery ? "SEARCH RESULTS" : "CONVERSATIONS"}
</span>
</div>
{#each filteredConversations as conversation (conversation.id)}
{@const info = resolveConversationInfo(conversation)}
<div class="px-2">
{#if editingId === conversation.id}
<!-- Edit mode -->
<div
class="p-2 bg-transparent border border-exo-yellow/20 rounded mb-1"
>
<input
type="text"
bind:value={editingName}
onkeydown={handleEditKeydown}
class="w-full bg-exo-black/60 border border-exo-yellow/30 rounded px-2 py-1.5 text-xs text-exo-light-gray focus:outline-none focus:border-exo-yellow/50 mb-2"
autofocus
/>
<div class="flex gap-2">
<button
onclick={handleSaveEdit}
class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-transparent text-exo-yellow border border-exo-yellow/30 rounded hover:border-exo-yellow/50 cursor-pointer"
>
SAVE
</button>
<button
onclick={handleCancelEdit}
class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
>
CANCEL
</button>
</div>
</div>
{:else if deleteConfirmId === conversation.id}
<!-- Delete confirmation -->
<div
class="p-2 bg-red-500/10 border border-red-500/30 rounded mb-1"
>
<p class="text-xs text-red-400 mb-2">
Delete "{conversation.name}"?
</p>
<div class="flex gap-2">
<button
onclick={handleConfirmDelete}
class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 cursor-pointer"
>
DELETE
</button>
<button
onclick={handleCancelDelete}
class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 cursor-pointer"
>
CANCEL
</button>
</div>
</div>
{:else}
<!-- Normal view -->
{@const stats = getLastAssistantStats(conversation)}
<div
role="button"
tabindex="0"
onclick={() => handleSelectConversation(conversation.id)}
onkeydown={(e) =>
e.key === "Enter" &&
handleSelectConversation(conversation.id)}
class="group w-full flex items-center justify-between p-2.5 rounded-lg mb-1 transition-all text-left cursor-pointer
{activeId === conversation.id
? 'bg-exo-yellow/5 border border-exo-yellow/30'
: 'hover:bg-white/[0.03] hover:border-white/10 border border-transparent'}"
>
<div class="flex-1 min-w-0 pr-2">
<div
class="text-sm font-medium truncate {activeId ===
conversation.id
? 'text-exo-yellow'
: 'text-white'}"
>
{conversation.name}
</div>
<div class="text-xs text-white/60 mt-0.5">
{formatDate(conversation.updatedAt)}
</div>
<div class="text-xs text-exo-light-gray truncate">
{info.modelLabel}
</div>
{#if stats}
<div class="text-xs text-white/70 font-mono mt-1">
{#if stats.ttftMs}<span class="text-white/50">TTFT</span>
<span class="text-exo-yellow/80"
>{stats.ttftMs.toFixed(0)}ms</span
>{/if}{#if stats.ttftMs && stats.tps}<span
class="text-white/30 mx-1.5">·</span
>{/if}{#if stats.tps}<span class="text-exo-yellow/80"
>{stats.tps.toFixed(1)}</span
>
<span class="text-white/50">tok/s</span>{/if}
</div>
{/if}
</div>
<div
class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
type="button"
onclick={(e) =>
handleStartEdit(conversation.id, conversation.name, e)}
class="p-1 text-exo-light-gray hover:text-exo-yellow transition-colors cursor-pointer"
title="Rename"
>
<svg
class="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
<button
type="button"
onclick={(e) => handleDeleteClick(conversation.id, e)}
class="p-1 text-exo-light-gray hover:text-red-400 transition-colors cursor-pointer"
title="Delete"
>
<svg
class="w-3 h-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{:else}
<div
class="flex flex-col items-center justify-center h-full p-4 text-center"
>
<div
class="w-12 h-12 border border-exo-yellow/20 rounded-full flex items-center justify-center mb-3"
>
<svg
class="w-6 h-6 text-exo-yellow/40"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
</div>
<p
class="text-xs text-white/70 font-mono tracking-wider uppercase mb-1"
>
{searchQuery ? "NO RESULTS" : "NO CONVERSATIONS"}
</p>
<p class="text-sm text-white/50">
{searchQuery ? "Try a different search" : "Start a new chat to begin"}
</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="p-3 border-t border-exo-yellow/10">
{#if showDeleteAllConfirm}
<div class="bg-red-500/10 border border-red-500/30 rounded p-2 mb-2">
<p class="text-xs text-red-400 text-center mb-2">
Delete all {conversationList.length} conversations?
</p>
<div class="flex gap-2">
<button
onclick={handleConfirmDeleteAll}
class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-red-500/20 text-red-400 border border-red-500/30 rounded hover:bg-red-500/30 transition-colors cursor-pointer"
>
DELETE ALL
</button>
<button
onclick={handleCancelDeleteAll}
class="flex-1 py-1.5 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/20 text-exo-light-gray border border-exo-medium-gray/30 rounded hover:bg-exo-medium-gray/30 transition-colors cursor-pointer"
>
CANCEL
</button>
</div>
</div>
{:else if conversationList.length > 0}
<button
onclick={handleDeleteAllClick}
class="w-full flex items-center justify-center gap-2 py-1.5 text-sm font-mono tracking-wider uppercase text-white/70 hover:text-red-400 hover:bg-red-500/10 border border-transparent hover:border-red-500/20 rounded transition-all cursor-pointer"
>
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
DELETE ALL CHATS
</button>
{/if}
<div
class="flex items-center justify-center gap-3 {conversationList.length >
0 && !showDeleteAllConfirm
? 'mt-2'
: ''}"
>
<button
type="button"
onclick={toggleDebugMode}
class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
title="Toggle debug mode"
>
<svg
class="w-4 h-4 {debugEnabled
? 'text-exo-yellow'
: 'text-exo-medium-gray'}"
fill="currentColor"
viewBox="0 0 24 24"
>
<path
d="M19 8h-1.81A6.002 6.002 0 0 0 12 2a6.002 6.002 0 0 0-5.19 3H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1v2H5a1 1 0 0 0 0 2h1.81A6.002 6.002 0 0 0 12 22a6.002 6.002 0 0 0 5.19-3H19a1 1 0 0 0 0-2h-1v-2h1a1 1 0 0 0 0-2h-1v-2h1a1 1 0 1 0 0-2Zm-5 10.32V19a1 1 0 1 1-2 0v-.68a3.999 3.999 0 0 1-3-3.83V9.32a3.999 3.999 0 0 1 3-3.83V5a1 1 0 0 1 2 0v.49a3.999 3.999 0 0 1 3 3.83v5.17a3.999 3.999 0 0 1-3 3.83Z"
/>
</svg>
</button>
<div class="text-xs text-white/60 font-mono tracking-wider text-center">
{conversationList.length} CONVERSATION{conversationList.length !== 1
? "S"
: ""}
</div>
<button
type="button"
onclick={toggleTopologyOnlyMode}
class="p-1.5 rounded border border-exo-medium-gray/40 hover:border-exo-yellow/50 transition-colors cursor-pointer"
title="Toggle topology only mode"
>
<svg
class="w-4 h-4 {topologyOnlyEnabled
? 'text-exo-yellow'
: 'text-exo-medium-gray'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<circle cx="12" cy="5" r="2" fill="currentColor" />
<circle cx="5" cy="19" r="2" fill="currentColor" />
<circle cx="19" cy="19" r="2" fill="currentColor" />
<path stroke-linecap="round" d="M12 7v5m0 0l-5 5m5-5l5 5" />
</svg>
</button>
</div>
</div>
{/snippet}
{#if isMobileDrawer}
<!-- Mobile drawer with overlay -->
{#if isOpen}
<!-- Overlay backdrop -->
<button
type="button"
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
onclick={() => onClose?.()}
aria-label="Close sidebar"
></button>
<!-- Drawer panel -->
<aside
class="fixed left-0 top-0 bottom-0 w-72 bg-exo-dark-gray border-r border-exo-yellow/10 z-50 flex flex-col md:hidden"
>
{@render sidebarContent()}
</aside>
{/if}
{:else}
<!-- Desktop sidebar -->
<aside
class="flex flex-col h-full bg-exo-dark-gray border-r border-exo-yellow/10 {className}"
>
{@render sidebarContent()}
</aside>
{/if}