Skip to content
Open
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
141 changes: 94 additions & 47 deletions src/lib/components/Composer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,32 @@
let fileInput = $state<HTMLInputElement>();
let dragging = $state(false);

// Overflow ("⋯") group: below the composer's own container-width breakpoint the
// trust pill + Prompts + Enhance collapse behind this toggle. The controls exist
// exactly once in the DOM; CSS (a container query, so browser sidebars count)
// decides whether they render inline or inside the popover.
let moreOpen = $state(false);
let moreBtn = $state<HTMLElement>();
let morePanel = $state<HTMLElement>();

$effect(() => {
if (!moreOpen) return;
const onDown = (e: MouseEvent) => {
const t = e.target as Node | null;
if (moreBtn?.contains(t) || morePanel?.contains(t)) return;
moreOpen = false;
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') moreOpen = false;
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
});

onMount(() => {
modelStore.load();
});
Expand Down Expand Up @@ -110,7 +136,7 @@

<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="rounded-t-mlq-composer border bg-mlq-surface p-3 shadow-sm {dragging
class="@container/composer rounded-t-mlq-composer border bg-mlq-surface p-3 shadow-sm {dragging
? 'border-mlq-workflow'
: 'border-mlq-subtle'}"
ondragover={(e) => {
Expand Down Expand Up @@ -223,18 +249,64 @@
class="max-h-48 w-full resize-none bg-transparent font-serif text-mlq-text outline-none placeholder:text-mlq-muted"
></textarea>

<div class="mt-2 flex items-center gap-2 border-t border-mlq-subtle pt-2">
<div class="relative mt-2 flex items-center gap-2 border-t border-mlq-subtle pt-2">
<ModelPicker
options={modelStore.options}
selected={modelStore.selectedModel}
error={modelStore.error}
{minimumTier}
onselect={modelStore.setModel}
/>
<TrustPill
option={modelStore.selectedOption}
format={page.data.user?.trust_pills ?? 'labels'}
/>
<!-- Collapsible group: inline (display: contents) at comfortable container
widths; below @3xl it becomes the "⋯" popover so the row never wraps. -->
<div
bind:this={morePanel}
data-testid="composer-overflow"
class="contents {moreOpen
? '@max-3xl/composer:absolute @max-3xl/composer:bottom-full @max-3xl/composer:left-0 @max-3xl/composer:z-20 @max-3xl/composer:mb-1 @max-3xl/composer:flex @max-3xl/composer:flex-col @max-3xl/composer:items-start @max-3xl/composer:gap-2 @max-3xl/composer:rounded-mlq-control @max-3xl/composer:border @max-3xl/composer:border-mlq-subtle @max-3xl/composer:bg-mlq-surface @max-3xl/composer:p-2 @max-3xl/composer:shadow-md'
: '@max-3xl/composer:hidden'}"
>
<TrustPill
option={modelStore.selectedOption}
format={page.data.user?.trust_pills ?? 'labels'}
/>
{#if promptLibrary}
<PromptPicker
prompts={promptLibrary.prompts}
loading={promptLibrary.loading}
error={promptLibrary.error}
draft={value}
onopen={promptLibrary.ensureLoaded}
oninsert={insertAtCursor}
onsave={promptLibrary.create}
/>
{/if}
{#if enhance}
{#if enhance.status === 'loading'}
<span
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap text-mlq-muted"
>
Enhancing…
<button
type="button"
aria-label="Cancel enhance"
onclick={enhance.cancel}
class="hover:text-mlq-text"><X size={12} /></button
>
</span>
{:else}
<button
type="button"
data-testid="enhance-button"
onclick={() => enhance.run(value)}
disabled={!value.trim()}
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap text-mlq-text disabled:opacity-40"
>
<Sparkles size={13} /> Enhance
</button>
{/if}
{/if}
</div>
{#if matters}
<MatterPicker {matters} bind:selectedId={selectedMatterId} />
{/if}
Expand All @@ -255,22 +327,22 @@
aria-checked={sticky.enabled}
data-testid="sticky-toggle"
onclick={() => sticky.toggle(skillAttach?.names ?? [])}
class="inline-flex items-center gap-1.5 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs {sticky.enabled
class="inline-flex items-center gap-1.5 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap {sticky.enabled
? 'bg-mlq-subtle text-mlq-strong'
: 'text-mlq-text'}"
title="Keep the skills applied in this chat on for follow-up messages"
>
<span
class="inline-block h-2 w-2 rounded-full {sticky.enabled
class="inline-block h-2 w-2 shrink-0 rounded-full {sticky.enabled
? 'bg-mlq-strong'
: 'bg-mlq-muted'}"
></span>
Keep skills on
<span class="whitespace-nowrap">Skills stay on</span>
{#if sticky.enabled && sticky.set.length > 0}
<span
class="text-mlq-muted"
class="whitespace-nowrap text-mlq-muted"
title={sticky.set.map(prettifySkillSlug).join(', ')}
data-testid="sticky-count">· Keeping {sticky.set.length} on</span
data-testid="sticky-count">· {sticky.set.length}</span
>
{/if}
</button>
Expand Down Expand Up @@ -298,42 +370,17 @@
<Paperclip size={13} />
</button>
{/if}
{#if promptLibrary}
<PromptPicker
prompts={promptLibrary.prompts}
loading={promptLibrary.loading}
error={promptLibrary.error}
draft={value}
onopen={promptLibrary.ensureLoaded}
oninsert={insertAtCursor}
onsave={promptLibrary.create}
/>
{/if}
{#if enhance}
{#if enhance.status === 'loading'}
<span
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs text-mlq-muted"
>
Enhancing…
<button
type="button"
aria-label="Cancel enhance"
onclick={enhance.cancel}
class="hover:text-mlq-text"><X size={12} /></button
>
</span>
{:else}
<button
type="button"
data-testid="enhance-button"
onclick={() => enhance.run(value)}
disabled={!value.trim()}
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs text-mlq-text disabled:opacity-40"
>
<Sparkles size={13} /> Enhance
</button>
{/if}
{/if}
<button
bind:this={moreBtn}
type="button"
data-testid="composer-more"
aria-label="More options"
aria-expanded={moreOpen}
onclick={() => (moreOpen = !moreOpen)}
class="hidden items-center rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap text-mlq-text @max-3xl/composer:inline-flex"
>
</button>
<span class="flex-1"></span>
{#if streaming}
<button
Expand Down
46 changes: 45 additions & 1 deletion src/lib/components/Composer.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import { render, screen, fireEvent, within } from '@testing-library/svelte';

vi.mock('$app/state', () => ({ page: { data: { user: null } } }));

Expand All @@ -9,6 +9,8 @@ import Composer from './Composer.svelte';
import { createPromptLibrary } from '$lib/prompts/promptLibrary.svelte';
import { createSkillAttach } from '$lib/skills/attach.svelte';
import { createFileAttach } from '$lib/files/fileAttach.svelte';
import { createStickySkills } from '$lib/skills/sticky.svelte';
import { createEnhance } from '$lib/enhance/enhance.svelte';

describe('Composer matter picker', () => {
it('shows the matter picker only when matters are provided', async () => {
Expand Down Expand Up @@ -95,3 +97,45 @@ describe('Composer file attach', () => {
expect(screen.getByRole('button', { name: 'Send' })).toBeDisabled();
});
});

describe('Composer responsive bottom bar', () => {
it('renders the keep-skills toggle as a compact single-line label', () => {
const sticky = createStickySkills();
render(Composer, { props: { value: '', sticky } as never });
const toggle = screen.getByTestId('sticky-toggle');
expect(toggle.className).toContain('whitespace-nowrap');
const label = within(toggle).getByText('Skills stay on');
expect(label.className).toContain('whitespace-nowrap');
});

it('shows a compact count when the sticky set is on', async () => {
const sticky = createStickySkills();
sticky.toggle(['nda-review', 'comms-log']);
render(Composer, { props: { value: '', sticky } as never });
const count = screen.getByTestId('sticky-count');
expect(count).toHaveTextContent('· 2');
expect(count.className).toContain('whitespace-nowrap');
});

it('groups the trust pill, Prompts and Enhance into the collapsible overflow group', () => {
const lib = createPromptLibrary();
lib.seed([{ id: 'p1', name: 'Risk', prompt_text: 'X', tags: [] }] as never);
const enhance = createEnhance(null, () => []);
render(Composer, { props: { value: '', promptLibrary: lib, enhance } as never });
const overflow = screen.getByTestId('composer-overflow');
expect(within(overflow).getByRole('button', { name: /prompts/i })).toBeInTheDocument();
expect(within(overflow).getByTestId('enhance-button')).toBeInTheDocument();
});

it('exposes a "More options" toggle that opens and closes the overflow popover', async () => {
const lib = createPromptLibrary();
render(Composer, { props: { value: '', promptLibrary: lib } as never });
const more = screen.getByTestId('composer-more');
expect(more).toHaveAttribute('aria-label', 'More options');
expect(more).toHaveAttribute('aria-expanded', 'false');
await fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'true');
await fireEvent.click(more);
expect(more).toHaveAttribute('aria-expanded', 'false');
});
});
13 changes: 8 additions & 5 deletions src/lib/components/ModelPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,22 @@
</script>

<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={root} class="relative" {onkeydown}>
<div bind:this={root} class="relative min-w-0" {onkeydown}>
<button
type="button"
data-testid="model-picker"
aria-haspopup="listbox"
aria-expanded={open}
aria-label="Model"
onclick={() => (open = !open)}
class="inline-flex items-center gap-1.5 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs text-mlq-text"
class="inline-flex max-w-56 min-w-0 items-center gap-1.5 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap text-mlq-text"
>
<span class="font-medium">{selected}</span>
{#if current?.label}<span class="text-mlq-muted">· {current.label}</span>{/if}
<ChevronDown size={13} />
<span class="min-w-0 truncate">
<span class="font-medium">{selected}</span>{#if current?.label}<span class="text-mlq-muted">
· {current.label}</span
>{/if}
</span>
<ChevronDown class="shrink-0" size={13} />
</button>

{#if open}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/SkillAttach.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
aria-expanded={open}
aria-label="Attach skill"
onclick={toggle}
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs text-mlq-text"
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap text-mlq-text"
>
<Plus size={13} /> Skill
</button>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/matters/MatterPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
aria-expanded={open}
aria-label={current ? `Matter: ${current.name}` : 'Choose matter'}
onclick={() => (open = !open)}
class="inline-flex max-w-[180px] items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs {current
class="inline-flex max-w-[180px] items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap {current
? 'text-mlq-text'
: 'text-mlq-muted'}"
>
Expand Down
2 changes: 1 addition & 1 deletion src/lib/prompts/PromptPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
aria-expanded={open}
aria-label="Prompts"
onclick={toggle}
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs text-mlq-text"
class="inline-flex items-center gap-1 rounded-mlq-control border border-mlq-subtle px-2.5 py-1 text-xs whitespace-nowrap text-mlq-text"
><BookMarked size={13} /> Prompts</button
>

Expand Down
8 changes: 4 additions & 4 deletions src/routes/(app)/about/assistant/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,13 @@
to a single message; each is sent alongside your prompt.
</p>

<h2 class="mt-6 mb-2 text-sm font-medium tracking-wide text-mlq-muted uppercase">Keep skills on</h2>
<h2 class="mt-6 mb-2 text-sm font-medium tracking-wide text-mlq-muted uppercase">Skills stay on</h2>

<p class="mb-3 max-w-prose text-sm leading-relaxed text-mlq-text">
Once you have a skill attached to a message, the <strong>Keep skills on</strong> toggle in the
Once you have a skill attached to a message, the <strong>Skills stay on</strong> toggle in the
composer toolbar lets you carry that skill — and any others attached — into every follow-up turn
of the same chat without re-attaching. When active, a quiet <strong>"Keeping N on"</strong>
indicator appears alongside the toggle; hover it to see the skill names that are being carried forward.
of the same chat without re-attaching. When active, a quiet <strong>"· N"</strong> count appears inside
the toggle; hover it to see the skill names that are being carried forward.
</p>

<p class="mb-3 max-w-prose text-sm leading-relaxed text-mlq-text">
Expand Down