The chat's settings at the top right of its header; the composer keeps the message
Model, effort, subagents and an agent chat's mode were chips inside the composer, cut off or out of reach with a long model name or every chip on. They are in the chat's header now, beside the Terminal, the activity panel, a Tools and skills button and the menu; a new agent chat's computer and folder sit beside the title (where an open one says where it works), and its Terminal is the header's button as an open chat's is. The composer keeps text, attach, Dictate, Draw a picture and Send/Stop. Where the header has no room -- measured by fitHead on every drawing and resize: the settings cut at their end, or the place squeezed under 10rem -- they fold behind Chat settings, which opens them as a wrapped row under the header. The new chat page shows its header. Tests (tests/ui CI2): the composer holds only the message's controls; at 1920/1280/1024/800/390 px, for a chat and an agent chat with long model names and every chip, every button is inside the window and not clipped by its container, nothing is drawn over another, folded or not, and the model's menu opens; a new agent chat at 1280 and 390. Earlier tests follow the chips' new places. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
8 files changed
+287
-52
No files matched your search
@@ -8,6 +8,18 @@ All notable changes to LLeMbas Desktop. The format follows
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **The chat's settings moved to the top right of its header; the composer keeps the message.**
|
||||||
|
The model picker, how hard it thinks, the subagents and an agent chat's mode sat as chips inside
|
||||||
|
the composer, where with a long model name or every chip on they were cut off and some could not
|
||||||
|
be reached. They are now in the chat's header, beside the Terminal, the activity panel, the
|
||||||
|
chat's tools and skills (a button of its own now, still in the chat's menu too) and the menu; a
|
||||||
|
new agent chat's computer and folder sit beside the title, where an open one says where it
|
||||||
|
works. The composer holds only the message's own: the text, attach, Dictate, Draw a picture and
|
||||||
|
Send/Stop. Where the header has no room for the settings -- measured, so a long model name
|
||||||
|
counts -- they fold behind Chat settings, which opens them as a wrapped row under the header:
|
||||||
|
nothing is cut off or drawn over another at any width (checked at 1920, 1280, 1024, 800 and
|
||||||
|
390 px with long model names and every chip on).
|
||||||
|
|
||||||
- **Files, in the web explorer's shape, using the window's width.** Both tabs (This computer and
|
- **Files, in the web explorer's shape, using the window's width.** Both tabs (This computer and
|
||||||
Server) are now where you are (crumbs, each part a way back), the folder tree, the list in
|
Server) are now where you are (crumbs, each part a way back), the folder tree, the list in
|
||||||
columns -- name with an icon by kind, type, size, changed -- and, on a wide window, the chosen
|
columns -- name with an icon by kind, type, size, changed -- and, on a wide window, the chosen
|
||||||
|
|||||||
@@ -564,6 +564,8 @@ KEYS = (
|
|||||||
"Path",
|
"Path",
|
||||||
"Choose a file to see it here.",
|
"Choose a file to see it here.",
|
||||||
"Details",
|
"Details",
|
||||||
|
# chat.js: the chat's settings in its header, folded behind one button where they do not fit
|
||||||
|
"Chat settings",
|
||||||
)
|
)
|
||||||
|
|
||||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||||
|
|||||||
+164
-32
@@ -355,11 +355,11 @@ async function newChatOn(p, kind, modelName) {
|
|||||||
async function subagentsChip(p) {
|
async function subagentsChip(p) {
|
||||||
// The chip comes after the others (an open chat's waits for the server's word).
|
// The chip comes after the others (an open chat's waits for the server's word).
|
||||||
await p.waitForTimeout(150)
|
await p.waitForTimeout(150)
|
||||||
return p.locator("#composer-chips .cchip--subagents")
|
return p.locator("#chat-settings .cchip--subagents")
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openSlider(p) {
|
async function openSlider(p) {
|
||||||
await p.click("#composer-chips .cchip--subagents")
|
await p.click("#chat-settings .cchip--subagents")
|
||||||
await p.waitForSelector(".range2")
|
await p.waitForSelector(".range2")
|
||||||
return {
|
return {
|
||||||
min: await p.getAttribute(".range2__input--min", "max"),
|
min: await p.getAttribute(".range2__input--min", "max"),
|
||||||
@@ -413,7 +413,7 @@ test("A5: subagents switched on while the app stays open: the next new chat offe
|
|||||||
await p.evaluate((m) => (window.__mockConfig.routes["GET /models"] = m), models())
|
await p.evaluate((m) => (window.__mockConfig.routes["GET /models"] = m), models())
|
||||||
await p.waitForTimeout(5200)
|
await p.waitForTimeout(5200)
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.waitForSelector("#composer-chips .cchip--subagents")
|
await p.waitForSelector("#chat-settings .cchip--subagents")
|
||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -470,9 +470,9 @@ test("B3: a new agent chat offers the Terminal once its folder is chosen, and op
|
|||||||
const p = await page(b3())
|
const p = await page(b3())
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await p.waitForSelector("#composer-chips .cchip--terminal")
|
await p.waitForSelector("#chat-tools [data-terminal-toggle]")
|
||||||
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 0, "nothing is opened until asked")
|
assert.equal(await p.evaluate(() => window.__mock.terminals().length), 0, "nothing is opened until asked")
|
||||||
await p.click("#composer-chips .cchip--terminal")
|
await p.click("#chat-tools [data-terminal-toggle]")
|
||||||
await p.waitForSelector("#terminal:not([hidden]) .xterm")
|
await p.waitForSelector("#terminal:not([hidden]) .xterm")
|
||||||
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||||
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
||||||
@@ -480,7 +480,7 @@ test("B3: a new agent chat offers the Terminal once its folder is chosen, and op
|
|||||||
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
await p.waitForFunction(() => document.querySelector("#terminal .xterm-rows")?.textContent.includes("dev@workbench"))
|
||||||
assert.equal(await p.textContent("#terminal-message"), "Connected.")
|
assert.equal(await p.textContent("#terminal-message"), "Connected.")
|
||||||
assert.equal(await p.textContent("#terminal-title"), "workbench")
|
assert.equal(await p.textContent("#terminal-title"), "workbench")
|
||||||
assert.equal(await p.getAttribute("#composer-chips .cchip--terminal", "aria-pressed"), "true")
|
assert.equal(await p.getAttribute("#chat-tools [data-terminal-toggle]", "aria-pressed"), "true")
|
||||||
// Keys go up through the bridge, never a socket of the page's own.
|
// Keys go up through the bridge, never a socket of the page's own.
|
||||||
await p.click("#terminal .xterm")
|
await p.click("#terminal .xterm")
|
||||||
await p.keyboard.type("ls")
|
await p.keyboard.type("ls")
|
||||||
@@ -571,22 +571,22 @@ test("B3: no Terminal where the device opens none, for a chat on the server, or
|
|||||||
let p = await page(b3(off))
|
let p = await page(b3(off))
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await p.waitForSelector("#composer-chips .cchip")
|
await p.waitForSelector("#chat-settings .cchip")
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "the device opens no terminal")
|
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "the device opens no terminal")
|
||||||
await openChatRow(p, "chat2")
|
await openChatRow(p, "chat2")
|
||||||
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0)
|
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0)
|
||||||
await p.context().close()
|
await p.context().close()
|
||||||
p = await page(b3({}))
|
p = await page(b3({}))
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Chat")
|
await p.click(".kind__tab >> text=Chat")
|
||||||
await p.waitForSelector("#composer-chips .cchip")
|
await p.waitForSelector("#chat-settings .cchip")
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "a chat on the server has no shell")
|
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "a chat on the server has no shell")
|
||||||
await p.context().close()
|
await p.context().close()
|
||||||
p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) })
|
p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) })
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await p.waitForSelector("#composer-chips .cchip")
|
await p.waitForSelector("#chat-settings .cchip")
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "no folder yet")
|
assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "no folder yet")
|
||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -802,30 +802,30 @@ test("C4: Subagents for a new agent chat as /models?kind=agent says for this dev
|
|||||||
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
|
const p = await page(c4({ "GET /models?kind=agent&device=token1": models }))
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await p.waitForSelector("#composer-chips .cchip")
|
await p.waitForSelector("#chat-settings .cchip")
|
||||||
await p.waitForTimeout(200)
|
await p.waitForTimeout(200)
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
let p = await newAgent(facts)
|
let p = await newAgent(facts)
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them")
|
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 1, "offered, and the device runs them")
|
||||||
await p.click("#composer-chips .cchip--subagents")
|
await p.click("#chat-settings .cchip--subagents")
|
||||||
await p.waitForSelector(".range2")
|
await p.waitForSelector(".range2")
|
||||||
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
|
assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap")
|
||||||
await p.context().close()
|
await p.context().close()
|
||||||
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
|
p = await newAgent({ ...facts, agent_subagents: { offered: true, device_runs: false } })
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
|
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 0, "this computer's CLI cannot run them")
|
||||||
await p.context().close()
|
await p.context().close()
|
||||||
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
|
p = await newAgent({ ...facts, agent_subagents: { offered: false, device_runs: true } })
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "not offered")
|
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 0, "not offered")
|
||||||
await p.context().close()
|
await p.context().close()
|
||||||
p = await newAgent({ __error: { status: 400, detail: "older server" } })
|
p = await newAgent({ __error: { status: 400, detail: "older server" } })
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
|
assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 0, "a server before 3.4.0: none, as before")
|
||||||
await p.context().close()
|
await p.context().close()
|
||||||
// An open agent chat: its own `subagents` (chat_agent.json offers them).
|
// An open agent chat: its own `subagents` (chat_agent.json offers them).
|
||||||
const chat = fixture("chat_agent")
|
const chat = fixture("chat_agent")
|
||||||
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
|
p = await page(c4({ "GET /chats/chat2": { chat: { ...agentChat({ running: false }), subagents: chat.subagents, agents: chat.agents }, messages: [userMessage("m1", "Go")], more: false, live: null } }))
|
||||||
await openChatRow(p, "chat2")
|
await openChatRow(p, "chat2")
|
||||||
await p.waitForSelector("#composer-chips .cchip--subagents")
|
await p.waitForSelector("#chat-settings .cchip--subagents")
|
||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -928,7 +928,7 @@ function d4(over = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function pickDevice(p, name) {
|
async function pickDevice(p, name) {
|
||||||
await p.click("#composer-chips .cchip--device")
|
await p.click("#chat-place .cchip--device")
|
||||||
await p.locator(".menu .menu__item", { hasText: name }).first().click()
|
await p.locator(".menu .menu__item", { hasText: name }).first().click()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,16 +936,16 @@ test("D4: a new agent chat defaults to this computer and can be sent to another
|
|||||||
const p = await page(d4())
|
const p = await page(d4())
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await p.waitForSelector("#composer-chips .cchip--device")
|
await p.waitForSelector("#chat-place .cchip--device")
|
||||||
assert.match(await p.textContent("#composer-chips .cchip--device"), /This computer/)
|
assert.match(await p.textContent("#chat-place .cchip--device"), /This computer/)
|
||||||
await pickDevice(p, "studio")
|
await pickDevice(p, "studio")
|
||||||
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
|
await p.waitForFunction(() => /studio/.test(document.querySelector("#chat-place .cchip--device")?.textContent || ""))
|
||||||
assert.match(await p.textContent("#new-chat .hello__sub"), /studio/, "the greeting names the computer")
|
assert.match(await p.textContent("#new-chat .hello__sub"), /studio/, "the greeting names the computer")
|
||||||
assert.match(await p.textContent("#composer-chips"), /site/, "the folder its agent chats work in")
|
assert.match(await p.textContent("#chat-place"), /site/, "the folder its agent chats work in")
|
||||||
assert.ok(await p.isHidden("#link-notice"), "this computer's agent is not in the way")
|
assert.ok(await p.isHidden("#link-notice"), "this computer's agent is not in the way")
|
||||||
await shot(p, "d4-device-1280")
|
await shot(p, "d4-device-1280")
|
||||||
// The Terminal opens on that computer, in that folder, before the first message.
|
// The Terminal opens on that computer, in that folder, before the first message.
|
||||||
await p.click("#composer-chips .cchip--terminal")
|
await p.click("#chat-tools [data-terminal-toggle]")
|
||||||
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
await p.waitForFunction(() => window.__mock.terminals().length === 1)
|
||||||
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
const [shell] = await p.evaluate(() => window.__mock.terminals())
|
||||||
assert.deepEqual(shell.target, { chat: null, device: "token9", directory: "/srv/site" })
|
assert.deepEqual(shell.target, { chat: null, device: "token9", directory: "/srv/site" })
|
||||||
@@ -964,18 +964,18 @@ test("D4: on a computer with no known folder the path is typed; back to this com
|
|||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await pickDevice(p, "studio")
|
await pickDevice(p, "studio")
|
||||||
await p.waitForSelector("#composer-chips .cchip.is-warn")
|
await p.waitForSelector("#chat-place .cchip.is-warn")
|
||||||
await p.fill("#composer-text", "Hello")
|
await p.fill("#composer-text", "Hello")
|
||||||
await p.click("#send")
|
await p.click("#send")
|
||||||
await p.waitForSelector("#thread-note:not([hidden])")
|
await p.waitForSelector("#thread-note:not([hidden])")
|
||||||
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats").length), 0, "nothing sent without a folder")
|
assert.equal(await p.evaluate(() => window.__mock.api("POST", "/chats").length), 0, "nothing sent without a folder")
|
||||||
await p.click("#composer-chips .cchip.is-warn")
|
await p.click("#chat-place .cchip.is-warn")
|
||||||
await p.waitForSelector("dialog[open] .dialog__input")
|
await p.waitForSelector("dialog[open] .dialog__input")
|
||||||
await p.fill("dialog[open] .dialog__input", "/home/dev/other")
|
await p.fill("dialog[open] .dialog__input", "/home/dev/other")
|
||||||
await p.click("dialog[open] .btn--primary")
|
await p.click("dialog[open] .btn--primary")
|
||||||
await p.waitForFunction(() => /other/.test(document.querySelector("#composer-chips").textContent))
|
await p.waitForFunction(() => /other/.test(document.querySelector("#chat-place").textContent))
|
||||||
await pickDevice(p, "This computer")
|
await pickDevice(p, "This computer")
|
||||||
await p.waitForFunction(() => /project/.test(document.querySelector("#composer-chips").textContent))
|
await p.waitForFunction(() => /project/.test(document.querySelector("#chat-place").textContent))
|
||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -983,14 +983,14 @@ test("D4: with no other computer linked there is no choice to make; at 390 px th
|
|||||||
let p = await page(b3())
|
let p = await page(b3())
|
||||||
await p.click("#new-chat-btn")
|
await p.click("#new-chat-btn")
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await p.waitForSelector("#composer-chips .cchip")
|
await p.waitForSelector("#chat-settings .cchip")
|
||||||
assert.equal(await p.locator("#composer-chips .cchip--device").count(), 0)
|
assert.equal(await p.locator("#chat-place .cchip--device").count(), 0)
|
||||||
await p.context().close()
|
await p.context().close()
|
||||||
p = await page(d4(), { width: 390, height: 844 })
|
p = await page(d4(), { width: 390, height: 844 })
|
||||||
await p.evaluate(() => document.getElementById("new-chat-btn").click())
|
await p.evaluate(() => document.getElementById("new-chat-btn").click())
|
||||||
await p.click(".kind__tab >> text=Agent")
|
await p.click(".kind__tab >> text=Agent")
|
||||||
await pickDevice(p, "studio")
|
await pickDevice(p, "studio")
|
||||||
await p.waitForFunction(() => /studio/.test(document.querySelector("#composer-chips .cchip--device")?.textContent || ""))
|
await p.waitForFunction(() => /studio/.test(document.querySelector("#chat-place .cchip--device")?.textContent || ""))
|
||||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||||
await shot(p, "d4-device-390")
|
await shot(p, "d4-device-390")
|
||||||
assert.deepEqual(p.errors, [])
|
assert.deepEqual(p.errors, [])
|
||||||
@@ -1963,6 +1963,138 @@ test("FX: at every width nothing scrolls sideways and nothing is cut off; narrow
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// CI2: the chat's input overhauled -- the chat's settings (mode, model, effort, subagents) in the
|
||||||
|
// header's top right beside Terminal, the activity panel, tools and skills and the menu; the
|
||||||
|
// composer keeps the message's own: text, attach, Dictate, Draw a picture, Send/Stop. Nothing is
|
||||||
|
// cut off or out of reach at any width: where the settings do not fit they fold behind Chat
|
||||||
|
// settings, which opens them as a wrapped row of their own.
|
||||||
|
|
||||||
|
const LONG_MODEL = "Qwen3.8 Coder 480B A35B Instruct, long context, tools and vision"
|
||||||
|
function ci2(kind) {
|
||||||
|
const cfg = i8()
|
||||||
|
const m = models()
|
||||||
|
m.models[0] = { ...m.models[0], name: LONG_MODEL }
|
||||||
|
m.models[1] = { ...m.models[1], name: "Another model with a long name, served elsewhere" }
|
||||||
|
Object.assign(cfg.routes, {
|
||||||
|
"GET /models": m,
|
||||||
|
"GET /models?chat=chat1": m,
|
||||||
|
"GET /models?chat=chat2": m,
|
||||||
|
"GET /chats/chat2": { chat: agentChat({ subagents: { offered: true, named_only: false, cap: 8 } }), messages: [userMessage("m1", "What is in build?")], more: false, live: null },
|
||||||
|
"GET /chats/chat2/tree": { tree: [], live: true },
|
||||||
|
})
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every visible button of the header and the composer inside the window, none of zero width. */
|
||||||
|
async function reachable(p, width) {
|
||||||
|
return p.$$eval(
|
||||||
|
"#chat-head button, #composer button",
|
||||||
|
(l, w) =>
|
||||||
|
l
|
||||||
|
.filter((b) => b.offsetParent !== null && getComputedStyle(b).visibility !== "hidden")
|
||||||
|
.filter((b) => {
|
||||||
|
const r = b.getBoundingClientRect()
|
||||||
|
// A button inside a container that clips it (overflow) is cut off too.
|
||||||
|
let clip = { left: 0, right: w }
|
||||||
|
for (let el = b.parentElement; el; el = el.parentElement) {
|
||||||
|
const cs = getComputedStyle(el)
|
||||||
|
if (cs.overflowX !== "visible") {
|
||||||
|
const c = el.getBoundingClientRect()
|
||||||
|
clip = { left: Math.max(clip.left, c.left), right: Math.min(clip.right, c.right) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.width < 8 || r.left < clip.left - 1 || r.right > clip.right + 1
|
||||||
|
})
|
||||||
|
.map((b) => b.className || b.id),
|
||||||
|
width,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pairs of the header's controls (and where the chat works) drawn over each other. */
|
||||||
|
async function overlapping(p) {
|
||||||
|
return p.$$eval("#chat-head button, #chat-head .place", (l) => {
|
||||||
|
const shown = l.filter((b) => b.offsetParent !== null && b.getBoundingClientRect().width > 0)
|
||||||
|
const out = []
|
||||||
|
for (let i = 0; i < shown.length; i++)
|
||||||
|
for (let j = i + 1; j < shown.length; j++) {
|
||||||
|
const a = shown[i].getBoundingClientRect()
|
||||||
|
const b = shown[j].getBoundingClientRect()
|
||||||
|
if (shown[i].contains(shown[j]) || shown[j].contains(shown[i])) continue
|
||||||
|
if (a.left < b.right - 1 && b.left < a.right - 1 && a.top < b.bottom - 1 && b.top < a.bottom - 1) out.push(`${shown[i].className} / ${shown[j].className}`)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test("CI2: the composer holds the message's own controls; the chat's settings are in its header", async () => {
|
||||||
|
const p = await page(ci2(), { width: 1600, height: 900 })
|
||||||
|
await openChatRow(p, "chat1")
|
||||||
|
await p.waitForSelector("#chat-settings .cchip--model")
|
||||||
|
await p.waitForSelector("#chat-settings .cchip--subagents")
|
||||||
|
assert.equal(await p.locator("#composer .cchip--model, #composer .cchip--subagents").count(), 0, "no settings in the composer")
|
||||||
|
assert.equal(await p.locator("#composer-chips .cchip--picture").count(), 1, "Draw a picture stays with the message")
|
||||||
|
assert.ok(await p.isVisible("#chat-tools #activity-open"))
|
||||||
|
assert.ok(await p.isVisible("#chat-tools #scope-open"))
|
||||||
|
assert.ok(await p.isHidden("#chat-fold"), "everything fits: nothing folded")
|
||||||
|
// Tools and skills opens from the header.
|
||||||
|
await p.click("#scope-open")
|
||||||
|
await p.waitForSelector(".scope")
|
||||||
|
await shot(p, "ci2-chat-1600")
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("CI2: at 1920, 1280, 1024, 800 and 390 px every control is reachable, with long model names and every chip on", async () => {
|
||||||
|
for (const [width, height] of [[1920, 1080], [1280, 860], [1024, 768], [800, 700], [390, 844]]) {
|
||||||
|
for (const id of ["chat1", "chat2"]) {
|
||||||
|
const p = await page(ci2(), { width, height })
|
||||||
|
await p.evaluate((x) => document.querySelector(`.chatrow[data-id="${x}"]`).click(), id)
|
||||||
|
await p.waitForSelector("#messages .msg")
|
||||||
|
await p.waitForSelector("#chat-settings .cchip--subagents", { state: "attached" })
|
||||||
|
await p.waitForTimeout(100)
|
||||||
|
assert.ok(await noSideways(p, width), `${width} ${id}: nothing sideways`)
|
||||||
|
assert.deepEqual(await reachable(p, width), [], `${width} ${id}: every control inside`)
|
||||||
|
assert.deepEqual(await overlapping(p), [], `${width} ${id}: nothing drawn over another`)
|
||||||
|
const folded = await p.isVisible("#chat-fold")
|
||||||
|
if (folded) {
|
||||||
|
await p.click("#chat-fold")
|
||||||
|
await p.waitForSelector("#chat-settings .cchip--model", { state: "visible" })
|
||||||
|
assert.deepEqual(await reachable(p, width), [], `${width} ${id}: unfolded, every setting inside`)
|
||||||
|
assert.deepEqual(await overlapping(p), [], `${width} ${id}: unfolded, nothing drawn over another`)
|
||||||
|
}
|
||||||
|
// Every setting is there and can be used: the model's menu opens.
|
||||||
|
const names = await p.$$eval("#chat-settings .cchip", (l) => l.map((x) => x.className))
|
||||||
|
assert.ok(names.some((n) => n.includes("cchip--model")) && names.some((n) => n.includes("cchip--subagents")), `${width} ${id}: ${names}`)
|
||||||
|
if (id === "chat2") assert.ok(await p.isVisible("#chat-tools [data-terminal-toggle]"), `${width}: the Terminal`)
|
||||||
|
await p.click("#chat-settings .cchip--model")
|
||||||
|
await p.waitForSelector(".menu")
|
||||||
|
assert.match(await p.textContent(".menu"), /Another model/)
|
||||||
|
await p.keyboard.press("Escape")
|
||||||
|
await shot(p, `ci2-${id}-${width}${folded ? "-unfolded" : ""}`)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
await p.context().close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("CI2: a new agent chat says where it works beside the title and its settings at the right; the composer only the message", async () => {
|
||||||
|
for (const width of [1280, 390]) {
|
||||||
|
const p = await page(ci2(), { width, height: 844 })
|
||||||
|
await p.waitForSelector("#chat-settings .cchip--model", { state: "attached" })
|
||||||
|
await p.click(".kind__tab >> text=Agent")
|
||||||
|
await p.waitForSelector("#chat-place .cchip")
|
||||||
|
assert.equal(await p.locator("#composer .cchip").count(), 0, "nothing but the message in an agent chat's composer")
|
||||||
|
assert.ok(await noSideways(p, width))
|
||||||
|
assert.deepEqual(await reachable(p, width), [], `${width}: every control inside`)
|
||||||
|
assert.deepEqual(await overlapping(p), [], `${width}: nothing drawn over another`)
|
||||||
|
if (await p.isVisible("#chat-fold")) await p.click("#chat-fold")
|
||||||
|
assert.deepEqual(await overlapping(p), [], `${width}: unfolded, nothing drawn over another`)
|
||||||
|
assert.deepEqual(await reachable(p, width), [], `${width}: unfolded, every control inside`)
|
||||||
|
await shot(p, `ci2-new-agent-${width}`)
|
||||||
|
assert.deepEqual(p.errors, [])
|
||||||
|
await p.context().close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// --- Run ----------------------------------------------------------------------------------------
|
// --- Run ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
const only = process.argv[2] || ""
|
const only = process.argv[2] || ""
|
||||||
|
|||||||
+26
-2
@@ -497,7 +497,29 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
|||||||
.head__title { font: 600 var(--text-base) / var(--leading-tight) var(--font-body); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
.head__title { font: 600 var(--text-base) / var(--leading-tight) var(--font-body); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||||
.head__place { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
|
.head__place { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
|
||||||
.head__tools { display: flex; gap: var(--sp-1); align-items: center; justify-content: end; }
|
.head__tools { display: flex; gap: var(--sp-1); align-items: center; justify-content: end; }
|
||||||
.main[data-new="true"] .head { display: none; }
|
/* The chat's header: the title, where it works, then its settings and tools at the right. The
|
||||||
|
settings take what room is left after the title's minimum; where they do not fit they fold
|
||||||
|
behind Chat settings (chat.js `fitHead`, measured), which opens them as a wrapped row of their
|
||||||
|
own under the header. */
|
||||||
|
.head.chathead {
|
||||||
|
grid-template-columns: minmax(min(6rem, 30%), max-content) minmax(0, 1fr) minmax(0, max-content) auto auto;
|
||||||
|
grid-template-areas: "title place set fold tools";
|
||||||
|
column-gap: var(--sp-2);
|
||||||
|
}
|
||||||
|
.chathead > .head__title { grid-area: title; }
|
||||||
|
.chathead > .head__place { grid-area: place; overflow: hidden; }
|
||||||
|
.chathead > .head__tools { grid-area: tools; }
|
||||||
|
.chathead__fold { grid-area: fold; }
|
||||||
|
.chathead__fold[hidden] { display: none; }
|
||||||
|
.chathead__settings { grid-area: set; display: flex; flex-wrap: nowrap; gap: var(--sp-1); align-items: center; justify-content: start; min-width: 0; overflow: hidden; }
|
||||||
|
.chathead__settings > * { flex: none; }
|
||||||
|
.chathead__settings:empty { display: none; }
|
||||||
|
.chathead[data-fold="true"] .chathead__settings { display: none; }
|
||||||
|
.head.chathead[data-fold="true"][data-unfold="true"] { grid-template-areas: "title place set fold tools" "row row row row row"; row-gap: var(--sp-2); }
|
||||||
|
.chathead[data-fold="true"][data-unfold="true"] .chathead__settings { grid-area: row; display: flex; flex-wrap: wrap; justify-content: start; overflow: visible; padding-bottom: var(--sp-1); }
|
||||||
|
.chathead__settings .cchip { max-width: min(var(--w-field), 100%); }
|
||||||
|
.head__place .cchip { min-width: 0; }
|
||||||
|
.head__place > .cchip:last-child { flex: 0 1 auto; }
|
||||||
.main > .head { grid-row: 1; }
|
.main > .head { grid-row: 1; }
|
||||||
.main > .thread { grid-row: 2; }
|
.main > .thread { grid-row: 2; }
|
||||||
.main > .dock { grid-row: 3; }
|
.main > .dock { grid-row: 3; }
|
||||||
@@ -530,7 +552,7 @@ label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-body); co
|
|||||||
|
|
||||||
/* A new chat is the same page, empty: the greeting low in the space above the composer, which
|
/* A new chat is the same page, empty: the greeting low in the space above the composer, which
|
||||||
sits in the middle of the window rather than at its foot. */
|
sits in the middle of the window rather than at its foot. */
|
||||||
.main[data-new="true"] { grid-template-rows: 0 minmax(0, 1fr) auto minmax(0, 0.8fr); }
|
.main[data-new="true"] { grid-template-rows: auto minmax(0, 1fr) auto minmax(0, 0.8fr); }
|
||||||
.main[data-new="true"] .thread { display: grid; align-content: end; padding-bottom: var(--sp-6); }
|
.main[data-new="true"] .thread { display: grid; align-content: end; padding-bottom: var(--sp-6); }
|
||||||
.hello { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; }
|
.hello { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; }
|
||||||
.hello__mark { width: var(--sp-10); height: var(--sp-10); }
|
.hello__mark { width: var(--sp-10); height: var(--sp-10); }
|
||||||
@@ -1928,6 +1950,8 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
|
|||||||
.head__place { grid-column: 1 / -1; grid-row: 2; }
|
.head__place { grid-column: 1 / -1; grid-row: 2; }
|
||||||
.head__place:empty { display: none; }
|
.head__place:empty { display: none; }
|
||||||
.cchip { max-width: var(--w-field-xs); }
|
.cchip { max-width: var(--w-field-xs); }
|
||||||
|
.head.chathead { grid-template-columns: minmax(min(6rem, 30%), 1fr) minmax(0, max-content) auto auto; grid-template-areas: "title set fold tools" "place place place place"; }
|
||||||
|
.head.chathead[data-fold="true"][data-unfold="true"] { grid-template-areas: "title set fold tools" "place place place place" "row row row row"; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 48rem (--bp-narrow): the sidebar is a drawer over the page, opened from the title bar. */
|
/* 48rem (--bp-narrow): the sidebar is a drawer over the page, opened from the title bar. */
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Provést to",
|
"Carry it out": "Provést to",
|
||||||
"Changed": "Změněno",
|
"Changed": "Změněno",
|
||||||
"Chat": "Konverzace",
|
"Chat": "Konverzace",
|
||||||
|
"Chat settings": "Nastavení konverzace",
|
||||||
"Chats": "Konverzace",
|
"Chats": "Konverzace",
|
||||||
"Check for updates…": "Zkontrolovat aktualizace…",
|
"Check for updates…": "Zkontrolovat aktualizace…",
|
||||||
"Choose": "Vybrat",
|
"Choose": "Vybrat",
|
||||||
@@ -585,6 +586,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Ausführen",
|
"Carry it out": "Ausführen",
|
||||||
"Changed": "Geändert",
|
"Changed": "Geändert",
|
||||||
"Chat": "Chat",
|
"Chat": "Chat",
|
||||||
|
"Chat settings": "Chat-Einstellungen",
|
||||||
"Chats": "Chats",
|
"Chats": "Chats",
|
||||||
"Check for updates…": "Nach Updates suchen…",
|
"Check for updates…": "Nach Updates suchen…",
|
||||||
"Choose": "Auswählen",
|
"Choose": "Auswählen",
|
||||||
@@ -1089,6 +1091,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Llevarlo a cabo",
|
"Carry it out": "Llevarlo a cabo",
|
||||||
"Changed": "Cambiado",
|
"Changed": "Cambiado",
|
||||||
"Chat": "Chat",
|
"Chat": "Chat",
|
||||||
|
"Chat settings": "Ajustes del chat",
|
||||||
"Chats": "Chats",
|
"Chats": "Chats",
|
||||||
"Check for updates…": "Buscar actualizaciones…",
|
"Check for updates…": "Buscar actualizaciones…",
|
||||||
"Choose": "Elegir",
|
"Choose": "Elegir",
|
||||||
@@ -1593,6 +1596,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Exécuter",
|
"Carry it out": "Exécuter",
|
||||||
"Changed": "Modifié",
|
"Changed": "Modifié",
|
||||||
"Chat": "Conversation",
|
"Chat": "Conversation",
|
||||||
|
"Chat settings": "Paramètres de la conversation",
|
||||||
"Chats": "Conversations",
|
"Chats": "Conversations",
|
||||||
"Check for updates…": "Rechercher des mises à jour…",
|
"Check for updates…": "Rechercher des mises à jour…",
|
||||||
"Choose": "Choisir",
|
"Choose": "Choisir",
|
||||||
@@ -2097,6 +2101,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Végrehajtás",
|
"Carry it out": "Végrehajtás",
|
||||||
"Changed": "Módosítva",
|
"Changed": "Módosítva",
|
||||||
"Chat": "Csevegés",
|
"Chat": "Csevegés",
|
||||||
|
"Chat settings": "Csevegés beállításai",
|
||||||
"Chats": "Csevegések",
|
"Chats": "Csevegések",
|
||||||
"Check for updates…": "Frissítések keresése…",
|
"Check for updates…": "Frissítések keresése…",
|
||||||
"Choose": "Kiválasztás",
|
"Choose": "Kiválasztás",
|
||||||
@@ -2601,6 +2606,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Eseguilo",
|
"Carry it out": "Eseguilo",
|
||||||
"Changed": "Modificato",
|
"Changed": "Modificato",
|
||||||
"Chat": "Chat",
|
"Chat": "Chat",
|
||||||
|
"Chat settings": "Impostazioni della chat",
|
||||||
"Chats": "Chat",
|
"Chats": "Chat",
|
||||||
"Check for updates…": "Cerca aggiornamenti…",
|
"Check for updates…": "Cerca aggiornamenti…",
|
||||||
"Choose": "Scegli",
|
"Choose": "Scegli",
|
||||||
@@ -3105,6 +3111,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Wykonaj",
|
"Carry it out": "Wykonaj",
|
||||||
"Changed": "Zmieniono",
|
"Changed": "Zmieniono",
|
||||||
"Chat": "Czat",
|
"Chat": "Czat",
|
||||||
|
"Chat settings": "Ustawienia czatu",
|
||||||
"Chats": "Czaty",
|
"Chats": "Czaty",
|
||||||
"Check for updates…": "Sprawdź aktualizacje…",
|
"Check for updates…": "Sprawdź aktualizacje…",
|
||||||
"Choose": "Wybierz",
|
"Choose": "Wybierz",
|
||||||
@@ -3609,6 +3616,7 @@ const LEMBAS_I18N = {
|
|||||||
"Carry it out": "Vykonať to",
|
"Carry it out": "Vykonať to",
|
||||||
"Changed": "Zmenené",
|
"Changed": "Zmenené",
|
||||||
"Chat": "Konverzácia",
|
"Chat": "Konverzácia",
|
||||||
|
"Chat settings": "Nastavenia konverzácie",
|
||||||
"Chats": "Konverzácie",
|
"Chats": "Konverzácie",
|
||||||
"Check for updates…": "Skontrolovať aktualizácie…",
|
"Check for updates…": "Skontrolovať aktualizácie…",
|
||||||
"Choose": "Vybrať",
|
"Choose": "Vybrať",
|
||||||
|
|||||||
+3
-1
@@ -64,9 +64,11 @@
|
|||||||
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
<div class="scrim" id="scrim" aria-hidden="true"></div>
|
||||||
|
|
||||||
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
|
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
|
||||||
<header class="head">
|
<header class="head chathead" id="chat-head" data-fold="false" data-unfold="false">
|
||||||
<h1 class="head__title" id="chat-title"></h1>
|
<h1 class="head__title" id="chat-title"></h1>
|
||||||
<div class="head__place" id="chat-place"></div>
|
<div class="head__place" id="chat-place"></div>
|
||||||
|
<div class="chathead__settings" id="chat-settings" role="toolbar" aria-label="Chat settings" data-i18n-aria-label></div>
|
||||||
|
<button type="button" class="tool chathead__fold" id="chat-fold" aria-expanded="false" aria-controls="chat-settings" aria-label="Chat settings" title="Chat settings" data-i18n-aria-label data-i18n-title></button>
|
||||||
<div class="head__tools" id="chat-tools"></div>
|
<div class="head__tools" id="chat-tools"></div>
|
||||||
</header>
|
</header>
|
||||||
<div class="thread" id="thread">
|
<div class="thread" id="thread">
|
||||||
|
|||||||
+70
-17
@@ -731,8 +731,49 @@ function renderHead(chat) {
|
|||||||
}
|
}
|
||||||
// An archived chat says so where it is read, with the way back into the sidebar.
|
// An archived chat says so where it is read, with the way back into the sidebar.
|
||||||
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
|
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
|
||||||
|
if (!chat.archived) tools.append(h("button", { type: "button", class: "tool", id: "scope-open", "aria-label": t("Tools and skills…"), title: t("Tools and skills…"), "aria-haspopup": "dialog", onclick: (e) => scopePopover(e.currentTarget, open?.chat || chat) }, icon(ICONS.tools)))
|
||||||
tools.append(activityButton())
|
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)))
|
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)))
|
||||||
|
fitHead()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- The chat's settings, top right ---------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// The model, how hard it thinks, the subagents and an agent chat's mode sit in the chat's header,
|
||||||
|
// beside the Terminal, the activity panel, the tools and skills and the chat's menu; the composer
|
||||||
|
// keeps only what belongs to the message. Where the header has no room for them all on its line,
|
||||||
|
// they fold behind one button (Chat settings), which opens them as a row of their own under the
|
||||||
|
// header -- wrapped, every one reachable, never a line cut off at its end. Whether they fit is
|
||||||
|
// measured, not guessed: a long model name or one more chip folds them at any width.
|
||||||
|
|
||||||
|
function fitHead() {
|
||||||
|
const head = $("chat-head")
|
||||||
|
const box = $("chat-settings")
|
||||||
|
if (!head || !box) return
|
||||||
|
const any = box.childElementCount > 0
|
||||||
|
head.dataset.fold = "false"
|
||||||
|
// Folded when the settings are cut at their end, or when they leave where the chat works (its
|
||||||
|
// folder) less than its own width or 10rem, whichever is less.
|
||||||
|
const place = $("chat-place")
|
||||||
|
const room = Math.min(place.scrollWidth, 10 * parseFloat(getComputedStyle(document.documentElement).fontSize))
|
||||||
|
const squeezed = place.childElementCount > 0 && place.clientWidth + 1 < room
|
||||||
|
const fold = any && (box.scrollWidth > box.clientWidth + 1 || squeezed)
|
||||||
|
head.dataset.fold = String(fold)
|
||||||
|
if (!fold) head.dataset.unfold = "false"
|
||||||
|
$("chat-fold").hidden = !fold
|
||||||
|
$("chat-fold").setAttribute("aria-expanded", String(head.dataset.unfold === "true"))
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHead() {
|
||||||
|
$("chat-fold").replaceChildren(icon(ICONS.sliders))
|
||||||
|
$("chat-fold").hidden = true
|
||||||
|
$("chat-fold").addEventListener("click", () => {
|
||||||
|
const head = $("chat-head")
|
||||||
|
head.dataset.unfold = String(head.dataset.unfold !== "true")
|
||||||
|
$("chat-fold").setAttribute("aria-expanded", head.dataset.unfold)
|
||||||
|
if (head.dataset.unfold === "true") $("chat-settings").querySelector("button")?.focus()
|
||||||
|
})
|
||||||
|
new ResizeObserver(() => fitHead()).observe($("chat-head"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
/** The models this chat may switch to (its data group's), or the account's when that fails. */
|
||||||
@@ -966,7 +1007,8 @@ let chipsDrawn = 0 // which drawing is the latest: an older one's late answers a
|
|||||||
|
|
||||||
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
/** The chips for what the composer is writing to: a new chat (`draft`) or the open chat. */
|
||||||
async function renderChips() {
|
async function renderChips() {
|
||||||
const box = $("composer-chips")
|
const box = $("chat-settings")
|
||||||
|
const message = $("composer-chips")
|
||||||
const drawing = ++chipsDrawn
|
const drawing = ++chipsDrawn
|
||||||
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -974,18 +1016,20 @@ async function renderChips() {
|
|||||||
const models = ctx.models()
|
const models = ctx.models()
|
||||||
const model = models.find((m) => m.id === draft.model)
|
const model = models.find((m) => m.id === draft.model)
|
||||||
const parts = []
|
const parts = []
|
||||||
|
const where = [] // an agent chat's computer and folder: where it works, beside the title
|
||||||
if (draft.kind === "agent") {
|
if (draft.kind === "agent") {
|
||||||
const others = otherDevices()
|
const others = otherDevices()
|
||||||
if (others.length) parts.push(deviceChip(others))
|
if (others.length) where.push(deviceChip(others))
|
||||||
if (draftHere()) {
|
if (draftHere()) {
|
||||||
const roots = s?.remote?.enabled ? s.remote.roots : []
|
const roots = s?.remote?.enabled ? s.remote.roots : []
|
||||||
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
if (!roots.includes(draft.dir)) draft.dir = roots[0] || ""
|
||||||
parts.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
where.push(folderChip(roots, draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||||
} else {
|
} else {
|
||||||
parts.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
|
where.push(elsewhereFolderChip(draftDevice(), draft.dir, (d) => ((draft.dir = d), renderChips())))
|
||||||
}
|
}
|
||||||
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
|
||||||
}
|
}
|
||||||
|
$("chat-place").replaceChildren(...where)
|
||||||
const shell = terminalTarget()
|
const shell = terminalTarget()
|
||||||
aimTerminal(shell)
|
aimTerminal(shell)
|
||||||
parts.push(modelChip(models, draft.model, (id) => {
|
parts.push(modelChip(models, draft.model, (id) => {
|
||||||
@@ -994,8 +1038,11 @@ async function renderChips() {
|
|||||||
renderChips()
|
renderChips()
|
||||||
}, mayPick))
|
}, mayPick))
|
||||||
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
|
||||||
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
|
if (draft.kind === "chat" && picturesOffered()) message.replaceChildren(pictureChip())
|
||||||
else wantPicture = false
|
else {
|
||||||
|
wantPicture = false
|
||||||
|
message.replaceChildren()
|
||||||
|
}
|
||||||
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
|
||||||
// An agent chat's subagents run on its device: offered as the server says for that device.
|
// An agent chat's subagents run on its device: offered as the server says for that device.
|
||||||
const device = draft.kind === "agent" ? draftDevice() : ""
|
const device = draft.kind === "agent" ? draftDevice() : ""
|
||||||
@@ -1007,20 +1054,20 @@ async function renderChips() {
|
|||||||
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
|
if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Last, so the chat's own settings come first where the line scrolls.
|
// The Terminal, as an open chat has it: in the header's tools.
|
||||||
if (shell) {
|
$("chat-tools").replaceChildren(...(shell ? [terminalButton()] : []))
|
||||||
const b = chip({ ico: ICONS.terminal, label: t("Terminal"), menuish: false, cls: "cchip--terminal", onclick: toggleTerminal })
|
|
||||||
b.dataset.terminalToggle = ""
|
|
||||||
b.setAttribute("aria-pressed", String(terminalOpen()))
|
|
||||||
b.setAttribute("aria-controls", "terminal")
|
|
||||||
parts.push(b)
|
|
||||||
}
|
|
||||||
box.replaceChildren(...parts.filter(Boolean))
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
|
fitHead()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const chat = open?.chat
|
const chat = open?.chat
|
||||||
aimTerminal(terminalTarget())
|
aimTerminal(terminalTarget())
|
||||||
if (!chat?.kind) return box.replaceChildren()
|
if (!chat?.kind) {
|
||||||
|
box.replaceChildren()
|
||||||
|
message.replaceChildren()
|
||||||
|
fitHead()
|
||||||
|
return
|
||||||
|
}
|
||||||
const save = async (body) => {
|
const save = async (body) => {
|
||||||
try {
|
try {
|
||||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
|
||||||
@@ -1041,12 +1088,17 @@ async function renderChips() {
|
|||||||
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
|
||||||
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
|
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 })))
|
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
|
||||||
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
|
if (chat.kind === "chat" && picturesOffered() && !chat.archived) message.replaceChildren(pictureChip())
|
||||||
else wantPicture = false
|
else {
|
||||||
|
wantPicture = false
|
||||||
|
message.replaceChildren()
|
||||||
|
}
|
||||||
box.replaceChildren(...parts.filter(Boolean))
|
box.replaceChildren(...parts.filter(Boolean))
|
||||||
|
fitHead()
|
||||||
const offer = await teamOffered(chat)
|
const offer = await teamOffered(chat)
|
||||||
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
|
||||||
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
|
box.append(agentsChip(open.chat.agents || chat.agents, (a) => save({ agents: a }), offer))
|
||||||
|
fitHead()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1505,5 +1557,6 @@ export function initChat(context) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
onAgentChange(renderNotice)
|
onAgentChange(renderNotice)
|
||||||
|
initHead()
|
||||||
showComposer(false)
|
showComposer(false)
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,8 @@ export const ICONS = {
|
|||||||
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
|
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
|
||||||
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
|
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
|
||||||
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
|
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
|
||||||
|
tools: "M10.6 1.5a3.9 3.9 0 00-3.7 5.1L1.8 11.7a1.6 1.6 0 002.3 2.3l5.1-5.1a3.9 3.9 0 005.1-3.7l-.1-.9-2.1 2.1-1.9-.4-.4-1.9 2.1-2.1zM3 12.4l.6.6 5.3-5.3-.6-.6z",
|
||||||
|
sliders: ["M2 4h7v1.5H2zM12 4h2v1.5h-2zM2 10.5h2V12H2zM7 10.5h7V12H7z", "M9.5 2.75h2v4h-2zM4.5 9.25h2v4h-2z"],
|
||||||
fileText: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6 8h4v1H6zM6 10h4v1H6z"],
|
fileText: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6 8h4v1H6zM6 10h4v1H6z"],
|
||||||
fileCode: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6.7 7.6l.7.7-1.2 1.2 1.2 1.2-.7.7-1.9-1.9z", "M9.3 7.6l1.9 1.9-1.9 1.9-.7-.7 1.2-1.2-1.2-1.2z"],
|
fileCode: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6.7 7.6l.7.7-1.2 1.2 1.2 1.2-.7.7-1.9-1.9z", "M9.3 7.6l1.9 1.9-1.9 1.9-.7-.7 1.2-1.2-1.2-1.2z"],
|
||||||
drive: ["M1.5 4h13v8h-13zM3 5.5v5h10v-5z", "M10.5 7.25h1.5v1.5h-1.5z"],
|
drive: ["M1.5 4h13v8h-13zM3 5.5v5h10v-5z", "M10.5 7.25h1.5v1.5h-1.5z"],
|
||||||
|
|||||||
Reference in new issue
Block a user