From 60b721fefc726b29f364f7796f09364e732e17fa Mon Sep 17 00:00:00 2001 From: Valera Brizhatiuk <19537764+plombeer31@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:24:28 +0300 Subject: [PATCH] =?UTF-8?q?desktop:=20composer=20popovers=20in=20Soft=20Ta?= =?UTF-8?q?ctile=20=E2=80=94=20per-chip=20anchoring,=20brand-mark=20rows,?= =?UTF-8?q?=20query=20highlight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where it runs, Provider, Model, the add-provider wizard inside the popover, context breakdown, modes and the dictation language menu, restyled as floating lift cards (radius 18, pop shadow) with the app's logic and copy unchanged. - anchorStyle takes a selector or an element, opens 8px above the composer card of the chip that opened the popover, aligns with that chip (right edge for chips on the right half), clamps inside the content column and caps the height to what the column leaves. Widths per pane: backend 440, provider 420, model 520, wizard 460, presets 440, context 380, modes 360. - Route rows: real provider/model marks (server/CPU badge otherwise), route icons on backend rows with a radio, ids in DM Mono, Unverified amber chip, blue download chip, a divider before the trailing action row, the typed query marked inside model ids without changing .nm text. - Wizard: provider mark in the header, key/URL fields with a key icon that take the error (red) or could-not-check (amber) tone, Tactile footer buttons. - Context: breakdown bar (amber above 70%, red above 85%, striped when projected) with the reply reserve, amber trimming line, icon steppers. - Modes: title, rows whose word takes the mode colour, level-5 note in amber, Update agent in the footer when the agent lacks the route. - Voice menu floats above the strip with radio, badge, progress bar and a short-window height budget. Hooks kept: .popover, .selpop, .selhead text and busy captions, .nm, .modelrow, first .cap as row detail, the inline danger colour on switch errors, .ob-err, #wiz-key, #sel-filter, #sel-key, [data-wizmodel], .popfoot [data-act=close], .ctxdial/.ctxbound/.ctxbasis/.hd, .poprow(.on/.dim), .vsmrow/.vsmfoot, every data-act and data-* attribute. --- desktop/renderer/css/popovers.css | 233 +++++++++++++++++++----- desktop/renderer/renderer.js | 285 +++++++++++++++++++++--------- 2 files changed, 392 insertions(+), 126 deletions(-) diff --git a/desktop/renderer/css/popovers.css b/desktop/renderer/css/popovers.css index 218b7f1e..85bd4036 100644 --- a/desktop/renderer/css/popovers.css +++ b/desktop/renderer/css/popovers.css @@ -3,63 +3,206 @@ Loaded after styles.css (tokens + kit). Colours come from tokens only. ============================================================ */ -.popover{position:absolute;background:var(--bg-overlay);border-radius:0;box-shadow:var(--shadow-overlay);width:340px;z-index:25;overflow:hidden} -.poprow{display:flex;align-items:flex-start;gap:var(--s3);padding:8px var(--s4);width:100%} -.poprow:hover{background:var(--bg-hover)} -.poprow.on{background:var(--accent-wash)} +/* ---- The popover shell ---- + Every composer popover is a floating --lift card: radius 18, the pop shadow, + 6px inside (the kit's .tk-pop rides on the same element). anchorStyle() pins + it above the composer card of the chip that opened it, gives it that chip's + width and caps its height to what the content column leaves; the numbers + here are only the fallback for a popover with no chip on screen. */ +.popover{position:absolute;z-index:25;width:340px;max-width:calc(100vw - 32px);overflow:hidden; + background:var(--lift);border-radius:var(--r-pop);box-shadow:var(--sh-pop);padding:6px; + display:flex;flex-direction:column;gap:1px;color:var(--ink)} +.popfoot{flex:none;display:flex;align-items:center;justify-content:flex-end;gap:6px; + margin-top:4px;padding:6px 0 0 2px;box-shadow:inset 0 1px 0 var(--divider)} +.popfoot .grow{flex:1;min-width:0} +/* Footer verbs are the small pill size everywhere, including the ones another + function hands the shell (the model step's Back · Use default · Use this model). */ +.popover .popfoot .btn:not(.icon){height:26px;padding:0 10px;font-size:12px} +.popover .btn svg{width:14px;height:14px} -.ctxbar{display:flex;height:6px;border-radius:0;overflow:hidden;margin-top:10px;gap:1px} -.ctxbar i{display:block} -.swatch{width:8px;height:8px;border-radius:0;flex:none} -.popfoot{display:flex;align-items:center;justify-content:flex-end;gap:var(--s3);padding:var(--s4);box-shadow:inset 0 1px 0 var(--line-soft)} +/* ---------------- model selector popup ---------------- */ +.selpop{width:460px;max-height:60vh} +.selhead{flex:none;display:flex;align-items:center;gap:8px;min-height:32px;padding:4px 8px; + font:600 13px/1.3 var(--font-ui);letter-spacing:-.003em;color:var(--ink)} +.selhead .selttl{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.selhead .logo,.selhead .tk-ico{flex:none} +.selhead .selbusy{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:6px;font-weight:500;color:var(--ink-2)} +.selhead .selbusy .tk-spin{width:11px;height:11px} -.modellist{display:flex;flex-direction:column} -.modelrow{display:flex;align-items:center;gap:var(--s4);padding:8px var(--s4);width:100%;border-left:2px solid transparent;cursor:pointer} -.modelrow + .modelrow{box-shadow:inset 0 1px 0 var(--line-soft)} -.modelrow:hover{background:var(--bg-hover)} -.modelrow.on{background:var(--accent-wash);border-left-color:var(--accent)} -.modelrow .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} -.modelrow .nm{color:var(--text-primary);font-size:13px} +.selsearch{flex:none;padding:0 0 6px} +.selpop .tk-inpwrap{height:36px;padding:0 12px} +.selpop .tk-inpwrap > svg{width:15px;height:15px;flex:none} +.selpop .tk-inpwrap input{font-size:13.5px} +.selpop .tk-inpwrap.is-error{background:var(--lift);border-color:var(--critical);box-shadow:0 0 0 4px var(--critical-wash)} +.selpop .tk-inpwrap.is-warn{border-color:var(--amber)} +.selpop .tk-inpwrap .tk-spin{flex:none;margin-right:4px} -/* ---------------- model selector popup ---------------- */ -.selpop { width: 460px; max-width: calc(100vw - 32px); display: flex; flex-direction: column; max-height: 60vh; } -.selhead { - flex: none; height: 36px; display: flex; align-items: center; gap: var(--s3); - padding: 0 var(--s5); font-size: 13px; font-weight: 500; - box-shadow: inset 0 -1px 0 var(--line-soft); -} -.selsearch { flex: none; padding: var(--s3) var(--s5); } -.selbody { flex: 1; min-height: 0; overflow-y: auto; } +.selbody{flex:1 1 auto;min-height:0;overflow-y:auto;display:flex;flex-direction:column} /* The list scrolls inside the popup — it must never grow past the window. */ -.sellist { flex: 1; min-height: 0; max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; } -.selpop .popfoot { flex: none; } +.sellist{flex:1 1 auto;min-height:0;max-height:340px;overflow-y:auto;display:flex;flex-direction:column;gap:1px} +.selbody > .sellist{flex:none} +.selpop .popfoot{flex:none} + +/* One row: badge · id over its one-line facts · right slot (radio, Unverified, download). */ +.selpop .modelrow{display:flex;align-items:center;gap:10px;width:100%;min-height:42px;padding:6px 10px;flex:none; + border:0;border-radius:var(--r-well);background:transparent;color:var(--ink);text-align:left;cursor:pointer; + transition:background var(--t-state) var(--ease)} +.selpop .modelrow:hover{background:var(--hover)} +.selpop .modelrow.on{background:var(--brand-wash)} +.selpop .modelrow:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +.selpop .modelrow .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.selpop .modelrow .nm{font:600 13.5px/1.3 var(--font-ui);letter-spacing:-.003em;color:var(--ink); + overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.selpop .modelrow .nm.mono{font:500 13px/18px var(--font-mono);letter-spacing:0} +.selpop .modelrow .col > .cap{font-size:12px;line-height:1.4;color:var(--ink-2);overflow:hidden; + display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2} +.selpop .modelrow .cap.mono,.selpop .modelrow .cap .mono{font-family:var(--font-mono);font-size:11.5px;letter-spacing:0} +.selpop .modelrow.dim .nm{color:var(--ink-3)} +.selpop .modelrow.dim .logo,.selpop .modelrow.dim .tk-ico{opacity:.55} +.selpop .selr{margin-left:auto;flex:none;display:flex;align-items:center;gap:6px;padding-left:4px} +.selpop .seldl svg{width:13px;height:13px} +.selpop .selsep{margin:4px 8px} +.selspin{width:22px;height:22px;flex:none;display:grid;place-items:center} +/* MP-11: the typed query inside a result. A wrapper around the matched letters + only — the row's .nm keeps exactly the text it had. */ +.selhit{background:var(--brand-wash);color:var(--blue);border-radius:3px} + +.selnote{flex:none;display:flex;align-items:center;gap:8px;margin:0;padding:12px 10px;font-size:12.5px;line-height:1.45;color:var(--ink-2)} +/* A switch error. The inline colour on it is what drive-selector reads — keep it. */ +.selerr{flex:none;display:flex;align-items:flex-start;gap:8px;margin:6px 0 0;padding:8px 10px;border-radius:var(--r-well); + background:var(--critical-wash);font-size:12.5px;line-height:1.45;overflow-wrap:anywhere;max-height:140px;overflow-y:auto} +.selerr > svg{flex:none;width:14px;height:14px;margin-top:2px} + +/* MP-16: the popover's own download — the raw CLI line in a mono well. */ +.selpull{gap:8px;padding:2px 2px 6px} +.selpull .selpullline{margin:0;padding:10px 12px;border-radius:var(--r-well);background:var(--well);color:var(--ink); + font:400 12px/1.5 var(--font-mono);white-space:pre-wrap;overflow-wrap:anywhere} +.selpull > .cap:not(.selpullline){margin:0 4px} + +/* MP-04: the preset list's key field. */ +.selkey{flex:none;padding:8px 0 2px} + +/* ---- The add-provider wizard inside the popover (MP-06…10) ---- */ +.selwiz{gap:6px;padding:0 2px 6px} +.selwiz .tk-lbl{padding:4px 4px 0} +.selwiz #wiz-key,.selwiz #wiz-url{font-family:var(--font-mono);font-size:12.5px} +.selpop .ob-help{margin:0;padding:0 4px;max-width:none;font-size:12.5px;line-height:1.45;color:var(--ink-2)} +.selpop .ob-err{margin:0;padding:0 4px;max-width:none;font-size:12.5px;line-height:1.45;white-space:pre-wrap;color:var(--critical)} +.selpop .ob-err.tk-help--warn{color:var(--amber-text)} + +/* Inside the popover the shell's own body is the scroller, so the list must + not add a second one — two nested scrollers is how the footer ends up + somewhere nobody can reach. */ +.selpop .ob-wizlist{max-height:none;overflow:visible} +.selpop .ob-wiz{display:flex;flex-direction:column;gap:6px;width:100%;padding:0 0 4px} +/* The popover's header already names the provider; the step's own kicker and heading would say it twice. */ +.selpop .ob-wiz > .ob-kicker,.selpop .ob-wiz > .ob-h{display:none} +.selpop #wiz-model-q{width:100%;height:34px;flex:none;padding:0 14px;border:1px solid transparent;border-radius:var(--r-pill); + background:var(--well);color:var(--ink);font:400 13.5px/1 var(--font-ui);outline:none} +.selpop #wiz-model-q::placeholder{color:var(--ink-3)} +.selpop #wiz-model-q:focus{background:var(--lift);border-color:var(--brand);box-shadow:var(--focus-ring)} +.selpop .prows{display:flex;flex-direction:column;gap:1px;border:0;box-shadow:none;background:transparent} +.selpop .prows [data-wizmodel]{display:flex;align-items:center;gap:10px;width:100%;min-height:42px;padding:6px 10px; + border:0;border-radius:var(--r-well);background:transparent;box-shadow:none;color:var(--ink);text-align:left;cursor:pointer} +.selpop .prows [data-wizmodel]:hover{background:var(--hover)} +.selpop .prows [data-wizmodel].on{background:var(--brand-wash)} +.selpop .prows [data-wizmodel] .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.selpop .prows [data-wizmodel] .nm{font:500 13.5px/1.3 var(--font-ui);color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.selpop .prows [data-wizmodel] .ep{font:400 11.5px/1.4 var(--font-mono);color:var(--ink-2)} +.selpop .prows [data-wizmodel] .ann{margin-left:auto;flex:none;background:var(--brand-wash);color:var(--blue)} +/* ---- Context breakdown (CM-15…17) ---- */ +.ctxpop{width:380px} +.ctxin{flex:none;display:flex;flex-direction:column;gap:10px;padding:8px 8px 10px} +.ctxpop .ctxttl{margin:0;font:500 12.5px/1.4 var(--font-mono);letter-spacing:0;color:var(--ink)} +.ctxmeter{display:flex;flex:none;height:8px} +.ctxmeter > i,.ctxmeter > b{display:block;height:100%;flex:none;border-radius:0} +.ctxmeter > b{background:var(--bd-2)} +.ctxmeter.proj > i{background:repeating-linear-gradient(90deg,var(--brand) 0 3px,transparent 3px 5px)} +.ctxpop .kvgrid{grid-template-columns:minmax(0,1fr) max-content;gap:5px 12px;font-size:13px} +.ctxpop .kvgrid dt{color:var(--ink)} +.ctxpop .kvgrid dd{text-align:right;font-size:12px;line-height:18px;color:var(--ink)} +.ctxpop .kvgrid .ter{color:var(--ink-2)} +.ctxpop .ctxin > .cap{margin:0;line-height:1.5} +/* ---- Item 7C: the context panel's binding line ---- */ +.ctxbound{display:flex;align-items:flex-start;gap:6px;color:var(--amber-text)} +.ctxbound > svg{flex:none;width:13px;height:13px;margin-top:2px} /* context panel dials */ -.ctxdials { box-shadow: inset 0 1px 0 var(--line-soft); padding: var(--s3) 0; } -.ctxdial { display: flex; align-items: center; gap: var(--s4); padding: var(--s2) var(--s5); } -.ctxdial .col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; font-size: 13px; } -.ctxdial .btn { min-width: 28px; justify-content: center; } +.ctxdials{flex:none;padding:0 8px 2px} +.ctxdial{display:flex;align-items:center;gap:6px;padding:10px 0 4px;box-shadow:inset 0 1px 0 var(--divider)} +.ctxdial .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.ctxdial .ctxdt{font:600 13px/1.3 var(--font-ui);color:var(--ink)} +.ctxdial .ctxstep{display:flex;align-items:center;gap:4px} +.ctxdial .ctxval{min-width:32px;text-align:center;font-size:13px} -.modelrow.dim { opacity: .5; } +/* ---- Modes (CM-12…14) ---- */ +.modepop{width:360px} +.modehead{flex:none;display:flex;align-items:baseline;gap:8px;min-height:30px;padding:6px 8px 4px; + font:600 13px/1.3 var(--font-ui);color:var(--ink)} +.modehead .cap{margin-left:auto;min-width:0;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.poprow{display:flex;align-items:center;gap:10px;width:100%;min-height:44px;padding:6px 10px;flex:none; + border:0;border-radius:var(--r-well);background:transparent;color:var(--ink);text-align:left;cursor:pointer; + transition:background var(--t-state) var(--ease)} +.poprow:hover{background:var(--hover)} +.poprow:focus-visible{outline:2px solid var(--brand);outline-offset:-2px} +.poprow .col{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.poprow .ml{font:600 13.5px/1.3 var(--font-ui);color:var(--ink)} +.poprow .col > .cap{font-size:12px;line-height:1.4} +.poprow .modecur{margin-left:auto;flex:none} +/* The row's word takes the chip's colour: default ink, plan blue, auto amber, bypass red. */ +.poprow.modetone-plan .ml{color:var(--blue)} +.poprow.modetone-auto .ml{color:var(--amber-text)} +.poprow.modetone-bypass .ml{color:var(--critical)} +.modenote{flex:none;display:flex;flex-direction:column;gap:4px;padding:6px 10px 2px} +.modenote p{margin:0;line-height:1.5} +.modenote .modewarn{color:var(--amber-text)} +.modenote .ob-help{max-width:none;font-size:12px;color:var(--ink-2)} /* ============================================================ Coding-mode popover — the unavailable state (item 6) ============================================================ `.dim` is applied to the mode rows when the agent has no - /api/coding-mode route, but the only `.dim` rules that existed were - `.palrow`, `.modelrow` and `.tuirow`, so the unavailable state was - invisible. A disabled row also has to stop looking clickable. */ -.poprow.dim{opacity:.5} + /api/coding-mode route. A disabled row has to look unavailable + and stop looking clickable. (The smoke looks for this rule.) */ +.poprow.dim{cursor:default} +.poprow.dim .ml,.poprow.dim .col > .cap{color:var(--ink-3)} .poprow[disabled]{cursor:default} .poprow[disabled]:hover{background:transparent} -/* ---- Item 7C: the context panel's binding line ---- */ -.ctxbound{color:var(--warn)} - -.modelrow .ann{margin-left:auto} - -/* Inside the popover the shell's own body is the scroller, so the list must - not add a second one — two nested scrollers is how the footer ends up - somewhere nobody can reach. */ -.selpop .ob-wizlist{max-height:none;overflow:visible} -.selpop .ob-wiz{padding:var(--s4) var(--s5)} +/* ---- Voice language menu (CM-08) ---- + voiceMenuHTML draws it inside the voice strip; it floats above the strip as a + popover, off .composerwrap (the strip's positioned ancestor). The .tk-vmenu + compound keeps these declarations ahead of the strip's own .vsm* rules. */ +.voicestrip .vsmenu.tk-vmenu{position:absolute;z-index:16;right:var(--s6);bottom:calc(100% + 6px); + width:420px;max-width:calc(100% - 16px);max-height:none;margin:0;padding:6px;border:0;border-radius:var(--r-pop); + background:var(--lift);box-shadow:var(--sh-pop);display:flex;flex-direction:column;gap:1px;overflow:hidden} +.voicestrip .tk-vmenu .vsmtitle{flex:none;padding:6px 8px 2px;font:600 13px/1.3 var(--font-ui);color:var(--ink)} +/* The list gives up height first on a short window, so the menu never climbs under the toolbar (940×620). */ +.voicestrip .tk-vmenu .vsmscroll{min-height:0;max-height:min(300px,max(160px,calc(100vh - 460px)));overflow-y:auto;display:flex;flex-direction:column;gap:1px} +.voicestrip .tk-vmenu .vsmhead{padding:8px 8px 3px;font:600 12px/1.3 var(--font-ui);letter-spacing:0;text-transform:none;color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmrow{display:flex;align-items:center;gap:6px;padding:0 8px 0 0;border-radius:var(--r-well);background:transparent} +.voicestrip .tk-vmenu .vsmrow:hover{background:var(--hover)} +.voicestrip .tk-vmenu .vsmpick{flex:1;min-width:0;display:flex;align-items:center;gap:10px;height:auto;min-height:42px;padding:5px 10px; + border:0;border-radius:var(--r-well);background:transparent;color:var(--ink);text-align:left} +.voicestrip .tk-vmenu .vsmbody{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.voicestrip .tk-vmenu .vsmline{display:flex;align-items:center;gap:6px;min-width:0} +.voicestrip .tk-vmenu .vsmname{flex:none;font:500 13.5px/1.3 var(--font-ui);color:var(--ink)} +.voicestrip .tk-vmenu .vsmtag{flex:none;font:400 11px/1 var(--font-mono);color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmnote{flex:none;text-align:left;font-size:12px;line-height:1.35;color:var(--ink-2); + white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.voicestrip .tk-vmenu .vsmbadge{flex:none;height:18px;display:inline-flex;align-items:center;padding:0 7px;border-radius:var(--r-pill); + background:var(--brand-wash);color:var(--blue);font:600 11px/1 var(--font-ui)} +.voicestrip .tk-vmenu .vsmadd{width:26px;height:26px;flex:none;display:grid;place-items:center;border:0;border-radius:50%; + background:transparent;color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmadd:hover{background:var(--press);color:var(--ink)} +.voicestrip .tk-vmenu .vsmadd.on{background:var(--brand-wash);color:var(--blue)} +.voicestrip .tk-vmenu .vsmadd svg{width:14px;height:14px} +.voicestrip .tk-vmenu .vsmprog{flex:none;width:60px;min-width:0} +.voicestrip .tk-vmenu .vsmerr{padding:6px 10px;font-size:12.5px;line-height:1.45;color:var(--critical)} +.voicestrip .tk-vmenu .vsmfoot{flex:none;margin-top:4px;padding:8px 10px 4px;box-shadow:inset 0 1px 0 var(--divider); + font-size:12px;line-height:1.5;color:var(--ink-2)} +.voicestrip .tk-vmenu .vsmempty{padding:10px;font-size:12.5px;color:var(--ink-2)} +/* On a short window the long note scrolls in its own few lines instead of pushing the languages out. */ +@media (max-height:700px){ + .voicestrip .tk-vmenu .vsmfoot{max-height:78px;overflow-y:auto} +} diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js index 58bf93a9..dd493279 100644 --- a/desktop/renderer/renderer.js +++ b/desktop/renderer/renderer.js @@ -2253,15 +2253,19 @@ function voiceChipHTML() { function voiceMenuHTML() { if (!VOICE.supported.length) { - return '
' + esc(VOICE.reason || 'No on-device languages were reported') + '
'; + return '
' + esc(VOICE.reason || 'No on-device languages were reported') + '
'; } const installed = VOICE.supported.filter((id) => VOICE.installed.indexOf(id) >= 0); const rest = VOICE.supported.filter((id) => VOICE.installed.indexOf(id) < 0); + /* Soft Tactile (CM-08): a radio for the first language, the name with its + mono tag and `first` badge over the note, a bar while a model downloads, + and the + / ✓ that adds a second language on the right. */ const row = (id) => { const on = VOICE.locales.indexOf(id) >= 0; const primary = VOICE.locales[0] === id; const here = VOICE.installed.indexOf(id) >= 0; const eng = voiceEngineOf(id); + const pct = VOICE.installing === id ? Math.round(VOICE.installFraction * 100) : -1; const note = here ? (eng === 'dictation' ? 'on this Mac · plain text, no punctuation' : 'on this Mac') : (VOICE.installing === id @@ -2269,20 +2273,26 @@ function voiceMenuHTML() { : (eng === 'dictation' ? 'downloads a model · plain text, no punctuation' : 'downloads a model')); return '
' + '' + + (pct >= 0 ? '' : '') + (here && !primary ? '' : '') + '
'; }; - return '
' + return '
' + + '
Dictation language
' + + '
' + '
On this Mac
' + installed.map(row).join('') + (rest.length ? '
Downloads a model the first time
' + rest.map(row).join('') : '') + + '
' + (VOICE.installErr ? '
' + esc(VOICE.installErr) + '
' : '') + '
Transcribed on this Mac. One language is active at a time unless you add a second with +: ' + 'then both models hear the same audio and the higher-scoring one wins the whole dictation. ' @@ -2801,15 +2811,36 @@ function refreshDlProgress() { -/** Pin a popover to the control that opened it, clamped inside the window. */ +/** Pin a popover to the control that opened it, clamped inside the window. + * + * Soft Tactile: every popover belongs to its own chip. `sel` is a selector or + * the element itself. The popover opens 8px above the composer card that chip + * sits in (above the chip itself anywhere else), lines up with the chip's left + * edge — or its right edge for a chip on the right half of the row — and is + * clamped inside the content column, so a wide one never runs under the + * inspector. Its width (narrowed when the column is narrower) and the height + * the column leaves above the card are part of the returned style, so a + * caller's own `width:` in front of it is only the default. */ function anchorStyle(sel, width) { - const el = document.querySelector(sel); - const win = $('#window').getBoundingClientRect(); - if (!el) return 'right:24px;bottom:96px'; + const el = typeof sel === 'string' ? document.querySelector(sel) : sel; + const winEl = $('#window'); + if (!el || !winEl) return 'right:24px;bottom:96px'; + const win = winEl.getBoundingClientRect(); + const colEl = document.querySelector('#content'); + const colR = colEl ? colEl.getBoundingClientRect() : null; + const col = colR && colR.width > 0 ? colR : win; + const lo = Math.max(win.left, col.left) + 8; + const hi = Math.min(win.right, col.right, window.innerWidth) - 8; + const w = Math.round(Math.max(Math.min(width, hi - lo), Math.min(width, 280))); const r = el.getBoundingClientRect(); - const left = Math.max(win.left + 8, Math.min(r.left, win.right - width - 8)); + const card = el.closest('#composer'); + const top = card ? card.getBoundingClientRect().top : r.top; + const onRight = r.left + r.width / 2 > (lo + hi) / 2; + const left = Math.max(lo, Math.min(onRight ? r.right + 6 - w : r.left - 6, hi - w)); + const room = Math.round(top - 8 - Math.max(win.top, col.top) - 8); return 'position:fixed;left:' + Math.round(left) + 'px;bottom:' - + Math.round(window.innerHeight - r.top + 8) + 'px;top:auto;right:auto'; + + Math.round(window.innerHeight - top + 8) + 'px;top:auto;right:auto;width:' + w + 'px' + + (room > 160 ? ';max-height:' + room + 'px' : ''); } /** @@ -2835,7 +2866,7 @@ function ctxBoundLine() { text = 'older turns are being dropped — the window is the limit, not a configured cap'; } if (u.droppedPairs > 0) text += ' · ' + u.droppedPairs + ' dropped so far'; - return '

' + esc(text) + '

'; + return '

' + ic('alert') + '' + esc(text) + '

'; } function contextHTML() { const agent = (LIVE_CONFIG && LIVE_CONFIG.agent) || {}; @@ -2875,23 +2906,34 @@ function contextHTML() { } } const rows = CTX.tokens - ? '
' + body + '
' - : '

send a message \u2014 the breakdown comes from the prompt the agent actually builds

'; + ? '
' + body + '
' + : '

send a message \u2014 the breakdown comes from the prompt the agent actually builds

'; + /* Soft Tactile (CM-15…17): the breakdown bar under the title — the prompt in + brand (amber above 70%, red above 85%, striped while projected) and the + reply reserve beside it — then the rows, trimming and basis lines, the + tasks-per-turn dial and the footer. */ + const used = win ? Math.min(100, CTX.tokens / win * 100) : 0; + const held = win ? Math.min(100 - used, reserved / win * 100) : 0; + const meter = CTX.tokens && win + ? '
' + + '' + + (held > 0 ? '' : '') + '
' + : ''; return '
' - + '
' - + '
' + esc(title) + '
' + rows + + '
' + + '
' + esc(title) + '
' + meter + rows + ctxBoundLine() - + (CTX.tokens ? '

' + esc(ctxBasisLine()) + '

' : '') + + (CTX.tokens ? '

' + esc(ctxBasisLine()) + '

' : '') + '
' - + '
tasks per turn' + + '
tasks per turn' + 'sent each turn (1-100)' - + '' - + '' - + '' + pairs + '' - + '' + + '' + + '' + + '' + pairs + '' + + '' + '
' - + '
' - + '
'; + + '
' + + '
'; } function sessionSheet() { @@ -9565,15 +9607,58 @@ function selPull(id) { }); } +/** Soft Tactile — the leading badge of one popover row: the route's own icon on + * a backend row, the real brand mark on a provider or model row (the server / + * CPU badge when there is none), a spinner on a local model that is starting. */ +function selRowLead(r) { + if (r.type === 'backend') { + return '' + + ic(r.id === 'cloud' ? 'cloud' : r.id === 'local' ? 'laptop' : 'server') + ''; + } + if (r.type === 'provider') return providerMark(r.id, 'sm'); + if (r.type === 'action') return '' + ic(r.id === 'add' ? 'plus' : 'download') + ''; + if (r.type === 'localModel' && SEL.busy && BSW.line === 'starting ' + r.id + '…') { + return ''; + } + return modelMark(r.id, 'sm'); +} + +/** Soft Tactile (MP-11) — the typed query, marked inside a result. Each query + * token is marked wherever it occurs (modelMatches matches tokens, not the + * whole string); the marks only wrap letters, so `.nm` keeps its text. */ +function selHilite(label, query) { + const s = String(label || ''); + const toks = String(query || '').toLowerCase().split(/[\/\.\-_:\s]+/).filter(Boolean); + if (!toks.length) return esc(s); + const low = s.toLowerCase(); + const hit = new Array(s.length).fill(false); + toks.forEach((t) => { + for (let at = low.indexOf(t); at >= 0; at = low.indexOf(t, at + 1)) { + for (let k = 0; k < t.length; k++) hit[at + k] = true; + } + }); + let out = ''; + for (let i = 0; i < s.length;) { + let j = i; + while (j < s.length && hit[j] === hit[i]) j++; + out += hit[i] ? '' + esc(s.slice(i, j)) + '' : esc(s.slice(i, j)); + i = j; + } + return out; +} + function selectorHTML() { const rows = selRows(); SEL.rows = rows; if (SEL.pulling) { + /* The first `.popover .cap` is the line the pull's progress events patch + in place (selPull's listener) — keep it first. */ return selShell('Downloading ' + SEL.pulling, - '

' + esc(SEL.pullLine) + '

' + '

' + esc(SEL.pullLine) + '

' + '

It is selected automatically when it lands.

', - ''); + '', + '' + ic('download') + ''); } // Adding a provider is its own screen: the presets you have NOT @@ -9588,15 +9673,16 @@ function selectorHTML() { + (free.length ? '
' + free.map((p, i) => '').join('') + '
' - + '
' - : '

Every preset is already configured.

') + + '' + esc(p.baseUrl) + '' + + '').join('') + '
' + + '
' + : '

Every preset is already configured.

') + '
', - '' - + (free.length ? '' : '')); + '' + + (free.length ? '' : '')); } const title = SEL.kind === 'backend' ? 'Where it runs' @@ -9608,43 +9694,61 @@ function selectorHTML() { // provider list IS that one row; only the cloud model pane can be bare. const real = rows.filter((r) => r.type !== 'action'); if (!rows.length && !SEL.modelsBusy && !SEL.localBusy && !(SEL.kind === 'model' && SEL.filter)) { - return selShell(title, '

Nothing to show.

', ''); + return selShell(title, '

Nothing to show.

', ''); } const search = SEL.kind === 'model' - ? '
' + ? '
' : ''; + /* Soft Tactile rows (MP-01, MP-05, MP-11, MP-14): badge · the id (DM Mono for + providers and models, the query marked) over its one-line facts · the right + slot. The first `.cap` in a row is its detail — integration.drive reads it. */ const list = '
' - + (SEL.modelsBusy || SEL.localBusy ? '
reading the catalogue…
' : '') - + (SEL.modelsErr ? '
' + esc(SEL.modelsErr) + '
' : '') - + rows.map((r, i) => '').join('') - + (!real.length && SEL.kind === 'model' && SEL.filter && !SEL.modelsBusy && !SEL.localBusy ? '
no models match \u201c' + esc(SEL.filter) + '\u201d
' : '') + + (SEL.modelsBusy || SEL.localBusy ? '
reading the catalogue…
' : '') + + (SEL.modelsErr ? '
' + ic('alert') + '' + esc(SEL.modelsErr) + '
' : '') + + rows.map((r, i) => { + const model = r.type === 'cloudModel' || r.type === 'localModel'; + const right = (r.type === 'backend' ? '' : '') + + (r.type === 'localModel' && !r.downloaded ? '' + ic('download') + 'download' : '') + /* F1 — an unlit cell, not a lit one: this is a state we could not + confirm, not a fault we found. It goes out when a turn succeeds. */ + + (r.unverified ? 'Unverified' : ''); + return (r.type === 'action' && i > 0 ? '
' : '') + + ''; + }).join('') + + (!real.length && SEL.kind === 'model' && SEL.filter && !SEL.modelsBusy && !SEL.localBusy ? '
no models match \u201c' + esc(SEL.filter) + '\u201d
' : '') + '
'; // Adding a provider is the pane's own trailing row now, as in the TUI. - const foot = ''; + const foot = ''; return selShell(title, search + list, foot); } -/** One popup shell: fixed height, its own scroll, anchored to the chip. */ -function selShell(title, body, foot) { +/** One popup shell: fixed height, its own scroll, anchored to the chip. + * Soft Tactile: anchored to the chip of the pane it shows (backend, provider or + * model), at that pane's width; `lead` is an optional badge before the title + * (the provider's mark on the wizard's steps). */ +function selShell(title, body, foot, lead) { + const chip = (kind) => document.querySelector('#composer .cfoot [data-sel-open="' + kind + '"]'); + const anchor = chip(SEL.kind) || chip('provider') || chip('backend') + || document.querySelector('.modelchip') || document.querySelector('.modechip'); + const width = WIZ.phase ? 460 : SEL.addOpen ? 440 : SEL.pulling || SEL.kind === 'model' ? 520 + : SEL.kind === 'provider' ? 420 : 440; return '
' - + '
' - + '
' + esc(title) - + (SEL.busy ? '' + esc(BSW.line || 'saving…') + '' : '') + '
' + + '
' + + '
' + (lead || '') + '' + esc(title) + '' + + (SEL.busy ? '' + esc(BSW.line || 'saving…') + '' : '') + '
' + body - + (SEL.err ? '
' + esc(SEL.err) + '
' : '') + /* The inline colour is the hook drive-selector reads switch errors by. */ + + (SEL.err ? '
' + ic('alert') + '' + esc(SEL.err) + '
' : '') + (foot ? '
' + foot + '
' : '') + '
'; } @@ -10056,44 +10160,50 @@ function codingModeChip() { } function modesHTML() { + const off = MODE.supported === false; + /* Soft Tactile (CM-12…14): a titled menu of four radio rows whose word takes + the chip's colour, the note under them and Done — and, on an agent without + the route, the version it needs with Update agent in the footer. */ return '
' - + '
' + + '
' + + '
Mode' + + (off ? '' : 'what the agent may do without asking') + '
' + CODING_MODES.map((m) => { - const off = MODE.supported === false; // Nothing is "current" on an agent that has no such state, so no // row is marked, and the rows carry no data-mode: the click // handler keys off that attribute, so dropping it is what makes // them genuinely inert rather than merely grey. // ...and nothing is "current" before the route has answered either. const on = !off && MODE.known && m.id === currentMode(); - return ''; + + '' + esc(m.label) + '' + + '' + esc(off ? MODE_NEEDS_NEWER : m.detail) + '' + + (on ? 'current' : '') + ''; }).join('') - + '
' - + (MODE.supported === false + + '
' + + (off /* F4 — this is our packaging problem, and it used to be presented to the user as their broken feature: four greyed stances, an internal route name, and the path of the binary we happened to spawn. None of that is actionable by a person. Say which version is needed and offer the one thing that helps. */ - ? '

' + esc(MODE_NEEDS_NEWER) - + '

' - : '

' + ? '

' + esc(MODE_NEEDS_NEWER) + '

' + : '

' + 'A stance for this session. It moves the live approval ladder and plan flag and writes nothing to config.' + '

' // The disclosure that stops a level-5 operator reading a working // chip as a broken one: three of the four choices genuinely do // not change what the agent does at that base. + (MODE.baseLevel === MAX_APPROVAL_LEVEL - ? '

Your configured approval level is ' + MAX_APPROVAL_LEVEL + ? '

Your configured approval level is ' + MAX_APPROVAL_LEVEL + ' of ' + MAX_APPROVAL_LEVEL + ', so default already approves everything — lower agent.approvalLevel to make the modes differ.

' : '')) + '
' - + '
'; + + '
' + + (off ? '' : '') + + '
'; } /* Item 6 review fix: S.level has three writers and the diagnostics line @@ -10796,17 +10906,30 @@ function wizardHTML() { return selShell('Add a provider', '
' + KIND_ROWS.map((k, i) => '').join('') + + '' + (k.custom ? esc('you supply the URL') : '' + esc(k.baseUrl || k.kind) + '') + + (taken.has(k.id) ? ' \u00b7 already configured' : '') + '').join('') + '
', - ''); + ''); } const k = WIZ.row; + const lead = providerMark(k.custom ? '' : k.id, 'sm'); + const verifying = WIZ.phase === 'verifying'; + const unchecked = WIZ.uncheckedFor; + /* Soft Tactile (MP-06…09): the field takes the tone of the line under it — + red for an error, amber for a key that could not be checked. */ + const urlBad = !!(k.custom && WIZ.error && !/^https?:\/\/\S+$/.test(WIZ.baseUrl)); + const tone = WIZ.error ? (unchecked ? ' is-warn' : ' is-error') : ''; const fields = (k.custom - ? '' + ? '' + + '' + ic('globe') + + '' : '') - + '' - + ''; + + '' + + '' + ic('key') + + '' + + (verifying ? '' : '') + ''; /* The popover is a flex column with a fixed max-height, and `.selbody` is the child that scrolls. Handing it a bare `.ob-wiz` meant nothing scrolled: with a 37-model catalogue the content ran straight past the @@ -10814,26 +10937,26 @@ function wizardHTML() { was drawn 90px below the last visible pixel. */ if (WIZ.phase === 'pick_model') { return selShell(WIZ.savedLabel || 'Choose a model', - '
' + wizModelStepHTML(false) + '
', wizModelStepFoot()); + '
' + wizModelStepHTML(false) + '
', wizModelStepFoot(), lead); } - const verifying = WIZ.phase === 'verifying'; /* This is the SECOND place the app asks for an API key — the wizard's own screen is the other — and the two had drifted: this one wrote the error into a `

` with an inline colour, so the error slot the rest of the app (and F1's own check) looks for did not exist here at all. Same class, same slot, same two buttons when a key could not be checked. */ - const unchecked = WIZ.uncheckedFor; return selShell(k.label, - '

' + fields + '
' + fields + (verifying ? '

Asking the provider to answer once with this key\u2026

' : '') - + (WIZ.error ? '
' + esc(WIZ.error) + '
' : '') + + (WIZ.error ? '
' + esc(WIZ.error) + '
' : '') + '
', unchecked - ? '' - + '' - + '' - : '' - + ''); + ? '' + + '' + + '' + : '' + + '', + lead); } /**