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
91 changes: 80 additions & 11 deletions src/lib/skills/SkillInputForm.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { Paperclip } from '@lucide/svelte';
import type { SkillInputDef } from './types';

let {
Expand Down Expand Up @@ -28,19 +29,41 @@
: typeof v === 'number'
? Number.isFinite(v)
: v != null;

/** "perspective" / "doc_type" → "Perspective" / "Doc Type" — the visible field label. */
const fieldLabel = (name: string) =>
name.replace(/[_-]+/g, ' ').replace(/(^|\s)\S/g, (c) => c.toUpperCase());

/** Many corpus skills encode their options in the description as "a | b | c. …" —
* a pipe-separated list before the first period. Parse that into select options so
* users pick instead of guessing free text; null ⇒ not an enum-shaped description. */
function enumFromDescription(def: SkillInputDef): string[] | null {
if (def.type === 'enum' || def.type === 'boolean' || def.type === 'integer') return null;
const head = (def.description ?? '').split('.', 1)[0];
if (!head.includes('|')) return null;
const opts = head
.split('|')
.map((s) => s.trim())
.filter(Boolean);
return opts.length >= 2 ? opts : null;
}

/** Help text shown under the label: the full description, minus the option list
* when it was parsed into a select (no point showing "a | b | c" twice). */
function helpText(def: SkillInputDef): string {
const desc = (def.description ?? '').trim();
if (!desc) return '';
if (!enumFromDescription(def)) return desc;
const dot = desc.indexOf('.');
return dot === -1 ? '' : desc.slice(dot + 1).trim();
}
</script>

<div class="rounded-mlq-control border border-mlq-subtle bg-mlq-surface/50 p-2">
<div class="mb-1 text-xs font-medium text-mlq-muted">{skillTitle} — inputs</div>

{#each req as def (def.name)}
<label class="mb-1.5 flex flex-col gap-0.5">
<span class="text-xs text-mlq-muted">
{def.description || def.name}
{#if !provided(values[def.name])}<span class="text-mlq-error"> ⚠ required</span>{/if}
</span>
{@render widget(def)}
</label>
{@render field(def, true)}
{/each}

{#if opt.length}
Expand All @@ -54,15 +77,50 @@
</button>
{#if showOptional}
{#each opt as def (def.name)}
<label class="mt-1 mb-1.5 flex flex-col gap-0.5">
<span class="text-xs text-mlq-muted">{def.description || def.name}</span>
{@render widget(def)}
</label>
{@render field(def, false)}
{/each}
{/if}
{/if}
</div>

{#snippet labelLine(def: SkillInputDef, isRequired: boolean)}
<span class="text-xs text-mlq-text">
<span class="font-medium">{fieldLabel(def.name)}</span>{#if isRequired}<span
class="text-mlq-error"
title="Required">*</span
>{/if}
{#if isRequired && def.type !== 'document' && !provided(values[def.name])}<span
class="text-mlq-error"
>
⚠ required</span
>{/if}
</span>
{#if helpText(def)}
<span class="text-[11px] leading-snug text-mlq-muted">{helpText(def)}</span>
{/if}
{/snippet}

{#snippet field(def: SkillInputDef, isRequired: boolean)}
{#if def.type === 'document'}
<!-- Documents travel as message attachments, never as a typed value — no text box. -->
<div class="mb-1.5 flex flex-col gap-0.5">
{@render labelLine(def, isRequired)}
<span
data-testid={`doc-hint-${def.name}`}
class="inline-flex w-fit items-center gap-1 rounded-full border border-mlq-subtle bg-mlq-subtle/40 px-2 py-0.5 text-xs text-mlq-muted"
>
<Paperclip size={11} aria-hidden="true" />
Attach the document to the message — the clip button
</span>
</div>
{:else}
<label class="mb-1.5 flex flex-col gap-0.5">
{@render labelLine(def, isRequired)}
{@render widget(def)}
</label>
{/if}
{/snippet}

{#snippet widget(def: SkillInputDef)}
{#if def.type === 'enum' && def.enum}
<select
Expand Down Expand Up @@ -94,6 +152,17 @@
)}
class="rounded-mlq-control border border-mlq-subtle bg-transparent px-2 py-1 text-sm text-mlq-text outline-none focus:border-mlq-workflow"
/>
{:else if enumFromDescription(def)}
<select
aria-label={def.name}
value={(values[def.name] as string) ?? ''}
onchange={(e) =>
onchange(def.name, e.currentTarget.value === '' ? undefined : e.currentTarget.value)}
class="rounded-mlq-control border border-mlq-subtle bg-transparent px-2 py-1 text-sm text-mlq-text outline-none focus:border-mlq-workflow"
>
<option value=""></option>
{#each enumFromDescription(def) ?? [] as o (o)}<option value={o}>{o}</option>{/each}
</select>
{:else}
<input
type="text"
Expand Down
117 changes: 117 additions & 0 deletions src/lib/skills/SkillInputForm.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,123 @@ describe('SkillInputForm', () => {
expect(screen.queryByLabelText('doc')).toBeNull();
});

it('renders the input name as a title-cased label with a required marker and the description as help text', () => {
render(SkillInputForm, {
props: {
skillTitle: 'NDA',
required: [
def({
name: 'perspective',
type: 'text',
required: true,
description: 'The vantage point to review from'
})
],
optional: [],
values: {},
onchange: vi.fn()
}
});
expect(screen.getByText('Perspective')).toBeInTheDocument();
expect(screen.getByText('*')).toBeInTheDocument();
expect(screen.getByText('The vantage point to review from')).toBeInTheDocument();
// The raw name still drives the control's accessible name.
expect(screen.getByLabelText('perspective')).toBeInTheDocument();
});

it('does not render a required marker for optional inputs', async () => {
render(SkillInputForm, {
props: {
skillTitle: 'NDA',
required: [],
optional: [def({ name: 'extra_notes', type: 'text' })],
values: {},
onchange: vi.fn()
}
});
await fireEvent.click(screen.getByRole('button', { name: /optional \(1\)/i }));
expect(screen.getByText('Extra Notes')).toBeInTheDocument();
expect(screen.queryByText('*')).toBeNull();
});

it('renders a select with parsed options for a pipe-enum description', async () => {
const onchange = vi.fn();
render(SkillInputForm, {
props: {
skillTitle: 'NDA',
required: [
def({
name: 'depth',
type: 'text',
required: true,
description: 'quick | standard | deep. How thorough the review should be.'
})
],
optional: [],
values: {},
onchange
}
});
const select = screen.getByLabelText('depth') as HTMLSelectElement;
expect(select.tagName).toBe('SELECT');
expect(screen.getByRole('option', { name: 'quick' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'standard' })).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'deep' })).toBeInTheDocument();
// An empty choice exists so the value can be left unset.
expect((select.options[0] as HTMLOptionElement).value).toBe('');
// The option list is not repeated in the help text; the remainder is.
expect(screen.getByText('How thorough the review should be.')).toBeInTheDocument();
expect(screen.queryByText(/quick \| standard \| deep/)).toBeNull();
await fireEvent.change(select, { target: { value: 'deep' } });
expect(onchange).toHaveBeenCalledWith('depth', 'deep');
});

it('keeps a free-text input when the description has no pipe-enum head', () => {
render(SkillInputForm, {
props: {
skillTitle: 'NDA',
required: [
def({
name: 'focus',
type: 'text',
required: true,
description: 'What to focus on. E.g. liability or IP.'
})
],
optional: [],
values: {},
onchange: vi.fn()
}
});
expect((screen.getByLabelText('focus') as HTMLInputElement).tagName).toBe('INPUT');
});

it('renders an attach hint instead of a text box for a document input', () => {
render(SkillInputForm, {
props: {
skillTitle: 'NDA',
required: [
def({
name: 'contract',
type: 'document',
required: true,
description: 'The contract to review'
})
],
optional: [],
values: {},
onchange: vi.fn()
}
});
expect(screen.getByText('Contract')).toBeInTheDocument();
expect(screen.queryByRole('textbox')).toBeNull();
expect(screen.getByTestId('doc-hint-contract')).toHaveTextContent(
'Attach the document to the message — the clip button'
);
// Not fillable inline, so it must not warn as missing.
expect(screen.queryByText(/⚠ required/)).toBeNull();
});

it('pre-fills a text input from values', () => {
render(SkillInputForm, {
props: {
Expand Down
11 changes: 11 additions & 0 deletions src/lib/skills/attach.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,17 @@ describe('createSkillAttach', () => {
expect(s.allRequiredFilled).toBe(true);
});

it('does not let a required document-type input block sending (documents travel as attachments)', async () => {
const s = createSkillAttach();
const f = vi
.fn()
.mockImplementation(() =>
inputsRes([{ name: 'contract', type: 'document', required: true }])
);
await s.attach(NDA, f);
expect(s.allRequiredFilled).toBe(true);
});

it('blocks sending while a skill is still loading its inputs', () => {
const s = createSkillAttach();
let resolveFetch: (r: Response) => void = () => {};
Expand Down
8 changes: 5 additions & 3 deletions src/lib/skills/attach.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ export function createSkillAttach() {
return out;
},
/** True when no skill is still loading inputs and every attached skill's required
* (non-file) inputs are provided. File-type inputs are never rendered (separate
* channel), so they must not block sending. */
* (non-file) inputs are provided. File- and document-type inputs are never rendered
* as typed fields (they travel as message attachments), so they must not block sending. */
get allRequiredFilled() {
return attached.every(
(a) =>
!a.inputsLoading &&
a.required.filter((d) => d.type !== 'file').every((d) => provided(a.values[d.name]))
a.required
.filter((d) => d.type !== 'file' && d.type !== 'document')
.every((d) => provided(a.values[d.name]))
);
},
open: (fetchFn: typeof fetch = fetch) => fetchResults('', fetchFn),
Expand Down