diff --git a/public/css/assistant.css b/public/css/assistant.css index 2d7783b2..1e67952c 100644 --- a/public/css/assistant.css +++ b/public/css/assistant.css @@ -322,6 +322,11 @@ body.assistant-workspace #assistant-chat-view { min-height: 0; } .assistant-gallery-item img { width:100%; height:56px; object-fit:cover; display:block; } .assistant-create-image-label { display:block; font-size:12px; font-weight:700; color:var(--g500); margin:10px 0 4px; } #create-image-description, #create-image-chat { width:100%; border:1.5px solid var(--g300); border-radius:10px; padding:8px 10px; font-size:13px; } +/* The closed select shows as much of a chat title as the width allows, so a + phone shows less than a desktop rather than everything sharing one cut. */ +#create-image-chat { max-width:100%; text-overflow:ellipsis; } +.assistant-takehome-modal .modal-content { width:min(760px, 94vw); } +@media (max-width:640px) { .assistant-takehome-modal .modal-content { width:96vw; } } .assistant-create-image-actions { display:flex; justify-content:flex-end; margin-top:12px; } /* Open WebUI-style composer: a prominent input box with a slim action row below */ diff --git a/public/css/styles.css b/public/css/styles.css index a3c26f43..34598070 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -1106,7 +1106,29 @@ textarea.full-input{resize:vertical;} animation-delay:var(--ext-stagger,0ms); transition:transform .15s ease, box-shadow .2s ease, border-color .15s ease; will-change:transform; + /* Every card is the same box regardless of how long its name or number is, so + a grid of them reads as rows rather than a ragged mosaic. */ + display:flex; gap:10px; align-items:flex-start; + background:white; border:1px solid var(--g200); border-radius:10px; padding:12px 14px; + min-height:96px; } +.ext-card-body{ flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:4px; } +.ext-card-head{ display:flex; align-items:center; gap:8px; min-width:0; } +.ext-card-head i{ font-size:13px; flex-shrink:0; } +/* The name is the scan target, so it leads and gets the weight. */ +.ext-name{ flex:1 1 auto; min-width:0; font-size:14px; font-weight:700; color:var(--g900); + letter-spacing:-.012em; line-height:1.3; overflow-wrap:anywhere; } +.ext-badge{ flex-shrink:0; font-size:9.5px; font-weight:700; padding:2px 6px; border-radius:5px; + text-transform:uppercase; letter-spacing:.05em; } +/* The number is the payload. Tabular figures and wrapping only at the + points between groups, never inside a run of digits. */ +.ext-number{ align-self:flex-start; max-width:100%; font-family:ui-monospace,SFMono-Regular,monospace; + font-size:16px; font-weight:700; letter-spacing:.3px; line-height:1.35; text-align:left; + background:none; border:0; padding:0; cursor:pointer; word-break:normal; overflow-wrap:break-word; + font-feature-settings:'tnum' 1; } +.ext-notes{ font-size:11.5px; color:var(--g500); line-height:1.45; overflow-wrap:anywhere; } +.ext-card-actions{ display:flex; gap:2px; flex-shrink:0; } +@media(max-width:640px){ .ext-card{ min-height:0; } .ext-number{ font-size:15px; } } .ext-card:hover{ border-color:var(--g300)!important; box-shadow:0 4px 12px -2px rgba(0,0,0,.08), 0 2px 4px -1px rgba(0,0,0,.04); diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 7927205c..0b7ca022 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -2349,9 +2349,25 @@ import { return row; } + // A hard slice(0, 60) cut titles mid-word — "Rickets Radiographic Fea". The + // server already allows 160, so keep whole words and use that budget; every + // place that displays a title decides its own visible length from the width it + // actually has, rather than inheriting one arbitrary cut made at save time. function deriveChatTitle() { var first = messages.find(function (m) { return m.role === 'user' && m.content; }); - return String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim().slice(0, 60); + return truncateOnWord(String(first && first.content || 'Clinical assistant chat').replace(/\s+/g, ' ').trim(), 160); + } + + // Cuts at a word boundary, and only adds an ellipsis when something was + // actually dropped. + function truncateOnWord(text, limit) { + text = String(text || '').trim(); + if (text.length <= limit) return text; + var cut = text.slice(0, limit); + var lastSpace = cut.lastIndexOf(' '); + // Fall back to the hard cut for a single very long token. + if (lastSpace > limit * 0.5) cut = cut.slice(0, lastSpace); + return cut.replace(/[\s.,;:!?-]+$/, '') + '…'; } function conversationSize(question) { diff --git a/public/js/extensions.js b/public/js/extensions.js index 840a2264..6b10e71c 100644 --- a/public/js/extensions.js +++ b/public/js/extensions.js @@ -198,26 +198,22 @@ } var html = ''; - // Card layout: - // - uniform 1px border (no left stripe — too visually bulky) - // - phone/pager icon + colored number = type signal - // - number is click-to-copy with a green flash (data-copy) - // - hover lift + shadow grow via .ext-card:hover in styles.css - // - staggered fade-in (--ext-stagger CSS var consumed by keyframes) - html += '
'; - html += '
'; - html += '
'; - html += ' '; - html += ' '; - html += ' ' + typeLabel + ''; + // Card layout, in scan order: the NAME is what the eye hunts for in a list of + // fifty ("Blood Bank"), the number is the payload you then copy. The number + // led before, at 20px with word-break:break-all, so a multi-line entry wrapped + // mid-digit — "5616/3764/56 19" — which is unreadable and unsafe to dial. + html += '
'; + html += '
'; + html += '
'; + html += ' '; + html += ' ' + esc(x.name) + ''; + html += ' ' + typeLabel + ''; html += '
'; - // Name: scan-target. Larger, bolder, darker, slightly tighter - // letter spacing for that "headline" feel. The number is the action; - // the name is what your eye locks onto when scrolling a list of 50. - html += '
' + esc(x.name) + '
'; - if (x.notes) html += '
' + esc(x.notes) + '
'; + // Numbers wrap between their separators, never inside a group of digits. + html += ' '; + if (x.notes) html += '
' + esc(x.notes) + '
'; html += '
'; - html += '
' + actions + '
'; + html += '
' + actions + '
'; html += '
'; return html; } diff --git a/test/assistant-autosave.test.js b/test/assistant-autosave.test.js index 5d8e78a6..adee1347 100644 --- a/test/assistant-autosave.test.js +++ b/test/assistant-autosave.test.js @@ -69,7 +69,7 @@ async function ask(app, text) { await app.context.onAsk(); } -test('autosave debounces completed turns into one save with a 60-character derived title, then updates in place', async t => { +test('autosave debounces completed turns into one save with a word-boundary derived title, then updates in place', async t => { const app = ui(t); const c = app.context; const question = 'Synthetic pediatric asthma management question that is deliberately longer than sixty characters to verify title derivation'; @@ -79,7 +79,11 @@ test('autosave debounces completed turns into one save with a 60-character deriv assert.equal(app.timers.delay(), '800'); await app.timers.flush(); assert.equal(app.saves.length, 1); - assert.equal(app.saves[0].title, question.slice(0, 60), 'title derived from the first user message'); + // A hard 60-character slice cut titles mid-word. The server allows 160, so the + // title keeps whole words up to that and each view decides its own visible + // length from the width it actually has. + assert.equal(app.saves[0].title, question, 'the whole question fits within the server limit'); + assert.doesNotMatch(app.saves[0].title, /\S…$/, 'and nothing is cut mid-word'); assert.equal(app.saves[0].id, undefined, 'first autosave creates the chat'); assert.equal(app.saves[0].generatedImage, undefined); assert.deepEqual(app.saves[0].messages.map(m => [m.role, m.content]), [ diff --git a/test/assistant-image-done.test.js b/test/assistant-image-done.test.js index 574a4533..23051ef9 100644 --- a/test/assistant-image-done.test.js +++ b/test/assistant-image-done.test.js @@ -41,3 +41,29 @@ test('the done handler still reports success when a later step fails', () => { // an error state. assert.match(src, /catch \(doneError\) \{\s*\n\s*if \(typeof hooks\.onError === 'function'\) hooks\.onError\('Image ready: '/); }); + +test('extension cards put the name first and never wrap a number mid-digit', () => { + const js = read('public/js/extensions.js'); + const css = read('public/css/styles.css'); + // The number led at 20px with word-break:break-all, so "5616/3764/5619" + // wrapped as "5616/3764/56 19" — unreadable, and unsafe to dial. + // Scope to the card renderer; other handlers reference these classes too. + const card = js.slice(js.indexOf("html += '
/); + assert.ok(card.indexOf('ext-name') < card.indexOf('ext-number'), 'the name is the scan target and leads'); + assert.match(card, //, 'numbers may only break between groups'); + assert.doesNotMatch(css, /\.ext-number\{[^}]*word-break:break-all/, 'never inside a run of digits'); + assert.match(css, /\.ext-number\{[^}]*word-break:normal/); + // Uniform boxes so a grid reads as rows rather than a ragged mosaic. + assert.match(css, /\.ext-card\{[\s\S]*?min-height:96px;/); + assert.match(css, /@media\(max-width:640px\)\{ \.ext-card\{ min-height:0; \}/, 'except on a phone'); +}); + +test('a saved chat title keeps whole words', () => { + const src = read('public/js/clinicalAssistant.js'); + // slice(0, 60) cut mid-word, and every view inherited that one arbitrary cut. + assert.doesNotMatch(src, /function deriveChatTitle\(\)[\s\S]{0,240}?slice\(0, 60\)/); + assert.match(src, /truncateOnWord\(String\(first && first\.content/); + assert.match(src, /if \(lastSpace > limit \* 0\.5\) cut = cut\.slice\(0, lastSpace\);/, + 'a single very long token still falls back to a hard cut'); +});