From e799be352663d259a3bb71906cc75bc34159e3a0 Mon Sep 17 00:00:00 2001 From: mattshax Date: Fri, 25 Sep 2026 13:37:58 +0000 Subject: [PATCH] fix(ui): declare cascade layer order; the banner reason survives truncation ai-chat 0.5 and ui 0.16 nest their rules in the pw-ui and pw-ai-chat layers, except that the compiled ui stylesheet also emits a top-level `base` layer giving every input and textarea a border, padding, and background. Declared after the package layers, it outranked their utilities: the chat composer grew a second border inside its frame and the conversation search lost the left padding that clears its icon. A stylesheet imported first now names `base` before the package layers, making it the lowest. The Studio's own inputs carry their own classes and are unaffected. Measured after the change: composer textarea border 0 and transparent, search input padding 32px. The availability banner quoted raw JSON because the probe cut the provider's error to 200 characters before the reason was extracted, splitting the nested message. The probe now keeps 2,000 characters and the extractor also accepts a message cut off mid-string. --- server/src/chat/gateway.ts | 8 +++++--- server/test/providerProbe.test.mjs | 7 +++++++ web/src/layers.css | 13 +++++++++++++ web/src/main.tsx | 1 + 4 files changed, 26 insertions(+), 3 deletions(-) create mode 100644 web/src/layers.css diff --git a/server/src/chat/gateway.ts b/server/src/chat/gateway.ts index 31e8a56..3ce13dc 100644 --- a/server/src/chat/gateway.ts +++ b/server/src/chat/gateway.ts @@ -214,7 +214,9 @@ export function probeableProvider(prefix: string): boolean { return PROBE_PATTER export function providerReason(text: string): string { let t = text for (let i = 0; i < 3; i++) { - const m = /"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(t) + // A complete string first; failing that, a string cut off by an + // upstream truncation, taken to the end of what arrived. + const m = /"message"\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(t) ?? /"message"\s*:\s*"((?:[^"\\]|\\.)*)$/.exec(t) if (!m) break t = m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') } @@ -305,7 +307,7 @@ export async function probeProvider(prefix: string, sampleModelId: string, key?: const unlockUrl = extractUnlockUrl(r.text) const credentialish = unlockUrl !== null || r.status === 401 || r.status === 403 || /key locked|locked|unauthorized|api key/i.test(r.text) if (credentialish) { - v = { ok: false, kind: 'locked', unlockUrl, message: r.text.slice(0, 200) } + v = { ok: false, kind: 'locked', unlockUrl, message: r.text.slice(0, 2000) } } else { // The gateway masks a provider's own 401 into a generic 400 // (parallelworks/core#19405), so the lock this probe exists for @@ -314,7 +316,7 @@ export async function probeProvider(prefix: string, sampleModelId: string, key?: // for real, whatever the wording; a healthy serve essentially // never fails it once, let alone twice. r = await ping() - if (!r.ok) v = { ok: false, kind: 'unavailable', unlockUrl: extractUnlockUrl(r.text), message: r.text.slice(0, 200) } + if (!r.ok) v = { ok: false, kind: 'unavailable', unlockUrl: extractUnlockUrl(r.text), message: r.text.slice(0, 2000) } } } } catch { /* unreachable is not proof of a lock; leave ok */ } diff --git a/server/test/providerProbe.test.mjs b/server/test/providerProbe.test.mjs index 8b70404..71dfb7a 100644 --- a/server/test/providerProbe.test.mjs +++ b/server/test/providerProbe.test.mjs @@ -115,3 +115,10 @@ test('a marked model carries the reason to the client', async () => { assert.equal(r.impaired[0].reason, 'model not served') assert.equal(r.impaired[0].locked, false) }) + +test('the reason survives a body truncated mid-message', async () => { + const { providerReason } = await import('../dist/chat/gateway.js') + // Exactly what the banner showed: the outer message cut at 200 characters. + const cut = '{"error":{"message":"received error while streaming: {\\"message\\": \\"Requested model is not available and no compliant same-model variant was found.\\", \\"type\\": \\"invalid_reque' + assert.equal(providerReason(cut), 'Requested model is not available and no compliant same-model variant was found.') +}) diff --git a/web/src/layers.css b/web/src/layers.css new file mode 100644 index 0000000..132631a --- /dev/null +++ b/web/src/layers.css @@ -0,0 +1,13 @@ +/* + * Cascade layer order, declared before any stylesheet loads. + * + * The package stylesheets nest their rules in the pw-ui and pw-ai-chat + * layers, except one: the compiled ui stylesheet (0.16) also emits a + * top-level `base` layer that gives every input and textarea a border, + * padding, and background. Declared after the package layers, it + * outranked their utilities, so the chat composer grew a second border + * and the conversation search lost the padding that clears its icon. + * Naming `base` first makes it the lowest layer. The Studio's own inputs + * all carry their own classes and are unaffected. + */ +@layer base, pw-ui, pw-ai-chat; diff --git a/web/src/main.tsx b/web/src/main.tsx index b88a873..5362867 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -9,6 +9,7 @@ import '@fontsource-variable/geist-mono' // equal specificity, so whichever loads last wins. The chat package must come // second: its responsive variants (a two-column starter grid, for one) were // being overridden by the UI package's base utilities. Ours loads last of all. +import './layers.css' import '@parallelworks/ui/styles.css' import '@parallelworks/ui/theme.css' import '@parallelworks/ai-chat/styles.css'