Effort as the model offers it, and the team's size beside it
/api/v2/models now says each model's levels, start level and control. A model
with one level shows no effort control; off and one effort is a Thinking
switch (a checkbox with the switch role); more keep the selector. In the
chat's header the switch and selector save at once; in the new-chat form they
start at the model's start level and redraw when the model changes.
Where a team is offered -- an open chat asks GET /chats/{chat}/team; a new
chat on the server needs a model that calls tools and serves two requests at
once -- the Agents fewest/most fields sit beside it. The header saves the pair
on change (the server refuses a pair outside its bounds and says them; the
fields go back); a new chat sends them only when set, else the person's
default stands.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
3 files changed
+275
-32
No files matched your search
+64
-2
@@ -67,7 +67,7 @@ bdi { unicode-bidi: isolate; }
|
|||||||
.btn--danger { color: var(--ink-inverse); background: var(--danger); border-color: var(--danger); }
|
.btn--danger { color: var(--ink-inverse); background: var(--danger); border-color: var(--danger); }
|
||||||
.btn--danger:hover { background: var(--danger-hover); }
|
.btn--danger:hover { background: var(--danger-hover); }
|
||||||
|
|
||||||
input[type="text"], select, textarea {
|
input[type="text"], input[type="number"], select, textarea {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font: var(--text-sm) var(--font-ui);
|
font: var(--text-sm) var(--font-ui);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
@@ -75,7 +75,7 @@ input[type="text"], select, textarea {
|
|||||||
border: var(--border-w) solid var(--border-strong);
|
border: var(--border-w) solid var(--border-strong);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
input[type="text"], select { height: var(--control-h); padding: 0 var(--control-px-sm); }
|
input[type="text"], input[type="number"], select { height: var(--control-h); padding: 0 var(--control-px-sm); }
|
||||||
textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height: var(--leading-normal); }
|
textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height: var(--leading-normal); }
|
||||||
:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: var(--outline-offset); }
|
:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: var(--outline-offset); }
|
||||||
|
|
||||||
@@ -452,6 +452,68 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); colo
|
|||||||
/* A model's own picture fills the same square, cut to it: the avatar's size does not depend on it. */
|
/* A model's own picture fills the same square, cut to it: the avatar's size does not depend on it. */
|
||||||
.avatar--image { overflow: hidden; background: var(--surface); }
|
.avatar--image { overflow: hidden; background: var(--surface); }
|
||||||
.avatar--image img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
.avatar--image img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
/* The Thinking switch: a checkbox drawn as a switch, then its words, on two known tracks. The
|
||||||
|
label is the tap target; the whole of it is a control's height. */
|
||||||
|
.toggle {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: calc(2 * var(--check-size)) auto;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
height: var(--control-h);
|
||||||
|
color: var(--ink);
|
||||||
|
font: var(--text-sm) var(--font-ui);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.toggle--compact { height: var(--control-h-sm); }
|
||||||
|
.toggle input {
|
||||||
|
appearance: none;
|
||||||
|
position: relative;
|
||||||
|
width: calc(2 * var(--check-size));
|
||||||
|
height: var(--check-size);
|
||||||
|
margin: 0;
|
||||||
|
background: var(--surface-active);
|
||||||
|
border: var(--border-w) solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--dur-1) var(--ease-out);
|
||||||
|
}
|
||||||
|
.toggle input::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: var(--border-w);
|
||||||
|
left: var(--border-w);
|
||||||
|
width: calc(var(--check-size) - 4 * var(--border-w));
|
||||||
|
height: calc(var(--check-size) - 4 * var(--border-w));
|
||||||
|
background: var(--ink-muted);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
transition: transform var(--dur-1) var(--ease-out);
|
||||||
|
}
|
||||||
|
.toggle input:checked { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.toggle input:checked::after { background: var(--accent-ink); transform: translateX(var(--check-size)); }
|
||||||
|
/* The team's size: two number fields of one width with a dash between them, and in the header
|
||||||
|
its word before them -- fixed tracks, so nothing moves when a number grows a digit. */
|
||||||
|
.agents {
|
||||||
|
display: inline-grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
grid-template-areas: "min max";
|
||||||
|
gap: var(--sp-2);
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--control-h);
|
||||||
|
}
|
||||||
|
.agents__dash { display: none; }
|
||||||
|
.agents__input { width: 100%; min-width: 0; height: var(--control-h); padding: 0 var(--control-px-sm); }
|
||||||
|
.agents--compact {
|
||||||
|
grid-template-columns: auto calc(var(--w-field-xs) / 2) auto calc(var(--w-field-xs) / 2);
|
||||||
|
grid-template-areas: none;
|
||||||
|
width: auto;
|
||||||
|
height: var(--control-h-sm);
|
||||||
|
font: var(--text-sm) var(--font-ui);
|
||||||
|
}
|
||||||
|
.agents--compact .agents__dash { display: inline; color: var(--ink-muted); }
|
||||||
|
.agents--compact .agents__input { height: var(--control-h-sm); }
|
||||||
|
.agents__label { color: var(--ink-muted); white-space: nowrap; }
|
||||||
/* The model picker: a button the height of a control, avatar · name · chevron. */
|
/* The model picker: a button the height of a control, avatar · name · chevron. */
|
||||||
.picker {
|
.picker {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
|
|||||||
+72
-30
@@ -12,7 +12,7 @@ import { markdown } from "./markdown.js"
|
|||||||
import { openViewer, showKind } from "./viewer.js"
|
import { openViewer, showKind } from "./viewer.js"
|
||||||
import { readLocal } from "./explorer.js"
|
import { readLocal } from "./explorer.js"
|
||||||
import { menu, ask } from "./popup.js"
|
import { menu, ask } from "./popup.js"
|
||||||
import { avatar, modelPicker } from "./models.js"
|
import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js"
|
||||||
import { readyIds, sending, sent, discard } from "./attach.js"
|
import { readyIds, sending, sent, discard } from "./attach.js"
|
||||||
|
|
||||||
const MODES = ["manual", "edit", "auto", "plan"]
|
const MODES = ["manual", "edit", "auto", "plan"]
|
||||||
@@ -544,8 +544,21 @@ async function chatModels(chat) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chat's model, and its effort where the model offers more than one. */
|
/** Whether this chat may have a team (`GET /chats/{chat}/team`): only a chat on the server. */
|
||||||
function headModel(slot, chat, models) {
|
async function teamOffered(chat) {
|
||||||
|
if (chat.kind !== "chat") return false
|
||||||
|
try {
|
||||||
|
return !!(await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team?.offered
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chat's model; how hard it thinks, drawn as the model offers it (nothing for one level, the
|
||||||
|
* Thinking switch, or the selector); and the team's size where a team is offered.
|
||||||
|
*/
|
||||||
|
async function headModel(slot, chat, models) {
|
||||||
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
||||||
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
|
||||||
const parts = []
|
const parts = []
|
||||||
@@ -572,23 +585,38 @@ function headModel(slot, chat, models) {
|
|||||||
} else if (chat.model) {
|
} else if (chat.model) {
|
||||||
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
|
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
|
||||||
}
|
}
|
||||||
const efforts = current.efforts || []
|
const level = effortControl({
|
||||||
if (efforts.length > 1) {
|
model: current,
|
||||||
parts.push(
|
value: chat.effort || levelsOf(current).start,
|
||||||
h(
|
compact: true,
|
||||||
"select",
|
onChange: async (value) => {
|
||||||
{
|
try {
|
||||||
class: "head__mode",
|
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: value })
|
||||||
"aria-label": t("Reasoning effort"),
|
if (open?.chat?.id === chat.id) open.chat = r.chat
|
||||||
title: t("Reasoning effort"),
|
} catch (err) {
|
||||||
onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: e.target.value }).catch((err) => alertIn(why(err))),
|
alertIn(why(err))
|
||||||
},
|
level.value = open?.chat?.effort || levelsOf(current).start
|
||||||
chat.effort ? null : h("option", { value: "", text: "—", selected: true }),
|
|
||||||
efforts.map((x) => h("option", { value: x, text: x, selected: chat.effort === x })),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (level) parts.push(level)
|
||||||
slot.replaceChildren(...parts)
|
slot.replaceChildren(...parts)
|
||||||
|
if (!(await teamOffered(chat)) || open?.chat?.id !== chat.id || !slot.isConnected) return
|
||||||
|
const team = agentsControl({
|
||||||
|
value: open.chat.agents || chat.agents,
|
||||||
|
compact: true,
|
||||||
|
onChange: async (wanted) => {
|
||||||
|
try {
|
||||||
|
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { agents: wanted })
|
||||||
|
if (open?.chat?.id === chat.id) open.chat = r.chat
|
||||||
|
alertIn("")
|
||||||
|
} catch (err) {
|
||||||
|
alertIn(why(err))
|
||||||
|
team.value = open?.chat?.agents || chat.agents
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
slot.append(team)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
|
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
|
||||||
@@ -728,7 +756,7 @@ export function closeChat() {
|
|||||||
|
|
||||||
// --- A new chat ------------------------------------------------------------------------------------
|
// --- A new chat ------------------------------------------------------------------------------------
|
||||||
|
|
||||||
let draft = null // { kind }
|
let draft = null // { kind, level (the effort control), agents (the team's size) }
|
||||||
|
|
||||||
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
|
||||||
export function newChat(kind) {
|
export function newChat(kind) {
|
||||||
@@ -746,19 +774,31 @@ export function newChat(kind) {
|
|||||||
const s = ctx.status()
|
const s = ctx.status()
|
||||||
const models = ctx.models()
|
const models = ctx.models()
|
||||||
const start = (models.find((m) => m.default) || models[0])?.id || ""
|
const start = (models.find((m) => m.default) || models[0])?.id || ""
|
||||||
const effort = h("select", { id: "new-effort" })
|
// How hard it thinks, and the team's size: drawn for the chosen model, again when it changes.
|
||||||
const effortField = field(t("Reasoning effort"), effort, "")
|
// Each slot is a field of its own (label, control, hint), so the row lines up whichever is shown.
|
||||||
const showEfforts = (id) => {
|
const effortField = field(t("Reasoning effort"), h("span"), "")
|
||||||
const efforts = models.find((m) => m.id === id)?.efforts || []
|
const agentsField = field(t("Agents"), h("span"), t("Its own agent included. Above one, it can add members that do the work while it leads them."))
|
||||||
effort.replaceChildren(h("option", { value: "", text: "—" }), ...efforts.map((x) => h("option", { value: x, text: x })))
|
const showLevels = (id) => {
|
||||||
effortField.hidden = efforts.length < 2
|
const model = models.find((m) => m.id === id)
|
||||||
|
const control = effortControl({ model, value: levelsOf(model).start, id: "new-effort" })
|
||||||
|
const switchOnly = levelsOf(model).control === "switch"
|
||||||
|
effortField.children[0].textContent = switchOnly ? t("Thinking") : t("Reasoning effort")
|
||||||
|
effortField.children[0].htmlFor = "new-effort"
|
||||||
|
effortField.children[1].replaceWith(control || h("span", { id: "new-effort" }))
|
||||||
|
effortField.hidden = !control
|
||||||
|
draft.level = control
|
||||||
|
const team = kind === "chat" && teamPossible(model)
|
||||||
|
draft.agents = team ? agentsControl({ value: null, id: "new-agents" }) : null
|
||||||
|
agentsField.children[1].replaceWith(draft.agents || h("span", { id: "new-agents" }))
|
||||||
|
agentsField.children[0].htmlFor = "new-agents"
|
||||||
|
agentsField.hidden = !team
|
||||||
}
|
}
|
||||||
const modelField = field(
|
const modelField = field(
|
||||||
t("Model"),
|
t("Model"),
|
||||||
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showEfforts }),
|
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showLevels }),
|
||||||
models.length ? "" : t("No model is available to this account."),
|
models.length ? "" : t("No model is available to this account."),
|
||||||
)
|
)
|
||||||
showEfforts(start)
|
showLevels(start)
|
||||||
if (kind === "agent") {
|
if (kind === "agent") {
|
||||||
const roots = s.remote?.enabled ? s.remote.roots : []
|
const roots = s.remote?.enabled ? s.remote.roots : []
|
||||||
const devices = ctx.thisDevices()
|
const devices = ctx.thisDevices()
|
||||||
@@ -793,7 +833,7 @@ export function newChat(kind) {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
form.append(h("div", { class: "fields" }, modelField, effortField))
|
form.append(h("div", { class: "fields" }, modelField, effortField, agentsField))
|
||||||
}
|
}
|
||||||
showComposer(true)
|
showComposer(true)
|
||||||
setRunning(false)
|
setRunning(false)
|
||||||
@@ -813,8 +853,10 @@ async function createChat(text, attachments) {
|
|||||||
if (attachments.length) body.attachments = attachments
|
if (attachments.length) body.attachments = attachments
|
||||||
const model = $("new-model")?.value
|
const model = $("new-model")?.value
|
||||||
if (model) body.model = model
|
if (model) body.model = model
|
||||||
const effort = $("new-effort")?.value
|
const effort = draft.level?.value
|
||||||
if (effort) body.effort = effort
|
if (typeof effort === "string" && effort) body.effort = effort
|
||||||
|
const agents = draft.agents?.value
|
||||||
|
if (agents && (agents.min || agents.max)) body.agents = agents
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const device = ctx.thisDevices()[0]
|
const device = ctx.thisDevices()[0]
|
||||||
const dir = $("new-dir")?.value
|
const dir = $("new-dir")?.value
|
||||||
|
|||||||
+139
@@ -122,3 +122,142 @@ export function modelPicker({ id, models, value, onChange, label }) {
|
|||||||
draw()
|
draw()
|
||||||
return btn
|
return btn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- How hard a model thinks, and how many agents a chat may have --------------------------------
|
||||||
|
//
|
||||||
|
// The levels are the harness spec's (3.2.0, `models/schema.json`), as /api/v2/models gives them:
|
||||||
|
// `levels` in offering order (`off` first where the model can switch thinking off, then its
|
||||||
|
// efforts, or the one level `default`), `effort` the start level and `control` how a chat draws
|
||||||
|
// them -- `none` (one level: nothing to choose), `switch` (off and one effort: thinking on or
|
||||||
|
// off) or `slider` (more: a choice of each). The chat says the level in force as its `effort`.
|
||||||
|
|
||||||
|
/** A model's levels: { levels, start, control }, read the server's way when a field is missing. */
|
||||||
|
export function levelsOf(model) {
|
||||||
|
const given = Array.isArray(model?.levels) && model.levels.length ? model.levels : null
|
||||||
|
const levels = given || (model?.efforts?.length ? model.efforts : ["default"])
|
||||||
|
const control =
|
||||||
|
["none", "switch", "slider"].includes(model?.control) ? model.control
|
||||||
|
: levels.length < 2 ? "none"
|
||||||
|
: levels.length === 2 && levels.includes("off") ? "switch"
|
||||||
|
: "slider"
|
||||||
|
const start = levels.includes(model?.effort) ? model.effort : levels[0]
|
||||||
|
return { levels, start, control }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A level as the person reads it: `off` and `default` in their language, an effort as it is. */
|
||||||
|
export function levelWord(level) {
|
||||||
|
return level === "off" ? t("off") : level === "default" ? t("default") : String(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The control for a model's levels, or null where it has one level: the Thinking switch (a real
|
||||||
|
* checkbox with the switch role, the label its tap target) or the selector. `value` is the level
|
||||||
|
* in force; `onChange(level)` when the person changes it. The element's `value` is the level.
|
||||||
|
*/
|
||||||
|
export function effortControl({ model, value, onChange, id, compact }) {
|
||||||
|
const { levels, start, control } = levelsOf(model)
|
||||||
|
if (control === "none") return null
|
||||||
|
let current = levels.includes(value) ? value : start
|
||||||
|
if (control === "switch") {
|
||||||
|
const on = levels.find((l) => l !== "off") || start
|
||||||
|
const box = h("input", { type: "checkbox", role: "switch", id, checked: current !== "off" })
|
||||||
|
const el = h(
|
||||||
|
"label",
|
||||||
|
{ class: `toggle${compact ? " toggle--compact" : ""}`, title: t("Think before answering") },
|
||||||
|
box,
|
||||||
|
h("span", { class: "toggle__label", text: compact ? t("Thinking") : t("Think before answering") }),
|
||||||
|
)
|
||||||
|
if (compact) box.setAttribute("aria-label", t("Think before answering"))
|
||||||
|
box.addEventListener("change", () => {
|
||||||
|
current = box.checked ? on : "off"
|
||||||
|
onChange?.(current)
|
||||||
|
})
|
||||||
|
Object.defineProperty(el, "value", {
|
||||||
|
get: () => current,
|
||||||
|
set: (v) => {
|
||||||
|
current = v
|
||||||
|
box.checked = v !== "off"
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
const el = h(
|
||||||
|
"select",
|
||||||
|
{ id, class: compact ? "head__mode" : null, "aria-label": t("Reasoning effort"), title: t("Reasoning effort") },
|
||||||
|
levels.map((l) => h("option", { value: l, text: levelWord(l), selected: l === current })),
|
||||||
|
)
|
||||||
|
el.addEventListener("change", () => {
|
||||||
|
current = el.value
|
||||||
|
onChange?.(current)
|
||||||
|
})
|
||||||
|
return el
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a new chat on `model` may have a team: a chat on the server (an agent chat's team runs
|
||||||
|
* on its device), a model that calls tools and serves two requests at once (`parallel` null is a
|
||||||
|
* hosted API with no stated limit). The server decides again; this only keeps the fields away
|
||||||
|
* where they could never apply. An open chat asks the server (`GET /chats/{chat}/team`).
|
||||||
|
*/
|
||||||
|
export function teamPossible(model) {
|
||||||
|
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The team's size: the fewest and the most agents, the chat's own agent included, as two number
|
||||||
|
* fields on one shared track. `value` is { min, max } (empty fields for a new chat: the person's
|
||||||
|
* default for new chats then stands); `onChange({ min, max })` once a field is changed, with the
|
||||||
|
* empty one left out. The server checks the pair and says the bounds when it refuses one.
|
||||||
|
*/
|
||||||
|
export function agentsControl({ value, onChange, id, compact }) {
|
||||||
|
const field = (which, v, label) =>
|
||||||
|
h("input", {
|
||||||
|
type: "number",
|
||||||
|
class: "agents__input",
|
||||||
|
id: which === "min" ? id : null,
|
||||||
|
min: "1",
|
||||||
|
step: "1",
|
||||||
|
inputmode: "numeric",
|
||||||
|
value: v === undefined || v === null ? "" : String(v),
|
||||||
|
placeholder: compact ? null : t("default"),
|
||||||
|
"aria-label": label,
|
||||||
|
title: label,
|
||||||
|
})
|
||||||
|
const low = field("min", value?.min, t("Fewest"))
|
||||||
|
const high = field("max", value?.max, t("Most"))
|
||||||
|
const bounds = () => {
|
||||||
|
high.min = low.value || "1"
|
||||||
|
if (high.value) low.max = high.value
|
||||||
|
else low.removeAttribute("max")
|
||||||
|
}
|
||||||
|
bounds()
|
||||||
|
const read = () => {
|
||||||
|
const out = {}
|
||||||
|
if (low.value) out.min = Number(low.value)
|
||||||
|
if (high.value) out.max = Number(high.value)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for (const input of [low, high]) {
|
||||||
|
input.addEventListener("change", () => {
|
||||||
|
bounds()
|
||||||
|
onChange?.(read())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const el = h(
|
||||||
|
"span",
|
||||||
|
{ class: `agents${compact ? " agents--compact" : ""}`, role: "group", "aria-label": t("Agents"), title: t("Its own agent included. Above one, it can add members that do the work while it leads them.") },
|
||||||
|
compact ? h("span", { class: "agents__label", text: t("Agents") }) : null,
|
||||||
|
low,
|
||||||
|
h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }),
|
||||||
|
high,
|
||||||
|
)
|
||||||
|
Object.defineProperty(el, "value", {
|
||||||
|
get: read,
|
||||||
|
set: (v) => {
|
||||||
|
low.value = v?.min ?? ""
|
||||||
|
high.value = v?.max ?? ""
|
||||||
|
bounds()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return el
|
||||||
|
}
|
||||||
Reference in new issue
Block a user