Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/bots.css
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@
--color-accent-soft: rgb(139 124 246 / 0.16);
--color-accent-ink: #fff;
--color-surface-2: var(--island-card-hover);
/* The chips, popovers and effort track of Boite's model picker (`components/model-picker/`), at Boite's dark values. */
--color-surface-3: #1f1f26;
--color-active: rgb(255 255 255 / 0.09);
--control-glaze: linear-gradient(145deg, rgb(255 255 255 / 0.055), rgb(255 255 255 / 0.012) 48%, transparent);
--color-control-hover: var(--color-surface-3);
--color-reasoning: var(--color-accent);
--color-reasoning-on: #fff;
--shadow-e1: 0 1px 2px rgb(0 0 0 / 0.35), inset 0 1px 0 rgb(255 255 255 / 0.05);
--shadow-e2: 0 8px 24px rgb(0 0 0 / 0.45), 0 0 0 1px rgb(255 255 255 / 0.05);
--color-success: var(--island-done);
--color-live: var(--island-attention);
--color-danger: var(--island-danger);
Expand All @@ -87,6 +96,11 @@
font-family: var(--island-font);
}

/* Logos drawn for both themes take their dark colours on the island's black, whatever Boite's theme. */
:root[data-theme] .island-tokens [data-logo] .themed {
fill: var(--fill-dark);
}

/* A card of the island's views and of Settings, and the small heading over it. */
.island-card {
padding: 10px 12px;
Expand Down
1 change: 1 addition & 0 deletions src/components/island/Island.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@
return frame.call(key);
},
hold: (key, on) => frame.hold(key, on),
resized: () => onresize(),
reveal: (threadId) => showMain(threadId),
show: (tab) => frame.show(tab),
sessions,
Expand Down
5 changes: 3 additions & 2 deletions src/components/island/assistant/AssistantChat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
const copy = strings.island.assistant;

let list = $state<HTMLElement | null>(null);
let head = $state<HTMLElement | null>(null);
let field = $state<HTMLTextAreaElement | null>(null);
/** The reader is at the end: what streams in keeps it there. */
let pinned = true;
Expand Down Expand Up @@ -115,10 +116,10 @@
<button class="primary small" onclick={() => void openSettings().catch(() => {})}>{copy.makeAgent}</button>
</div>
{:else}
<header class="head">
<header class="head" bind:this={head}>
<AgentMenu {assistant} bind:open={agentsOpen} />
{#if agent}
<ModelMenu {assistant} bind:open={menuOpen} />
<ModelMenu {assistant} bind:open={menuOpen} anchor={() => head} />
<button
class="ghost icon reveal"
aria-label={fill(copy.openAgent, { name })}
Expand Down
253 changes: 62 additions & 191 deletions src/components/island/assistant/ModelMenu.svelte
Original file line number Diff line number Diff line change
@@ -1,216 +1,87 @@
<!--
The agent's model and effort, in one quiet button: a menu of the models the
core offers for the agent's account, and the effort as three choices (low,
medium, high). They are the agent's own: the menu says a change reaches it
everywhere, in Boite too. A custom menu: arrows move, Enter picks, Escape
closes and gives the focus back. The island stays open while it is.
The assistant's model and effort, as Boite's composer chooses them: Boite's
model picker (providers, favorites, search, accounts) and its effort chip
(`components/model-picker/`), on the agent's own selection. A pick changes
the agent everywhere, in Boite too (`Assistant.setModel`, `setEffort`).
The popover hangs under the chat's head, inside the island's window, and the
island stays open while either is.
-->
<script lang="ts">
import { Check, ChevronDown } from '@lucide/svelte';
import type { Assistant, Effort } from '../../../lib/island/assistant.svelte';
import { untrack } from 'svelte';
import type { Assistant } from '../../../lib/island/assistant.svelte';
import { useView } from '../../../lib/island/contract';
import { fill, strings } from '../../../lib/strings';
import { pickerStoreFor } from '../../../lib/model-picker/link';
import type { PickerChoice } from '../../../lib/model-picker/store.svelte';
import EffortSlider from '../../model-picker/EffortSlider.svelte';
import ModelPicker from '../../model-picker/ModelPicker.svelte';

let { assistant, open = $bindable(false) }: { assistant: Assistant; open?: boolean } = $props();
let {
assistant,
open = $bindable(false),
anchor
}: { assistant: Assistant; open?: boolean; anchor?: () => HTMLElement | null } = $props();
const view = useView();
const copy = strings.island.assistant;

let trigger = $state<HTMLButtonElement | null>(null);
let menu = $state<HTMLDivElement | null>(null);
/** Boite's popover height: the island's window has room for it under the chat's head. */
const CAP = 360;
/** The closing animation's length, after which a popover leaves the page. */
const GONE_MS = 400;

const current = $derived(assistant.offered.find((model) => model.id === assistant.model));
const label = $derived(
`${current?.name ?? assistant.model ?? copy.defaultModel} · ${assistant.effort ? copy.efforts[assistant.effort] : copy.defaultEffort}`
const store = $derived(view.core.client ? pickerStoreFor(view.core.client) : null);
const selection = $derived(assistant.agent?.selection ?? null);
const choice = $derived<PickerChoice | null>(
selection ? { providerId: selection.providerId, accountId: selection.accountId, model: selection.model, effort: selection.effort } : null
);
const model = $derived(store?.modelOf(choice) ?? assistant.current);
const levels = $derived(model?.effort?.levels ?? assistant.efforts);

let pickerOpen = $state(false);
let effortOpen = $state(false);
$effect(() => {
view.hold('assistant-menu', open);
if (!open) return;
const outside = (event: PointerEvent) => {
if (!menu?.contains(event.target as Node) && !trigger?.contains(event.target as Node)) open = false;
};
document.addEventListener('pointerdown', outside, true);
queueMicrotask(() => menu?.querySelector<HTMLElement>('[aria-checked="true"], [role^="menuitem"]')?.focus());
return () => document.removeEventListener('pointerdown', outside, true);
open = pickerOpen || effortOpen;
});
$effect(() => view.hold('assistant-menu', open));
$effect(() => () => view.hold('assistant-menu', false));

function close() {
open = false;
trigger?.focus();
}

function keys(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
return close();
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
event.preventDefault();
const items = [...(menu?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [])];
const at = items.indexOf(document.activeElement as HTMLElement);
items[(at + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length]?.focus();
}
// A popover over the island's edge takes clicks there: the window reads its
// areas once it is placed, and again once it has gone.
$effect(() => {
const shown = open;
const read = setTimeout(() => view.resized?.(), shown ? 60 : GONE_MS);
return () => clearTimeout(read);
});

function pickModel(id: string) {
void assistant.setModel(id);
}
// Boite's composer reads the agent's list as it mounts, which names the model on the chip.
$effect(() => {
const current = choice;
if (store && current) untrack(() => void store.probeModels(current.providerId, current.accountId));
});

function pickEffort(effort: Effort) {
void assistant.setEffort(effort);
}
// A pick the picker could not make reads as the chat's own problem line.
$effect(() => {
const error = store?.error;
if (!store || !error) return;
untrack(() => {
assistant.problem = error;
store.error = null;
});
});
</script>

<div class="picker">
<button
bind:this={trigger}
class="trigger"
aria-haspopup="menu"
aria-expanded={open}
aria-label="{copy.picker}: {label}"
title={copy.picker}
data-testid="assistant-model"
onclick={() => (open = !open)}
>
<span class="name">{label}</span>
<ChevronDown size={13} />
</button>

{#if open}
<div bind:this={menu} class="menu" role="menu" aria-label={copy.picker} tabindex="-1" onkeydown={keys} data-testid="assistant-model-menu">
<p class="label">{copy.model}</p>
{#each assistant.offered as model (model.id)}
<button role="menuitemradio" aria-checked={model.id === assistant.model} class="item" onclick={() => pickModel(model.id)}>
<span>{model.name}</span>
{#if model.id === assistant.model}<Check size={13} />{/if}
</button>
{:else}
<p class="none">{copy.noModels}</p>
{/each}
<p class="label">{copy.effort}</p>
<div class="efforts" role="group" aria-label={copy.effort}>
{#each assistant.efforts as effort (effort)}
<button role="menuitemradio" aria-checked={effort === assistant.effort} class="effort" class:on={effort === assistant.effort} onclick={() => pickEffort(effort)}>
{copy.efforts[effort]}
</button>
{/each}
</div>
<p class="note">{fill(copy.everywhere, { name: assistant.agent?.name ?? '' })}</p>
</div>
{/if}
</div>
{#if store}
<div class="model" data-testid="assistant-model">
<ModelPicker {store} {choice} {anchor} cap={CAP} onopen={(shown) => (pickerOpen = shown)} onpick={(patch) => void assistant.setModel(patch.model, patch)} />
{#if levels.length > 0}
<EffortSlider {levels} active={choice?.effort ?? model?.effort?.default ?? null} onopen={(shown) => (effortOpen = shown)} onpick={(id) => void assistant.setEffort(id)} />
{/if}
</div>
{/if}

<style>
.picker {
position: relative;
min-width: 0;
}
.trigger {
.model {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
height: 26px;
padding: 0 8px 0 10px;
border: 1px solid var(--island-line);
border-radius: 999px;
background: none;
color: var(--island-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.trigger:hover,
.trigger[aria-expanded='true'] {
border-color: var(--island-line-strong);
color: var(--island-text);
}
.name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu {
position: absolute;
z-index: 5;
top: calc(100% + 6px);
left: 0;
display: flex;
flex-direction: column;
gap: 1px;
width: 230px;
max-height: 260px;
padding: 6px;
border: 1px solid var(--island-line-strong);
border-radius: 12px;
background: var(--island-menu);
box-shadow: 0 12px 32px rgb(0 0 0 / 0.5);
overflow-y: auto;
}
.label {
margin: 6px 8px 4px;
color: var(--island-faint);
font-size: 11px;
font-weight: 600;
}
.label:first-child {
margin-top: 2px;
}
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
padding: 0 8px;
border: 0;
border-radius: 7px;
background: none;
color: var(--island-text);
font: inherit;
font-size: 12.5px;
text-align: left;
cursor: pointer;
}
.item:is(:hover, :focus-visible) {
background: var(--island-card-hover);
outline: none;
}
.note {
margin: 8px 8px 2px;
color: var(--island-subtle);
font-size: 11.5px;
line-height: 1.35;
}
.none {
margin: 2px 8px 4px;
color: var(--island-muted);
font-size: 12px;
}
.efforts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
gap: 2px;
padding: 2px;
border-radius: 9px;
background: var(--island-pill);
}
.effort {
height: 26px;
border: 0;
border-radius: 7px;
background: none;
color: var(--island-muted);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.effort.on {
background: var(--island-card-hover);
color: var(--island-text);
}
.effort:focus-visible {
outline: 1px solid var(--island-line-strong);
min-width: 0;
}
</style>
Loading
Loading