chat / src /routes /settings /(nav) /+layout.svelte
Andrew
Hover to click-to-deactivate persona.
ff03440
<script lang="ts">
import { onMount, tick } from "svelte";
import { base } from "$app/paths";
import { afterNavigate, goto } from "$app/navigation";
import { page } from "$app/state";
import { useSettingsStore } from "$lib/stores/settings";
import IconOmni from "$lib/components/icons/IconOmni.svelte";
import CarbonClose from "~icons/carbon/close";
import CarbonChevronLeft from "~icons/carbon/chevron-left";
import CarbonView from "~icons/carbon/view";
import CarbonLocked from "~icons/carbon/locked";
import type { LayoutData } from "../$types";
import { browser } from "$app/environment";
import { isDesktop } from "$lib/utils/isDesktop";
import { debounce } from "$lib/utils/debounce";
import type { Persona } from "$lib/types/Persona";
import { v4 } from "uuid";
interface Props {
data: LayoutData;
children?: import("svelte").Snippet;
}
let { data, children }: Props = $props();
let previousPage: string = $state(base || "/");
let showContent: boolean = $state(false);
let isCurrentlyDesktop: boolean = $state(browser && isDesktop(window));
let navContainer: HTMLDivElement | undefined = $state();
async function scrollSelectedItemIntoView() {
await tick();
const container = navContainer;
if (!container) return;
if (activeTab === 'models') {
const currentModelId = page.params.model as string | undefined;
if (!currentModelId) return;
const buttons = container.querySelectorAll<HTMLButtonElement>("button[data-model-id]");
for (const btn of buttons) {
if (btn.dataset.modelId === currentModelId) {
btn.scrollIntoView({ block: "nearest", inline: "nearest" });
break;
}
}
} else if (activeTab === 'personas') {
const currentPersonaId = page.params.persona as string | undefined;
if (!currentPersonaId) return;
const buttons = container.querySelectorAll<HTMLButtonElement>("button[data-persona-id]");
for (const btn of buttons) {
if (btn.dataset.personaId === currentPersonaId) {
btn.scrollIntoView({ block: "nearest", inline: "nearest" });
break;
}
}
}
}
function checkDesktopRedirect() {
if (!browser || !isDesktop(window)) return;
const pathname = page.url.pathname;
// Root settings redirect to application
if (pathname === `${base}/settings`) {
goto(`${base}/settings/application`, { replaceState: true });
return;
}
// If on models list page, redirect to active model detail
if (pathname === `${base}/settings/models`) {
const targetId = $settings.activeModel || data.models?.[0]?.id || "";
if (targetId) {
goto(`${base}/settings/models/${targetId}`, { replaceState: true });
}
return;
}
// If on personas list page, redirect to first active persona detail
if (pathname === `${base}/settings/personas`) {
const targetId = firstActivePersonaId ?? firstNonArchivedPersonaId ?? "";
if (targetId) {
goto(`${base}/settings/personas/${targetId}`, { replaceState: true });
}
return;
}
}
// Helper to determine if we should show content or list
function shouldShowContent(pathname: string): boolean {
// Hide content (show list only) for root settings, models list, and personas list
if (
pathname === `${base}/settings` ||
pathname === `${base}/settings/models` ||
pathname === `${base}/settings/personas`
) {
return false;
}
// Show content for everything else (specific model/persona/application)
return true;
}
onMount(() => {
// Show content based on current path
showContent = shouldShowContent(page.url.pathname);
// Initial desktop state
isCurrentlyDesktop = isDesktop(window);
// Initial desktop redirect check
checkDesktopRedirect();
// Ensure the selected item is visible in the nav
void scrollSelectedItemIntoView();
// Add resize listener for viewport changes
if (browser) {
const handleResize = () => {
isCurrentlyDesktop = isDesktop(window);
checkDesktopRedirect();
};
const debouncedResize = debounce(handleResize, 100);
window.addEventListener("resize", debouncedResize);
return () => window.removeEventListener("resize", debouncedResize);
}
});
afterNavigate(({ from }) => {
if (from?.url && !from.url.pathname.includes("settings")) {
previousPage = from.url.toString() || previousPage || base || "/";
}
// Show content based on current path
showContent = shouldShowContent(page.url.pathname);
// Check desktop redirect after navigation
checkDesktopRedirect();
// After navigation, keep the selected item in view
void scrollSelectedItemIntoView();
});
const settings = useSettingsStore();
$effect(() => {
const archivedIds = new Set(
$settings.personas.filter((persona) => persona.archived).map((persona) => persona.id)
);
if (archivedIds.size === 0) {
return;
}
const filteredActive = $settings.activePersonas.filter((id) => !archivedIds.has(id));
if (filteredActive.length !== $settings.activePersonas.length) {
void settings.instantSet({ activePersonas: filteredActive });
}
});
// Local filter for model list (hyphen/space insensitive)
let modelFilter = $state("");
const normalize = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ");
let queryTokens = $derived(normalize(modelFilter).trim().split(/\s+/).filter(Boolean));
// Local filter for persona list
let personaFilter = $state("");
let personaQueryTokens = $derived(normalize(personaFilter).trim().split(/\s+/).filter(Boolean));
// Determine active tab based on current route
let activeTab = $derived.by(() => {
if (page.url.pathname.includes("/personas")) return "personas";
if (page.url.pathname.includes("/application")) return "application";
if (page.url.pathname.includes("/models")) return "models";
return "models"; // default
});
let firstActivePersonaId = $derived(() =>
$settings.activePersonas.find((id) => !$settings.personas.find((p) => p.id === id)?.archived) ?? null
);
let firstNonArchivedPersonaId = $derived(() =>
$settings.personas.find((persona) => !persona.archived)?.id ?? null
);
function createNewPersona() {
const newPersona: Persona = {
id: v4(),
name: "New Persona",
age: "18-25",
gender: "Prefer not to say",
jobSector: "",
stance: "",
communicationStyle: "",
goalInDebate: "",
incomeBracket: "",
politicalLeanings: "",
geographicContext: "",
isDefault: false,
archived: false,
createdAt: new Date(),
updatedAt: new Date(),
};
$settings.personas = [...$settings.personas, newPersona];
// Navigate to the new persona
goto(`${base}/settings/personas/${newPersona.id}`);
}
function handlePersonaDoubleClick(personaId: string) {
// Toggle the persona on double-click
const isActive = $settings.activePersonas.includes(personaId);
if (isActive) {
// Prevent deactivating the last active persona
if ($settings.activePersonas.length === 1) {
alert("At least one persona must be active.");
return;
}
settings.instantSet({ activePersonas: $settings.activePersonas.filter(id => id !== personaId) });
} else {
settings.instantSet({ activePersonas: [...$settings.activePersonas, personaId] });
}
}
function deactivatePersona(personaId: string, event: Event) {
event.stopPropagation();
if ($settings.activePersonas.length === 1) {
alert("At least one persona must be active.");
return;
}
settings.instantSet({ activePersonas: $settings.activePersonas.filter(id => id !== personaId) });
}
let hoveredActiveTag: string | null = $state(null);
let hoverTimeout: number | undefined = $state();
function handleActiveTagMouseEnter(personaId: string) {
hoverTimeout = window.setTimeout(() => {
hoveredActiveTag = personaId;
}, 50);
}
function handleActiveTagMouseLeave() {
if (hoverTimeout) {
clearTimeout(hoverTimeout);
hoverTimeout = undefined;
}
hoveredActiveTag = null;
}
</script>
<div
class="mx-auto grid h-full w-full max-w-[1400px] grid-cols-1 grid-rows-[auto,1fr] content-start gap-x-6 overflow-hidden p-4 text-gray-800 dark:text-gray-300 md:grid-cols-3 md:grid-rows-[auto,1fr] md:p-4"
>
<div class="col-span-1 mb-3 flex flex-col gap-3 md:col-span-3 md:mb-4">
<div class="flex items-center justify-between">
{#if browser && !isCurrentlyDesktop}
{#if showContent && (activeTab === 'models' || activeTab === 'personas')}
<!-- Detail view: show only back button -->
<button
class="btn rounded-lg"
aria-label="Back to list"
onclick={() => {
if (activeTab === 'models') {
goto(`${base}/settings/models`);
} else if (activeTab === 'personas') {
goto(`${base}/settings/personas`);
}
}}
>
<CarbonChevronLeft
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
/>
</button>
{:else}
<!-- List view or application: show only X button -->
<button
class="btn rounded-lg"
aria-label="Close settings"
onclick={() => {
goto(previousPage);
}}
>
<CarbonClose
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
/>
</button>
{/if}
{/if}
<h2 class="left-0 right-0 mx-auto w-fit text-center text-xl font-bold">Settings</h2>
{#if browser && isCurrentlyDesktop}
<!-- Desktop: always show X button on the right -->
<button
class="btn rounded-lg"
aria-label="Close settings"
onclick={() => {
goto(previousPage);
}}
>
<CarbonClose
class="text-xl text-gray-900 hover:text-black dark:text-gray-200 dark:hover:text-white"
/>
</button>
{:else if showContent && (activeTab === 'models' || activeTab === 'personas')}
<!-- Mobile detail view: placeholder to maintain layout -->
<div class="size-8"></div>
{/if}
</div>
<!-- Tab Navigation -->
<div class="flex gap-2 border-b border-gray-200 dark:border-gray-700" class:max-md:hidden={showContent && browser && (activeTab === 'models' || activeTab === 'personas')}>
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === 'models'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'}"
onclick={() => {
// On mobile: go to list if coming from list or application page
if (browser && !isCurrentlyDesktop && (activeTab === 'application' || !showContent)) {
goto(`${base}/settings/models`);
} else {
goto(`${base}/settings/models/${$settings.activeModel || data.models?.[0]?.id || ''}`);
}
}}
>
Models
</button>
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === 'personas'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'}"
onclick={() => {
// On mobile: go to list if coming from list or application page
if (browser && !isCurrentlyDesktop && (activeTab === 'application' || !showContent)) {
goto(`${base}/settings/personas`);
} else {
const targetId = firstActivePersonaId ?? firstNonArchivedPersonaId ?? "";
goto(`${base}/settings/personas/${targetId}`);
}
}}
>
Personas
</button>
<button
class="px-4 py-2 text-sm font-medium border-b-2 transition-colors {activeTab === 'application'
? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400'
: 'border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'}"
onclick={() => goto(`${base}/settings/application`)}
>
Application
</button>
</div>
</div>
{#if !(showContent && browser && !isCurrentlyDesktop) && activeTab === 'models'}
<div
class="scrollbar-custom col-span-1 flex flex-col overflow-y-auto whitespace-nowrap rounded-r-xl bg-gradient-to-l from-gray-50 to-10% dark:from-gray-700/40 max-md:-mx-4 max-md:h-full md:pr-6"
class:max-md:hidden={showContent && browser}
bind:this={navContainer}
>
<!-- Filter input -->
<div class="px-2 py-2">
<input
bind:value={modelFilter}
type="search"
placeholder="Search by name"
aria-label="Search models by name or id"
class="w-full rounded-full border border-gray-300 bg-white px-4 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:placeholder:text-gray-500 dark:focus:ring-gray-700"
/>
</div>
{#each data.models
.filter((el) => !el.unlisted)
.filter((el) => {
const haystack = normalize(`${el.id} ${el.name ?? ""} ${el.displayName ?? ""}`);
return queryTokens.every((q) => haystack.includes(q));
}) as model}
<button
type="button"
onclick={() => goto(`${base}/settings/models/${model.id}`)}
class="group flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3 {model.id ===
page.params.model
? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'
: ''}"
data-model-id={model.id}
aria-label="Configure {model.displayName}"
>
<div class="mr-auto flex items-center gap-1 truncate">
<span class="truncate">{model.displayName}</span>
{#if model.isRouter}
<IconOmni />
{/if}
</div>
{#if $settings.multimodalOverrides?.[model.id] ?? model.multimodal}
<span
title="Supports image inputs (multimodal)"
class="grid size-[21px] flex-none place-items-center rounded-md border border-blue-700 dark:border-blue-500"
aria-label="Model is multimodal"
role="img"
>
<CarbonView class="text-xxs text-blue-700 dark:text-blue-500" />
</span>
{/if}
{#if model.id === $settings.activeModel}
<div
class="flex h-[21px] items-center rounded-md bg-black/90 px-2 text-[11px] font-semibold leading-none text-white dark:bg-white dark:text-black"
>
Active
</div>
{/if}
</button>
{/each}
</div>
{/if}
{#if !(showContent && browser && !isCurrentlyDesktop) && activeTab === 'personas'}
<div
class="scrollbar-custom col-span-1 flex flex-col overflow-y-auto whitespace-nowrap rounded-r-xl bg-gradient-to-l from-gray-50 to-10% dark:from-gray-700/40 max-md:-mx-4 max-md:h-full md:pr-6"
class:max-md:hidden={showContent && browser}
bind:this={navContainer}
>
<!-- Filter input -->
<div class="px-2 py-2">
<input
bind:value={personaFilter}
type="search"
placeholder="Search by name"
aria-label="Search personas by name"
class="w-full rounded-full border border-gray-300 bg-white px-4 py-1 text-sm placeholder:text-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-300 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:placeholder:text-gray-500 dark:focus:ring-gray-700"
/>
</div>
{#each $settings.personas
.filter((persona) => !persona.archived)
.filter((persona) => {
const haystack = normalize(`${persona.name} ${persona.jobSector ?? ""} ${persona.stance ?? ""}`);
return personaQueryTokens.every((q) => haystack.includes(q));
}) as persona (persona.id)}
<button
type="button"
onclick={() => goto(`${base}/settings/personas/${persona.id}`)}
ondblclick={() => handlePersonaDoubleClick(persona.id)}
class="group flex h-9 w-full flex-none items-center gap-1 rounded-lg px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3 {persona.id ===
page.params.persona
? '!bg-gray-100 !text-gray-800 dark:!bg-gray-700 dark:!text-gray-200'
: ''}"
data-persona-id={persona.id}
aria-label="Select {persona.name}"
title="Double-click to activate"
>
<div class="mr-auto flex items-center gap-1 truncate">
<span class="truncate">{persona.name}</span>
</div>
{#if persona.locked}
<span
title="This persona is locked and cannot be edited"
class="grid size-[21px] flex-none place-items-center rounded-md border border-amber-700 dark:border-amber-500"
aria-label="Persona is locked"
role="img"
>
<CarbonLocked class="text-xxs text-amber-700 dark:text-amber-500" />
</span>
{/if}
{#if $settings.activePersonas.includes(persona.id)}
<div
role="button"
tabindex="0"
class="flex h-[21px] cursor-pointer items-center rounded-md bg-black/90 px-2 text-[11px] font-semibold leading-none text-white dark:bg-white dark:text-black"
onclick={(e) => deactivatePersona(persona.id, e)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); deactivatePersona(persona.id, e); }}}
onmouseenter={() => handleActiveTagMouseEnter(persona.id)}
onmouseleave={handleActiveTagMouseLeave}
title="Click to deactivate"
>
{hoveredActiveTag === persona.id ? 'Deactivate?' : 'Active'}
</div>
{/if}
</button>
{/each}
<button
type="button"
onclick={createNewPersona}
class="group sticky bottom-0 mt-1 flex h-9 w-full flex-none items-center justify-center gap-1 rounded-lg bg-white px-3 text-[13px] text-gray-600 hover:bg-gray-100 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-800/60 md:rounded-xl md:px-3"
aria-label="Create new persona"
>
+ New Persona
</button>
</div>
{/if}
{#if showContent}
<div
class="scrollbar-custom col-span-1 w-full overflow-y-auto overflow-x-clip px-1 {activeTab === 'models' || activeTab === 'personas' ? 'md:col-span-2' : 'md:col-span-3'} md:row-span-2"
>
{@render children?.()}
</div>
{/if}
</div>