diff --git a/src/lib/skills/SkillInputForm.svelte b/src/lib/skills/SkillInputForm.svelte
index 5a28b377..a8f5e06e 100644
--- a/src/lib/skills/SkillInputForm.svelte
+++ b/src/lib/skills/SkillInputForm.svelte
@@ -1,4 +1,5 @@
{skillTitle} — inputs
{#each req as def (def.name)}
-
+ {@render field(def, true)}
{/each}
{#if opt.length}
@@ -54,15 +77,50 @@
{#if showOptional}
{#each opt as def (def.name)}
-
+ {@render field(def, false)}
{/each}
{/if}
{/if}
+{#snippet labelLine(def: SkillInputDef, isRequired: boolean)}
+
+ {fieldLabel(def.name)}{#if isRequired}*{/if}
+ {#if isRequired && def.type !== 'document' && !provided(values[def.name])}
+ ⚠ required{/if}
+
+ {#if helpText(def)}
+ {helpText(def)}
+ {/if}
+{/snippet}
+
+{#snippet field(def: SkillInputDef, isRequired: boolean)}
+ {#if def.type === 'document'}
+
+
+ {@render labelLine(def, isRequired)}
+
+
+ Attach the document to the message — the clip button
+
+
+ {:else}
+
+ {/if}
+{/snippet}
+
{#snippet widget(def: SkillInputDef)}
{#if def.type === 'enum' && def.enum}
+ {:else if enumFromDescription(def)}
+
{:else}
{
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: {
diff --git a/src/lib/skills/attach.svelte.test.ts b/src/lib/skills/attach.svelte.test.ts
index 22f9d634..5f753aa4 100644
--- a/src/lib/skills/attach.svelte.test.ts
+++ b/src/lib/skills/attach.svelte.test.ts
@@ -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 = () => {};
diff --git a/src/lib/skills/attach.svelte.ts b/src/lib/skills/attach.svelte.ts
index 49290bb7..763b0a29 100644
--- a/src/lib/skills/attach.svelte.ts
+++ b/src/lib/skills/attach.svelte.ts
@@ -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),