diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e7825..85841b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ All notable changes to LLeMbas Desktop. The format follows ### 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 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 diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 14c931e..ffb155e 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -564,6 +564,8 @@ KEYS = ( "Path", "Choose a file to see it here.", "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 diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 6fdb537..d08731b 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -355,11 +355,11 @@ async function newChatOn(p, kind, modelName) { async function subagentsChip(p) { // The chip comes after the others (an open chat's waits for the server's word). await p.waitForTimeout(150) - return p.locator("#composer-chips .cchip--subagents") + return p.locator("#chat-settings .cchip--subagents") } async function openSlider(p) { - await p.click("#composer-chips .cchip--subagents") + await p.click("#chat-settings .cchip--subagents") await p.waitForSelector(".range2") return { 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.waitForTimeout(5200) await p.click("#new-chat-btn") - await p.waitForSelector("#composer-chips .cchip--subagents") + await p.waitForSelector("#chat-settings .cchip--subagents") 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()) await p.click("#new-chat-btn") 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") - await p.click("#composer-chips .cchip--terminal") + await p.click("#chat-tools [data-terminal-toggle]") await p.waitForSelector("#terminal:not([hidden]) .xterm") await p.waitForFunction(() => window.__mock.terminals().length === 1) 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")) assert.equal(await p.textContent("#terminal-message"), "Connected.") 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. await p.click("#terminal .xterm") 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)) await p.click("#new-chat-btn") await p.click(".kind__tab >> text=Agent") - await p.waitForSelector("#composer-chips .cchip") - assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "the device opens no terminal") + await p.waitForSelector("#chat-settings .cchip") + assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "the device opens no terminal") await openChatRow(p, "chat2") assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0) await p.context().close() p = await page(b3({})) await p.click("#new-chat-btn") await p.click(".kind__tab >> text=Chat") - await p.waitForSelector("#composer-chips .cchip") - assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "a chat on the server has no shell") + await p.waitForSelector("#chat-settings .cchip") + assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "a chat on the server has no shell") await p.context().close() p = await page({ ...b3({}), status: status({ remote: { enabled: true, roots: [] } }) }) await p.click("#new-chat-btn") await p.click(".kind__tab >> text=Agent") - await p.waitForSelector("#composer-chips .cchip") - assert.equal(await p.locator("#composer-chips .cchip--terminal").count(), 0, "no folder yet") + await p.waitForSelector("#chat-settings .cchip") + assert.equal(await p.locator("#chat-tools [data-terminal-toggle]").count(), 0, "no folder yet") 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 })) await p.click("#new-chat-btn") await p.click(".kind__tab >> text=Agent") - await p.waitForSelector("#composer-chips .cchip") + await p.waitForSelector("#chat-settings .cchip") await p.waitForTimeout(200) return p } let p = await newAgent(facts) - assert.equal(await p.locator("#composer-chips .cchip--subagents").count(), 1, "offered, and the device runs them") - await p.click("#composer-chips .cchip--subagents") + assert.equal(await p.locator("#chat-settings .cchip--subagents").count(), 1, "offered, and the device runs them") + await p.click("#chat-settings .cchip--subagents") await p.waitForSelector(".range2") assert.equal(await p.getAttribute(".range2__input--min", "max"), "32", "the server's cap") await p.context().close() 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() 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() 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() // An open agent chat: its own `subagents` (chat_agent.json offers them). 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 } })) await openChatRow(p, "chat2") - await p.waitForSelector("#composer-chips .cchip--subagents") + await p.waitForSelector("#chat-settings .cchip--subagents") assert.deepEqual(p.errors, []) }) @@ -928,7 +928,7 @@ function d4(over = {}) { } 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() } @@ -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()) await p.click("#new-chat-btn") await p.click(".kind__tab >> text=Agent") - await p.waitForSelector("#composer-chips .cchip--device") - assert.match(await p.textContent("#composer-chips .cchip--device"), /This computer/) + await p.waitForSelector("#chat-place .cchip--device") + assert.match(await p.textContent("#chat-place .cchip--device"), /This computer/) 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("#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") await shot(p, "d4-device-1280") // 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) const [shell] = await p.evaluate(() => window.__mock.terminals()) 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(".kind__tab >> text=Agent") 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.click("#send") await p.waitForSelector("#thread-note:not([hidden])") 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.fill("dialog[open] .dialog__input", "/home/dev/other") 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 p.waitForFunction(() => /project/.test(document.querySelector("#composer-chips").textContent)) + await p.waitForFunction(() => /project/.test(document.querySelector("#chat-place").textContent)) 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()) await p.click("#new-chat-btn") await p.click(".kind__tab >> text=Agent") - await p.waitForSelector("#composer-chips .cchip") - assert.equal(await p.locator("#composer-chips .cchip--device").count(), 0) + await p.waitForSelector("#chat-settings .cchip") + assert.equal(await p.locator("#chat-place .cchip--device").count(), 0) await p.context().close() p = await page(d4(), { width: 390, height: 844 }) await p.evaluate(() => document.getElementById("new-chat-btn").click()) await p.click(".kind__tab >> text=Agent") 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") await shot(p, "d4-device-390") 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 ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/app.css b/ui/app.css index 4adb287..1437104 100644 --- a/ui/app.css +++ b/ui/app.css @@ -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__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; } -.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 > .thread { grid-row: 2; } .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 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); } .hello { display: grid; justify-items: center; gap: var(--sp-3); text-align: center; } .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:empty { display: none; } .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. */ diff --git a/ui/i18n.js b/ui/i18n.js index 4ed37bb..5bcd703 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -81,6 +81,7 @@ const LEMBAS_I18N = { "Carry it out": "Provést to", "Changed": "Změněno", "Chat": "Konverzace", + "Chat settings": "Nastavení konverzace", "Chats": "Konverzace", "Check for updates…": "Zkontrolovat aktualizace…", "Choose": "Vybrat", @@ -585,6 +586,7 @@ const LEMBAS_I18N = { "Carry it out": "Ausführen", "Changed": "Geändert", "Chat": "Chat", + "Chat settings": "Chat-Einstellungen", "Chats": "Chats", "Check for updates…": "Nach Updates suchen…", "Choose": "Auswählen", @@ -1089,6 +1091,7 @@ const LEMBAS_I18N = { "Carry it out": "Llevarlo a cabo", "Changed": "Cambiado", "Chat": "Chat", + "Chat settings": "Ajustes del chat", "Chats": "Chats", "Check for updates…": "Buscar actualizaciones…", "Choose": "Elegir", @@ -1593,6 +1596,7 @@ const LEMBAS_I18N = { "Carry it out": "Exécuter", "Changed": "Modifié", "Chat": "Conversation", + "Chat settings": "Paramètres de la conversation", "Chats": "Conversations", "Check for updates…": "Rechercher des mises à jour…", "Choose": "Choisir", @@ -2097,6 +2101,7 @@ const LEMBAS_I18N = { "Carry it out": "Végrehajtás", "Changed": "Módosítva", "Chat": "Csevegés", + "Chat settings": "Csevegés beállításai", "Chats": "Csevegések", "Check for updates…": "Frissítések keresése…", "Choose": "Kiválasztás", @@ -2601,6 +2606,7 @@ const LEMBAS_I18N = { "Carry it out": "Eseguilo", "Changed": "Modificato", "Chat": "Chat", + "Chat settings": "Impostazioni della chat", "Chats": "Chat", "Check for updates…": "Cerca aggiornamenti…", "Choose": "Scegli", @@ -3105,6 +3111,7 @@ const LEMBAS_I18N = { "Carry it out": "Wykonaj", "Changed": "Zmieniono", "Chat": "Czat", + "Chat settings": "Ustawienia czatu", "Chats": "Czaty", "Check for updates…": "Sprawdź aktualizacje…", "Choose": "Wybierz", @@ -3609,6 +3616,7 @@ const LEMBAS_I18N = { "Carry it out": "Vykonať to", "Changed": "Zmenené", "Chat": "Konverzácia", + "Chat settings": "Nastavenia konverzácie", "Chats": "Konverzácie", "Check for updates…": "Skontrolovať aktualizácie…", "Choose": "Vybrať", diff --git a/ui/index.html b/ui/index.html index c8eafac..19412dc 100644 --- a/ui/index.html +++ b/ui/index.html @@ -64,9 +64,11 @@
-
+

+ +
diff --git a/ui/js/chat.js b/ui/js/chat.js index 139d485..19b6d6b 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -731,8 +731,49 @@ function renderHead(chat) { } // 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: "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(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. */ @@ -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. */ async function renderChips() { - const box = $("composer-chips") + const box = $("chat-settings") + const message = $("composer-chips") const drawing = ++chipsDrawn const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false if (draft) { @@ -974,18 +1016,20 @@ async function renderChips() { const models = ctx.models() const model = models.find((m) => m.id === draft.model) const parts = [] + const where = [] // an agent chat's computer and folder: where it works, beside the title if (draft.kind === "agent") { const others = otherDevices() - if (others.length) parts.push(deviceChip(others)) + if (others.length) where.push(deviceChip(others)) if (draftHere()) { const roots = s?.remote?.enabled ? s.remote.roots : [] 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 { - 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()))) } + $("chat-place").replaceChildren(...where) const shell = terminalTarget() aimTerminal(shell) parts.push(modelChip(models, draft.model, (id) => { @@ -994,8 +1038,11 @@ async function renderChips() { renderChips() }, mayPick)) if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips()))) - if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip()) - else wantPicture = false + if (draft.kind === "chat" && picturesOffered()) message.replaceChildren(pictureChip()) + 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) })) // An agent chat's subagents run on its device: offered as the server says for that device. const device = draft.kind === "agent" ? draftDevice() : "" @@ -1007,20 +1054,20 @@ async function renderChips() { if (f && drawing === chipsDrawn && draft?.kind === "agent") renderChips() }) } - // Last, so the chat's own settings come first where the line scrolls. - if (shell) { - 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) - } + // The Terminal, as an open chat has it: in the header's tools. + $("chat-tools").replaceChildren(...(shell ? [terminalButton()] : [])) box.replaceChildren(...parts.filter(Boolean)) + fitHead() return } const chat = open?.chat aimTerminal(terminalTarget()) - if (!chat?.kind) return box.replaceChildren() + if (!chat?.kind) { + box.replaceChildren() + message.replaceChildren() + fitHead() + return + } const save = async (body) => { try { 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.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 }))) - if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip()) - else wantPicture = false + if (chat.kind === "chat" && picturesOffered() && !chat.archived) message.replaceChildren(pictureChip()) + else { + wantPicture = false + message.replaceChildren() + } box.replaceChildren(...parts.filter(Boolean)) + fitHead() 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)) + fitHead() } /** @@ -1505,5 +1557,6 @@ export function initChat(context) { } }) onAgentChange(renderNotice) + initHead() showComposer(false) } diff --git a/ui/js/dom.js b/ui/js/dom.js index 6363808..63eeefa 100644 --- a/ui/js/dom.js +++ b/ui/js/dom.js @@ -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"], 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"], + 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"], 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"],