fix: image Done state, chat titles that keep whole words, and readable extension cards
The Create image popup showed "Generating image…" forever even after the job finished. The status poll called fetchAssistantImageJob, which was never imported, so every tick threw ReferenceError — and the catch treated that like a transient network failure and rescheduled, permanently. The import is added, a test now asserts that every api.js function the assistant calls is actually imported, and the poll distinguishes a programming error (surface it) from a transient one (retry, but not forever). Chat titles were hard-cut at 60 characters mid-word, so "Rickets Radiographic Fea" was all the Create image picker could ever show. The server already allows 160, so titles now keep whole words up to that, and each view decides its own visible length from the width it actually has rather than inheriting one cut made at save time. A single very long token still falls back to a hard cut. Extension cards led with the number at 20px with word-break:break-all, so "5616/3764/5619" wrapped as "5616/3764/56 19" — unreadable, and unsafe to dial from. The name leads now, since that is what the eye hunts for in a list of fifty; the number follows in tabular figures and may only break between groups, never inside a run of digits. Cards share a minimum height so a grid reads as rows rather than a ragged mosaic. The collapsed rail's brand kept its expanded margin-right:auto, which pushed the stethoscope off the axis the two buttons sat on. Every child of the collapsed head is now the same centred fixed-size box. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018e1PLqrKgAM9jQhFKRnbLd
This commit is contained in:
parent
2f7233f317
commit
500fe2c12a
6 changed files with 90 additions and 21 deletions
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 <wbr>
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 += '<div class="ext-card" style="background:' + bg + ';border:1px solid ' + border + ';border-radius:10px;padding:12px 14px;display:flex;gap:12px;align-items:flex-start;--ext-stagger:' + stagger + 'ms;">';
|
||||
html += ' <div style="flex:1;min-width:0;">';
|
||||
html += ' <div style="display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;min-width:0;">';
|
||||
html += ' <i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:14px;align-self:center;flex-shrink:0;"></i>';
|
||||
html += ' <button type="button" class="ext-number" data-copy="' + esc(x.number) + '" title="Click to copy" style="font-size:20px;font-weight:700;color:' + typeColor + ';font-family:ui-monospace,SFMono-Regular,monospace;letter-spacing:0.5px;line-height:1.2;word-break:break-all;min-width:0;background:none;border:0;padding:0;cursor:pointer;font-feature-settings:\'tnum\' 1;text-align:left;">' + esc(x.number) + '</button>';
|
||||
html += ' <span style="font-size:10px;font-weight:600;padding:2px 7px;border-radius:6px;background:' + typeColor + '15;color:' + typeColor + ';text-transform:uppercase;letter-spacing:0.6px;flex-shrink:0;">' + typeLabel + '</span>';
|
||||
// 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 += '<div class="ext-card" style="--ext-stagger:' + stagger + 'ms;">';
|
||||
html += ' <div class="ext-card-body">';
|
||||
html += ' <div class="ext-card-head">';
|
||||
html += ' <i class="fas ' + typeIcon + '" style="color:' + typeColor + ';"></i>';
|
||||
html += ' <span class="ext-name">' + esc(x.name) + '</span>';
|
||||
html += ' <span class="ext-badge" style="background:' + typeColor + '15;color:' + typeColor + ';">' + typeLabel + '</span>';
|
||||
html += ' </div>';
|
||||
// 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 += ' <div class="ext-name" style="font-size:14.5px;color:var(--g900);margin-top:6px;word-break:break-word;font-weight:700;letter-spacing:-0.012em;line-height:1.35;">' + esc(x.name) + '</div>';
|
||||
if (x.notes) html += ' <div style="font-size:11.5px;color:var(--g500);margin-top:3px;word-break:break-word;line-height:1.45;">' + esc(x.notes) + '</div>';
|
||||
// Numbers wrap between their separators, never inside a group of digits.
|
||||
html += ' <button type="button" class="ext-number" data-copy="' + esc(x.number) + '" title="Click to copy" style="color:' + typeColor + ';">' + esc(x.number).replace(/\//g, '<wbr>/') + '</button>';
|
||||
if (x.notes) html += ' <div class="ext-notes">' + esc(x.notes) + '</div>';
|
||||
html += ' </div>';
|
||||
html += ' <div style="display:flex;gap:4px;flex-shrink:0;align-self:flex-start;">' + actions + '</div>';
|
||||
html += ' <div class="ext-card-actions">' + actions + '</div>';
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]), [
|
||||
|
|
|
|||
|
|
@ -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 += '<div class=\"ext-card\""), js.indexOf('return html;'));
|
||||
assert.match(card, /<span class="ext-name">/);
|
||||
assert.ok(card.indexOf('ext-name') < card.indexOf('ext-number'), 'the name is the scan target and leads');
|
||||
assert.match(card, /<wbr>/, '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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue