Subagents by name; the activity panel as the reference: header button, compact, tasks
ci / check (push) Failing after 3s
ci / start (push) Failing after 4s

- "Subagents" everywhere a person reads it (chip, popover with Fewest/Most, scheduled task,
  panel); offered per the server's `subagents` on a model or chat where it says it, else as
  before; the fields' most is the server's cap, else 32. No subagent-model setting.
- Panel: always reachable from the header's button; the line above the composer only while
  something runs; Environment for an agent chat; Context with "N until auto-compact" and a
  Compact button (POST /chats/{chat}/compact; a server without it is said so); Background
  tasks under Running with elapsed time and a stop square each, unfolding to kind, step,
  live text and conversation, nested tasks folded under their parent, Finished N folded;
  Room and Plan as sections.
- i18n: 24 new sentences x 8 languages, 9 retired; CHANGELOG [Unreleased].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 16:55:09 +00:00
1 parent 4058b4b018
commit 6bad293b29
10 files changed
+773 -236

No files matched your search

+218 -76
View File
@@ -1,14 +1,18 @@
// The chat's activity panel, beside it: what is in its context, by category, against the model's
// window; the files the chat and its agents wrote; every background agent -- a team's members as
// a tree, helpers and commands -- with its state, how long it has run, its model, its latest step
// and what it is writing, its whole conversation read-only and a button to stop it; the team's
// room; and the plan. The strip above the message box sums the agents up and opens it. The same
// facts as the server's own panel (`services/activity.py`), from /api/v2:
// The chat's activity panel, beside it, opened from the header's button at any time: where an
// agent chat works; what is in its context, by category, against the model's window, how far it
// is to the automatic compaction and a button to compact now; the files the chat and its
// subagents wrote; every background task -- subagents, a team's members as a tree under the one
// that leads them, background commands -- with how long it has run and a stop square, unfolding
// to its latest step, what it is writing and its whole conversation, read-only; the finished ones
// folded; the team's room; and the plan. The line above the message box sums up what runs, while
// anything does, and opens it. The same facts as the server's own panel (`services/activity.py`),
// from /api/v2:
//
// GET /chats/{chat}/activity?since=&context= everything, with a `version`
// GET /chats/{chat}/activity/events `activity` {version, summary} on each change
// GET /chats/{chat}/agents/{kind}/{id} one agent and its conversation
// GET /chats/{chat}/agents/{kind}/{id} one task and its conversation
// POST /chats/{chat}/agents/{kind}/{id}/stop
// POST /chats/{chat}/compact summarise the earlier turns now
//
// The events stream is followed for as long as a chat is open: it keeps the strip current, and
// while the panel is open a new `version` reads the panel again. The context is the costly part
@@ -31,6 +35,8 @@ const SHOWN = 6
const CONTEXT_EVERY = 15000
let layout = () => {} // (on) -> the shell makes room for the panel
let onThisComputer = () => false // (chat) -> whether an agent chat works on this computer
let onCompacted = () => {} // (chat id) -> the conversation is read again
let chat = null // the open chat: { id, kind, … }
let stream = null // the events stream's handle
let retry = 0 // the reconnect timer
@@ -69,12 +75,14 @@ export function compact(value) {
return `${trim(n / 1000000, 2)}M`
}
/** "3 agents working · 1 queued · 2 finished", or "" for none (the server's `summary_text`). */
/**
* "3 running · 1 queued · 2 finished", or "" for none: every background task -- a subagent, a
* team's member, a command -- counts the same, as the panel lists them together.
*/
export function summaryText(counts) {
if (!counts) return ""
const parts = []
if (counts.working === 1) parts.push(t("1 agent working"))
else if (counts.working) parts.push(t("%(n)s agents working", { n: counts.working }))
if (counts.working) parts.push(t("%(n)s running", { n: counts.working }))
if (counts.queued) parts.push(t("%(n)s queued", { n: counts.queued }))
if (counts.waiting) parts.push(t("%(n)s waiting", { n: counts.waiting }))
if (counts.finished) parts.push(t("%(n)s finished", { n: counts.finished }))
@@ -141,9 +149,10 @@ export function watchActivity(next) {
cursor = 0
readAt = contextAt = 0
view = null
compactError = ""
openRows.clear()
renderStrip()
$("activity-open")?.setAttribute("aria-expanded", String(panelOpen()))
markOpen()
if (!chat) {
if (panelOpen()) showPanel(false)
return
@@ -269,36 +278,58 @@ async function read(withContext) {
}
}
// --- The strip above the message box ---------------------------------------------------------------
// --- The line above the message box, and the header's button ---------------------------------------
/** Both ways into the panel say whether it is open. */
function markOpen() {
for (const id of ["activity-open", "activity-strip-btn"]) $(id)?.setAttribute("aria-expanded", String(panelOpen()))
}
function renderStrip() {
const strip = $("activity-strip")
// A chip for every open chat: what runs, while something does, as the server's strip says it (a
// chat whose helpers all finished long ago does not carry "12 finished" for ever: the panel
// still lists them); else only the way into the panel.
strip.hidden = !chat
if (!chat) return clear(strip)
const active = summary?.active ? summaryText(summary) : ""
const text = active || t("Activity")
// Only while something runs, as the server's own line: the header's button is the way into the
// panel at any other time (and a chat whose tasks all finished long ago does not carry
// "12 finished" for ever: the panel still lists them).
const active = chat && summary?.active ? summaryText(summary) : ""
strip.hidden = !active
if (!active) return clear(strip)
strip.replaceChildren(
h(
"button",
{
type: "button",
id: "activity-open",
class: `strip__btn${active ? " is-active" : ""}`,
"aria-label": `${text} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
id: "activity-strip-btn",
class: "strip__btn is-active",
"aria-label": `${active} — ${panelOpen() ? t("Close the activity panel") : t("Open the activity panel")}`,
"aria-controls": "activity",
"aria-expanded": String(panelOpen()),
onclick: togglePanel,
},
h("span", { class: "dot", "aria-hidden": "true" }),
h("span", { class: "strip__text", text }),
h("span", { class: "strip__text", text: active }),
icon(ICONS.chevron),
),
)
}
/** The header's button: the panel, open or closed, for every open chat. */
export function activityButton() {
return h(
"button",
{
type: "button",
class: "tool",
id: "activity-open",
"aria-label": t("Context and background tasks"),
title: t("Context and background tasks"),
"aria-controls": "activity",
"aria-expanded": String(panelOpen()),
onclick: togglePanel,
},
icon(ICONS.tasks),
)
}
// --- The panel ------------------------------------------------------------------------------------------
/** Open or close the panel (the header's Activity button, the strip, Escape). */
@@ -306,14 +337,16 @@ export function showPanel(on) {
if (on && !chat) return
$("activity").hidden = !on
layout(on)
$("activity-open")?.setAttribute("aria-expanded", String(on))
markOpen()
clearInterval(ticker)
ticker = 0
if (!on) {
view = null
renderStrip()
return
}
ticker = setInterval(tick, 1000)
renderStrip()
render()
read(true)
$("activity-close").focus()
@@ -359,15 +392,43 @@ function render() {
}
// Kept where the reader was: the panel is drawn again on every change.
const scroll = body.scrollTop
body.replaceChildren(
const entries = data.agents || []
const parts = [
chat?.kind === "agent" && chat.device ? section("environment", t("Environment"), 0, environmentPart(chat)) : null,
section("context", t("Context"), 0, contextPart(data.context)),
section("outputs", t("Outputs"), (data.outputs || []).length, outputsPart(data.outputs || [])),
section("tasks", t("Background tasks"), 0, tasksPart()),
section("tasks", t("Background tasks"), 0, tasksPart(entries)),
data.team ? section("room", t("Room"), room.length, roomPart()) : null,
data.plan ? section("plan", t("Plan"), 0, h("div", { class: "activity__plan" }, planList(data.plan))) : null,
)
]
body.replaceChildren(...parts.filter(Boolean))
body.scrollTop = scroll
}
/** How many tasks, nested ones included: those still at work, or those finished. */
function count(list, active) {
let n = 0
for (const a of list) {
if (ACTIVE.includes(a.state) === active) n += 1
n += count(a.children || [], active)
}
return n
}
/** Where an agent chat works: its folder, on this computer or another device. */
function environmentPart(c) {
const dir = c.device?.directory || ""
const name = dir.split(/[\\/]/).filter(Boolean).pop() || dir
const here = onThisComputer(c)
return h(
"p",
{ class: "env", title: dir },
icon(ICONS.folder),
h("bdi", { class: "env__name", text: name }),
h("span", { class: "env__where", text: here ? t("This computer") : t("Another device") }),
)
}
function contextPart(c) {
if (!c) return h("p", { class: "activity__empty", text: t("Not measured.") })
const known = c.percent !== null && c.percent !== undefined
@@ -410,7 +471,62 @@ function contextPart(c) {
{ class: "ctx__note" },
`${c.measured ? t("Counted by the model at the last reply; the categories are estimated.") : t("Estimated: no reply has been counted yet.")} ${t("%(n)s tools offered.", { n: c.tools_count ?? 0 })}`,
)
return h("div", { class: "ctx" }, used, bar, rows, note)
return h("div", { class: "ctx" }, used, bar, rows, compactRow(c), note)
}
/**
* How far it is to the automatic compaction -- the server compacts before a turn once the context
* would reach `threshold` % of the window; 0 is off -- and the button that compacts now.
*/
function compactRow(c) {
const limit = Number(c.limit) || 0
const threshold = Number(c.threshold)
let words
if (!(threshold > 0)) words = t("Automatic compaction is off.")
else if (!limit || threshold >= 100) words = t("Compacts by itself once the window is known.")
else {
const until = Math.floor((limit * threshold) / 100) - (Number(c.used) || 0)
words = until > 0 ? t("%(tokens)s until auto-compact", { tokens: compact(until) }) : t("Compacts before the next reply.")
}
const busy = compacting === chat?.id
return h(
"div",
{ class: "ctx__compact" },
h("span", { class: "ctx__until", text: words }),
h("button", {
type: "button",
class: "btn btn--sm",
text: busy ? t("Compacting…") : t("Compact"),
disabled: busy,
title: t("Summarise the earlier turns now and send the summary in their place."),
onclick: compactNow,
}),
compactError ? h("p", { class: "ctx__error", role: "alert", text: compactError }) : null,
)
}
let compacting = null // the chat being compacted
let compactError = ""
async function compactNow() {
if (!chat || compacting) return
const id = chat.id
compacting = id
compactError = ""
render()
try {
await post(`/chats/${enc(id)}/compact`)
compacting = null
if (chat?.id !== id) return
onCompacted(id)
read(true)
} catch (err) {
compacting = null
if (chat?.id !== id) return
// A server without the route in its /api/v2 says 404 or 405: said as what it means.
compactError = err?.status === 404 || err?.status === 405 ? t("This server cannot compact a chat from the app yet; the web interface can.") : why(err)
render()
}
}
/** A category's key as a class name: the server's words, kept to letters and underscores. */
@@ -439,64 +555,71 @@ function outputsPart(outputs) {
)
}
function tasksPart() {
const entries = data.agents || []
function tasksPart(entries) {
const team = data.team
const box = h("div", { class: "tasks", "aria-live": "polite" })
if (!entries.length && !team) {
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Helpers, commands and a team's members show here while they work.") }))
box.append(h("p", { class: "activity__empty", text: t("Nothing runs in the background. Subagents, a team's members and background commands show here while they work.") }))
return box
}
box.append(h("p", { class: "tasks__summary", text: summaryText(data.summary) || t("Nothing running.") }))
if (team) {
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of %(max)s agents", { size: team.size, max: team.max }) }))
box.append(h("p", { class: "tasks__team", text: t("Team: %(size)s of at most %(max)s subagents", { size: team.size, max: team.max }) }))
if (team.goal) box.append(h("p", { class: "tasks__goal", text: team.goal }))
}
const live = entries.filter((e) => ACTIVE.includes(e.state))
const ended = entries.filter((e) => !ACTIVE.includes(e.state))
// At the top level, what still runs -- with its own tasks under it, finished or not -- and then
// what finished with nothing under it still at work, folded.
const live = entries.filter((e) => ACTIVE.includes(e.state) || count(e.children || [], true))
const ended = entries.filter((e) => !live.includes(e))
box.append(h("p", { class: "tasks__group" }, h("span", { text: t("Running") }), h("span", { class: "activity__count", text: String(count(entries, true)) })))
if (live.length) {
box.append(h("div", { class: "tasks__list" }, live.slice(0, SHOWN).map(agentRows)))
if (live.length > SHOWN) box.append(fold("more", t("Show %(n)s more", { n: live.length - SHOWN }), live.slice(SHOWN)))
}
if (ended.length) box.append(fold("finished", t("%(n)s finished", { n: ended.length }), ended))
if (team) {
box.append(h("h3", { class: "activity__subheading", text: t("Room") }))
box.append(
room.length
? h(
"ol",
{ class: "room" },
room.map((m) =>
h(
"li",
{ class: `room__entry${m.report ? " is-report" : ""}` },
h(
"div",
{ class: "room__meta" },
h("span", { class: "room__n", text: `#${m.n}` }),
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
),
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
),
),
)
: h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") }),
)
}
} else box.append(h("p", { class: "activity__empty", text: t("Nothing running.") }))
if (ended.length) box.append(fold("finished", t("Finished %(n)s", { n: count(ended, false) }), ended, "tasks__finished"))
return box
}
/** A folded list of agents ("Show 3 more", "12 finished"), remembered open across redraws. */
function fold(name, label, list) {
const el = h("details", { class: "tasks__more", open: openRows.has(`fold:${name}`) }, h("summary", { text: label }), h("div", { class: "tasks__list" }, list.map(agentRows)))
function roomPart() {
if (!room.length) return h("p", { class: "activity__empty", text: t("Nothing has been said in the room yet.") })
return h(
"ol",
{ class: "room" },
room.map((m) =>
h(
"li",
{ class: `room__entry${m.report ? " is-report" : ""}` },
h(
"div",
{ class: "room__meta" },
h("span", { class: "room__n", text: `#${m.n}` }),
h("span", { class: "room__route", text: `${m.from} → ${(m.to || []).join(", ")}` }),
m.report ? h("span", { class: "chip room__badge", text: t("report") }) : null,
),
h("div", { class: "room__text md" }, markdown(String(m.text || ""))),
),
),
)
}
/** A folded list of tasks ("Show 3 more", "Finished 12", "2 tasks"), remembered across redraws. */
function fold(name, label, list, cls = "") {
const el = h(
"details",
{ class: `tasks__more ${cls}`, open: openRows.has(`fold:${name}`) },
h("summary", null, h("span", { class: "tasks__chev", "aria-hidden": "true" }, icon(ICONS.chevron)), h("span", { text: label })),
h("div", { class: "tasks__list" }, list.map(agentRows)),
)
el.addEventListener("toggle", () => (el.open ? openRows.add(`fold:${name}`) : openRows.delete(`fold:${name}`)))
return el
}
/** An agent and, under it, the agents it leads or started. */
/** A task and, folded under it, the tasks it leads or started ("1 task", "3 tasks"). */
function agentRows(a) {
return [agentRow(a), ...(a.children || []).map(agentRows)]
const kids = a.children || []
if (!kids.length) return agentRow(a)
const nested = fold(`under:${a.kind}:${a.id}`, kids.length === 1 ? t("1 task") : t("%(n)s tasks", { n: kids.length }), kids, "tasks__nested")
nested.style.setProperty("--depth", String(Math.max(0, Math.min(6, (Number(a.depth) || 0) + 1))))
return [agentRow(a), nested]
}
function agentRow(a) {
@@ -512,7 +635,7 @@ function agentRow(a) {
"button",
{
type: "button",
class: "pane__btn agent__stop",
class: "agent__stop",
"aria-label": t("Stop %(name)s", { name: a.name }),
title: t("Stop %(name)s", { name: a.name }),
onclick: (e) => {
@@ -521,13 +644,15 @@ function agentRow(a) {
stopAgent(a)
},
},
icon(ICONS.stop),
h("span", { class: "agent__square", "aria-hidden": "true" }),
)
: h("span", { class: "agent__stop", "aria-hidden": "true" })
: h("span", { class: "agent__stop is-none", "aria-hidden": "true" })
const facts = h(
"p",
{ class: "agent__facts" },
h("span", { class: "agent__state", text: stateWord(a.state) }),
h("span", { text: kindWord(a) }),
a.kind !== "member" && a.role ? h("code", { class: "agent__id", text: a.name || a.id }) : null,
a.agent ? h("span", { text: a.agent }) : null,
a.model ? h("span", { class: "agent__model", text: a.model }) : null,
a.steps ? h("span", { text: t("%(steps)s steps, %(tools)s tools", { steps: a.steps, tools: a.tools }) }) : null,
@@ -547,8 +672,8 @@ function agentRow(a) {
h(
"summary",
{ class: "agent__row" },
h("span", { class: "agent__dot", "aria-hidden": "true" }),
h("span", { class: "agent__what" }, h("span", { class: "agent__name", text: a.name || a.id }), a.role ? h("span", { class: "agent__role", text: a.role }) : null),
h("span", { class: "agent__chev", "aria-hidden": "true" }, icon(ICONS.chevron)),
h("span", { class: "agent__what", title: label(a) }, h("bdi", { class: "agent__name", text: label(a) })),
time,
stopBtn,
),
@@ -556,12 +681,11 @@ function agentRow(a) {
"div",
{ class: "agent__body" },
facts,
a.kind === "member" && a.role ? h("p", { class: "agent__role", text: a.role }) : null,
a.brief ? h("p", { class: "agent__brief", text: a.brief }) : null,
step,
a.text ? h("p", { class: "agent__text", text: a.text }) : null,
a.transcript
? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) })
: null,
a.transcript ? h("button", { type: "button", class: "btn btn--sm", text: t("Open its conversation"), onclick: () => openAgent(a) }) : null,
),
)
el.style.setProperty("--depth", String(Math.max(0, Math.min(6, Number(a.depth) || 0))))
@@ -569,6 +693,22 @@ function agentRow(a) {
return el
}
/**
* A task's line: a team's member by its name (it is how the room and the others call it); a
* subagent or a command by what it was given to do, its id only where there is nothing else.
*/
function label(a) {
if (a.kind === "member") return a.name || a.id
return a.role || a.brief || a.name || a.id
}
/** What a task is: a team's member, a subagent, or a background command. */
function kindWord(a) {
if (a.kind === "member") return t("Team member")
if (a.type === "command") return t("Background command")
return t("Subagent")
}
async function stopAgent(a) {
if (!chat) return
if (a.kind === "member") {
@@ -667,8 +807,10 @@ async function readAgent() {
// --- Start ------------------------------------------------------------------------------------------
export function initActivity({ onLayout }) {
export function initActivity({ onLayout, here, compacted }) {
layout = onLayout || layout
onThisComputer = here || onThisComputer
onCompacted = compacted || onCompacted
$("activity-close").replaceChildren(icon(ICONS.close))
$("activity-close").addEventListener("click", () => showPanel(false))
$("activity-back").replaceChildren(icon(ICONS.back))
+32 -18
View File
@@ -13,9 +13,9 @@ import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal } from "./explorer.js"
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
import { avatar, agentsControl, teamPossible, levelsOf, levelWord, picture } from "./models.js"
import { avatar, agentsControl, teamPossible, subagentsCap, SUBAGENTS_HINT, levelsOf, levelWord, picture } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, planList } from "./activity.js"
import { watchActivity, activityChanged, planList, activityButton } from "./activity.js"
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
import { folderName } from "./list.js"
@@ -30,7 +30,7 @@ export function openChatId() {
// --- Reading -----------------------------------------------------------------------------------
function onThisComputer(chat) {
export function onThisComputer(chat) {
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
}
@@ -552,6 +552,7 @@ function renderHead(chat) {
)
}
}
tools.append(activityButton())
tools.append(h("button", { type: "button", class: "tool", "aria-label": t("More"), title: t("More"), onclick: (e) => chatMenu(e.currentTarget, open?.chat || chat) }, icon(ICONS.more)))
}
@@ -564,13 +565,20 @@ async function chatModels(chat) {
}
}
/** Whether this chat may have a team (`GET /chats/{chat}/team`): only a chat on the server. */
/**
* Whether this chat is offered subagents, and the instance's most: the chat's own word where the
* server gives one (`subagents` on the chat), else its team's (`GET /chats/{chat}/team`) -- only a
* chat on the server. null where it is not offered.
*/
async function teamOffered(chat) {
if (chat.kind !== "chat") return false
const said = chat.subagents
if (said !== undefined && said !== null) return (typeof said === "object" ? said.offered : said) ? { cap: subagentsCap(said, ctx.me()) } : null
if (chat.kind !== "chat") return null
try {
return !!(await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team?.offered
const team = (await get(`/chats/${encodeURIComponent(chat.id)}/team`)).team
return team?.offered ? { cap: subagentsCap(team, ctx.me()) } : null
} catch {
return false
return null
}
}
@@ -640,26 +648,31 @@ function effortChip(model, value, onChange) {
})
}
/** The team's size: a chip saying it, opening the two fields. `value` { min, max } or empty. */
function agentsChip(value, onChange) {
/**
* The subagents a chat may run: a chip saying the fewest and the most, opening the two fields.
* `value` { min, max } or empty; `cap` the instance's most, where the server says it.
*/
function agentsChip(value, onChange, cap) {
const said = value?.min || value?.max ? `${value.min ?? "…"}–${value.max ?? "…"}` : t("default")
return chip({
ico: ICONS.users,
label: `${t("Agents")} ${said}`,
title: t("Its own agent included. Above one, it can add members that do the work while it leads them."),
label: `${t("Subagents")} ${said}`,
title: SUBAGENTS_HINT(),
cls: "cchip--subagents",
onclick: (e) => {
const fields = agentsControl({ value, id: "agents-min" })
const fields = agentsControl({ value, id: "agents-min", cap })
popover(
e.currentTarget,
h(
"div",
{ class: "pop__body" },
h("label", { class: "pop__title", for: "agents-min", text: t("Agents") }),
h("label", { class: "pop__title", for: "agents-min", text: t("Subagents") }),
h("div", { class: "agents__heads", "aria-hidden": "true" }, h("span", { text: t("Fewest") }), h("span"), h("span", { text: t("Most") })),
fields,
h("p", { class: "hint", text: t("Its own agent included. Above one, it can add members that do the work while it leads them.") }),
h("p", { class: "hint", text: SUBAGENTS_HINT() }),
h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Save"), onclick: () => (closePopover(), onChange(fields.value)) })),
),
{ up: true, label: t("Agents") },
{ up: true, label: t("Subagents") },
)
},
})
@@ -705,7 +718,7 @@ async function renderChips() {
renderChips()
}, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips())))
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), subagentsCap(model?.subagents, ctx.me())))
box.replaceChildren(...parts.filter(Boolean))
return
}
@@ -732,8 +745,9 @@ async function renderChips() {
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
box.replaceChildren(...parts.filter(Boolean))
if (!(await teamOffered(chat)) || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a })))
const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer.cap))
}
/**
+1
View File
@@ -81,6 +81,7 @@ export const ICONS = {
attach: "M7.25 2.5h1.5v4.75h4.75v1.5H8.75v4.75h-1.5V8.75H2.5v-1.5h4.75z",
bolt: "M9.5 1L3 9.25h4.25L6.5 15 13 6.75H8.75z",
stop: "M4.5 4.5h7v7h-7z",
tasks: "M2 3.25h2v2H2zM5.5 3.5H14V5H5.5zM2 7h2v2H2zM5.5 7.25H14v1.5H5.5zM2 10.75h2v2H2zM5.5 11H14v1.5H5.5z",
back: "M10 3.5L5.5 8 10 12.5 11.1 11.4 7.7 8 11.1 4.6z",
terminal: "M1.5 2.5h13v11h-13zM3 4v8h10V4zM4.2 5.4l2.6 2.6-2.6 2.6-1-1L4.8 8 3.2 6.4zM7.5 10h4v1.5h-4z",
search: "M7 2a5 5 0 013.95 8.07l3.24 3.24-1.06 1.06-3.24-3.24A5 5 0 117 2zm0 1.5a3.5 3.5 0 100 7 3.5 3.5 0 000-7z",
+4 -1
View File
@@ -12,7 +12,7 @@ import { renderSettings, initSettings, scrollToSection } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind } from "./chat.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind, onThisComputer } from "./chat.js"
import { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
@@ -282,6 +282,9 @@ async function boot() {
onLayout: (on) => {
$("shell").dataset.activity = String(on)
},
here: onThisComputer,
// The conversation as it now stands: the summary's divider, read again, if it is still open.
compacted: (id) => openChatId() === id && openChat(id),
})
$("new-chat-btn").prepend(icon(ICONS.plus))
+30 -14
View File
@@ -123,7 +123,7 @@ export function modelPicker({ id, models, value, onChange, label }) {
return btn
}
// --- How hard a model thinks, and how many agents a chat may have --------------------------------
// --- How hard a model thinks, and how many subagents 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
@@ -193,28 +193,45 @@ export function effortControl({ model, value, onChange, id, compact }) {
}
/**
* 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`).
* Whether a new chat on `model` is offered subagents. The server says so where it can: a model's
* `subagents` in `/models` (a flag, or `{offered}`), true when subagents are on for the model and
* the instance and at least one model could serve as one -- the model itself with room for two
* requests at once, or a usable model outside its one-model-at-a-time server. A server that does
* not say it yet: 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 chip away where
* it could never apply. An open chat asks the server (`GET /chats/{chat}/team`).
*/
export function teamPossible(model) {
const said = model?.subagents
if (said !== undefined && said !== null) return typeof said === "object" ? !!said.offered : !!said
return !!model?.tools && (model.parallel === null || model.parallel === undefined || model.parallel >= 2)
}
/** The most subagents the instance allows a chat (`cap`), where the server says it; else 32. */
export const SUBAGENTS_CAP = 32
export const SUBAGENTS_HINT = () => t("The fewest and the most subagents this chat runs. They work on the same model unless you name another one in the chat.")
export function subagentsCap(...sources) {
for (const s of sources) {
const n = Number(s?.cap ?? s?.subagents_cap ?? s?.subagents?.cap)
if (Number.isFinite(n) && n >= 1) return n
}
return SUBAGENTS_CAP
}
/**
* 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.
* The subagents a chat may run: the fewest and the most, as two number fields on one shared
* track. `value` is { min, max } (empty fields for a new chat: the server's defaults then stand);
* `cap` the instance's most; `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 }) {
export function agentsControl({ value, onChange, id, compact, cap = SUBAGENTS_CAP }) {
const field = (which, v, label) =>
h("input", {
type: "number",
class: "agents__input",
id: which === "min" ? id : null,
min: "1",
max: String(cap),
step: "1",
inputmode: "numeric",
value: v === undefined || v === null ? "" : String(v),
@@ -226,8 +243,7 @@ export function agentsControl({ value, onChange, id, compact }) {
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")
low.max = high.value || String(cap)
}
bounds()
const read = () => {
@@ -244,8 +260,8 @@ export function agentsControl({ value, onChange, id, compact }) {
}
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,
{ class: `agents${compact ? " agents--compact" : ""}`, role: "group", "aria-label": t("Subagents"), title: SUBAGENTS_HINT() },
compact ? h("span", { class: "agents__label", text: t("Subagents") }) : null,
low,
h("span", { class: "agents__dash", "aria-hidden": "true", text: "–" }),
high,
+4 -4
View File
@@ -341,7 +341,7 @@ async function drawTask(id) {
fact(t("Last run"), statusChip(task.last_run, task.running)),
fact(t("Model"), modelWords(models, task.model)),
fact(t("Reasoning effort"), h("span", { text: task.effort ? levelWord(task.effort) : t("The model's own") })),
fact(t("Agents"), h("span", { text: task.agents ? t("A team of up to %(n)s", { n: task.agents }) : t("No team") })),
fact(t("Subagents"), h("span", { text: task.agents ? t("Up to %(n)s at once", { n: task.agents }) : t("None") })),
fact(t("Tell me"), h("span", { text: NOTIFY[task.notify]?.() || NOTIFY.matters() })),
fact(t("Runs"), h("span", { text: MODE[task.mode]?.() || MODE.fresh() })),
),
@@ -582,15 +582,15 @@ async function drawForm(task) {
const agents = h(
"select",
{ id: "sched-agents", onchange: (e) => (draft.agents = Number(e.target.value) || null) },
h("option", { value: "", text: t("No team"), selected: !draft.agents }),
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("A team of up to %(n)s", { n }), selected: draft.agents === n })),
h("option", { value: "", text: t("None"), selected: !draft.agents }),
Array.from({ length: MAX_AGENTS - 1 }, (_, i) => i + 2).map((n) => h("option", { value: String(n), text: t("Up to %(n)s at once", { n }), selected: draft.agents === n })),
)
settings.replaceChildren(
field(t("Tell me"), select("sched-notify", NOTIFY, "notify", () => (settings.querySelector("#sched-notify-hint").textContent = NOTIFY_HINT[draft.notify]())), NOTIFY_HINT[draft.notify]?.() || "", "sched-notify", "sched-notify-hint"),
field(t("Runs"), select("sched-mode", MODE, "mode"), draft.mode === "continue" ? t("Each run sees the ones before it.") : t("Each run starts with nothing but the instruction."), "sched-mode"),
field(t("Model"), modelSelect, t("A busy model is waited for, then the next free one is used."), "sched-model"),
field(t("Reasoning effort"), effort, levels.length ? "" : t("Choose a model to choose its effort."), "sched-effort"),
field(t("Agents"), agents, t("A team works on it together, its own agent included."), "sched-agents"),
field(t("Subagents"), agents, t("How many work on it at once, the run's own agent among them, on the same model."), "sched-agents"),
)
settings.querySelector("#sched-mode").addEventListener("change", drawSettings)
}