Thomas G. Lopes
null checks
f9963a5
<script lang="ts">
import { clickOutside } from "$lib/attachments/click-outside.js";
import { checkpoints } from "$lib/state/checkpoints.svelte";
import { projects } from "$lib/state/projects.svelte";
import { iterate } from "$lib/utils/array.js";
import { formatDateTime } from "$lib/utils/date.js";
import { Popover } from "melt/builders";
import { Tooltip } from "melt/components";
import { fly } from "svelte/transition";
import IconCompare from "~icons/carbon/compare";
import IconHistory from "~icons/carbon/recently-viewed";
import IconStar from "~icons/carbon/star";
import IconStarFilled from "~icons/carbon/star-filled";
import IconDelete from "~icons/carbon/trash-can";
import { TEST_IDS } from "$lib/constants.js";
const popover = new Popover({
floatingConfig: {
offset: { crossAxis: -12 },
},
onOpenChange: open => {
if (open) dialog?.showModal();
else dialog?.close();
},
});
let dialog = $state<HTMLDialogElement>();
const projCheckpoints = $derived(checkpoints.for(projects.activeId));
</script>
<button class="btn relative size-[32px] p-0" {...popover.trigger} data-test-id={TEST_IDS.checkpoints_trigger}>
<IconHistory />
{#if projCheckpoints.length > 0}
<div class="absolute -top-1 -right-1 size-2.5 rounded-full bg-amber-500" aria-label="Project has checkpoints"></div>
{/if}
</button>
<dialog
bind:this={dialog}
class="mb-2 !overflow-visible rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
{@attach clickOutside(() => (popover.open = false))}
{...popover.content}
data-test-id={TEST_IDS.checkpoints_menu}
>
<div
class="size-4 translate-x-3 rounded-tl border-t border-l border-gray-200 dark:border-gray-700"
{...popover.arrow}
></div>
<div class="max-h-120 w-80 overflow-x-clip overflow-y-auto p-3 pb-1">
<div class="mb-2 flex items-center justify-between px-1">
<h3 class="text-sm font-medium dark:text-white">Checkpoints</h3>
<button
class="rounded-lg bg-blue-600 px-2 py-1 text-xs font-medium text-white transition-colors hover:bg-blue-700"
onclick={() => checkpoints.commit(projects.activeId)}
>
Create new
</button>
</div>
{#each projCheckpoints as checkpoint (checkpoint.id)}
{@const conversations = checkpoint.conversations}
{@const multiple = conversations.length > 1}
<Tooltip
openDelay={0}
floatingConfig={{
computePosition: {
placement: "right",
},
offset: {
mainAxis: 16,
},
}}
forceVisible
>
{#snippet children(tooltip)}
<div
class="mb-2 flex w-full items-center rounded-md px-3 hover:bg-gray-100 dark:hover:bg-gray-700"
{...tooltip.trigger}
data-test-id={TEST_IDS.checkpoint}
>
<button
class="flex flex-1 flex-col py-2 text-left text-sm transition-colors"
onclick={e => {
e.stopPropagation();
checkpoints.restore(checkpoint);
}}
>
<span class="font-medium text-gray-400">{formatDateTime(checkpoint.timestamp)}</span>
<p class="mt-0.5 flex items-center gap-2 text-sm">
{#if multiple}
<IconCompare class="text-xs text-gray-400" />
{/if}
{#each conversations as { messages }, i}
<span class={["text-gray-800 dark:text-gray-200"]}>
{messages?.length || 0} message{(messages?.length || 0) === 1 ? "" : "s"}
</span>
{#if multiple && i === 0}
<span class="text-gray-500">|</span>
{/if}
{/each}
</p>
</button>
<button
class="mr-0.5 grid place-items-center rounded-md p-1 text-xs hover:bg-gray-300 dark:hover:bg-gray-600"
onclick={e => {
e.stopPropagation();
checkpoints.toggleFavorite(checkpoint);
}}
>
{#if checkpoint.favorite}
<IconStarFilled class="text-yellow-500" />
{:else}
<IconStar />
{/if}
</button>
<button
class="grid place-items-center rounded-md p-1 text-xs hover:bg-gray-300 dark:hover:bg-gray-600"
onclick={e => {
e.stopPropagation();
checkpoints.delete(checkpoint);
}}
>
<IconDelete />
</button>
</div>
{#if tooltip.open}
<div
class={[
"flex rounded-xl border border-gray-100 bg-gray-50 p-2 shadow dark:border-gray-700 dark:bg-gray-800",
]}
{...tooltip.content}
transition:fly={{ x: -2 }}
>
<div
class="size-4 rounded-tl border-t border-l border-gray-200 dark:border-gray-700"
{...tooltip.arrow}
></div>
{#each conversations as conversation, i}
{@const msgs = conversation.messages || []}
{@const sliced = msgs.slice(0, 4)}
<div
class={[
"p-2",
multiple ? "w-52" : "w-72",
i === 0 && multiple && "border-r border-gray-200 dark:border-gray-700",
]}
>
<p class="text-2xs pl-1.5 font-mono font-medium text-gray-500 uppercase">
temp: {conversation.config.temperature}
{#if conversation.config.max_tokens}
| max tokens: {conversation.config.max_tokens}
{/if}
{#if conversation.structuredOutput?.enabled}
| structured output
{/if}
</p>
{#each iterate(sliced) as [msg, isLast]}
<div class="flex flex-col gap-1 p-2">
<p class="font-mono text-xs font-medium text-gray-400 uppercase">{msg.role}</p>
{#if msg.content?.trim()}
<p class="line-clamp-2 text-sm">{msg.content.trim()}</p>
{:else}
<p class="text-sm text-gray-500 italic">No content</p>
{/if}
</div>
{#if !isLast}
<div class="my-2 h-px w-full bg-gray-200 dark:bg-gray-700"></div>
{/if}
{/each}
</div>
{/each}
</div>
{/if}
{/snippet}
</Tooltip>
{:else}
<div class="flex flex-col items-center gap-2 py-3">
<span class="text-gray-500 text-sm">No checkpoints available</span>
</div>
{/each}
</div>
</dialog>