fix(ui-models): three faults the running app surfaced

**A hand-declared route must not offer a reasoning effort.** The earlier
commit read the create card's missing control as drift and added one. It
is the other way round: such a model has no reasoning capability — pi-ai's
installed catalog is what supplies one, and it ships nothing under the
route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every
model on it and the whole provider drops out of the picker. Verified
against the adapter, not inferred. The create card no longer offers it and
the editor withholds it on the directory's `declared` bit, which is the
real bug: that control has always been wrong for these routes.

**A blocked composer locked the way out of the block.** Reusing the
no-workspace inert posture disabled the model seat along with everything
else, so the bar asked for a model while preventing the one control that
picks one. A block now rides its own `blocked` owner prop: the textarea,
send, commands, plan seat, and access chip all lock, and the model seat
alone stays live.

**A Provider ID could derive an illegal credential reference.** The card
accepted a digit-leading id, whose derived `123_API_KEY` then failed at
the credential seam with a raw regular expression the user cannot act on.
The id must now start with a letter, and a test pins the relation between
the two rules rather than the regex.
This commit is contained in:
Yichen Jiang
2026-08-07 16:45:50 +08:00
parent d03d3ab70b
commit 5a90eb41fb
21 changed files with 179 additions and 91 deletions

View File

@@ -120,9 +120,14 @@ function mount(
const stop = vi.fn()
const open = vi.fn()
const slotCalls: string[] = []
/** Owner share handed to the two composer tool-row seats, per render. */
const seatOwners: { key: string; owner: unknown }[] = []
let pickerOwner: unknown
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
slotCalls.push(key)
if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
seatOwners.push({ key, owner })
}
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session.header') {
return (
@@ -200,7 +205,12 @@ function mount(
stop={stop}
command={() => Promise.resolve(true)}
t={t}
renderSlot={(() => null) as InputBarProps['renderSlot']}
renderSlot={((key: string, seatOwner: object) => {
// The bar's own seats: recorded so a case can assert what share
// each tool-row control received.
seatOwners.push({ key, owner: seatOwner })
return null
}) as InputBarProps['renderSlot']}
{...bar}
/>
)
@@ -236,7 +246,7 @@ function mount(
}
const view = render(<ConversationRoot {...props} />)
return {
view, chat, sink, retargetWorkspace, session, slotCalls, open,
view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
pickerOwner: () => pickerOwner,
rerender: () => { view.rerender(<ConversationRoot {...props} />) },
}
@@ -262,6 +272,13 @@ describe('ConversationRoot resident composer', () => {
expect(box.placeholder).toBe('select a model first')
fireEvent.keyDown(box, { key: 'Enter' })
expect(b.sink).not.toHaveBeenCalled()
// The model seat stays live. Locking it too would leave the composer
// asking for the one thing it prevents — every block this contract has is
// cleared by choosing a model.
const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
expect(seat('conversation.input.model')).toEqual({ locked: false })
expect(seat('conversation.input.plan')).toEqual({ locked: true })
})
it('lets the no-workspace posture win over a block', () => {