A chat app's shape: sidebar, a new chat as a chat page, the chat's settings as composer chips
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s

- Sidebar: New chat, search, Scheduled, Files, the chats of both kinds by day (Today, Earlier),
  Settings with the account at the foot; folds away (Ctrl+B), a drawer on a narrow window.
- A new chat is the chat page empty: a greeting, Agent or Chat, and the composer.
- The composer is a card: the text, then attach, the chips (folder and mode for an agent chat,
  model with its picture, effort as the model offers it, the team's size), and a round Send that
  Stop replaces while a reply is written. An open chat's chips change the chat.
- Messages: the person's in a bubble on the right with attached pictures as thumbnails, a reply
  full width; thinking and tool calls as compact rows that unfold.
- Activity: a chip above the composer for every open chat, the panel on the right as before.
- The agent: a chip in the title bar opening its state and the one thing to do about it; "This
  computer is not linked yet" with Link this computer right above the composer of an agent chat,
  and "refused" only with the server's reason.
- Files: a page with the two trees as tabs. Settings: grouped, the groups listed beside them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 12:58:46 +00:00
1 parent 5b00822551
commit af14f4a229
15 files changed
+1839 -899

No files matched your search

+809 -415
View File
File diff suppressed because it is too large. Load diff
+70 -44
View File
@@ -13,13 +13,13 @@
<body>
<div class="app">
<header class="bar" data-tauri-drag-region>
<button class="bar__btn bar__side" id="side-toggle" type="button" aria-label="Chats" title="Chats" aria-expanded="false" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
<button class="bar__btn" id="side-toggle" type="button" aria-label="Sidebar" title="Sidebar" aria-expanded="true" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__update" id="update" type="button" hidden>update</button>
<button class="bar__agent" id="agent" type="button" title="Agent">
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
<button class="bar__agent" id="agent" type="button" aria-haspopup="dialog" aria-expanded="false">
<span class="dot" aria-hidden="true"></span><span class="bar__agent-text" id="agent-text">agent off</span>
</button>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
<span class="bar__sep" aria-hidden="true"></span>
@@ -41,79 +41,105 @@
</main>
</div>
<div class="shell" id="shell" hidden data-side="false" data-viewer="false">
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label data-local="true" data-server="false">
<div class="switch" role="tablist">
<button type="button" role="tab" data-kind="agent" aria-selected="true" data-i18n>Agent</button>
<button type="button" role="tab" data-kind="chat" aria-selected="false" data-i18n>Chats</button>
<div class="shell" id="shell" hidden data-side="true" data-viewer="false" data-view="chat">
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label>
<div class="side__top">
<button type="button" class="side__new" id="new-chat-btn"><span class="side__new-label" data-i18n>New chat</span><kbd class="kbd">Ctrl N</kbd></button>
<label class="search">
<span class="search__icon" id="side-search-icon"></span>
<input type="search" id="side-search" placeholder="Search chats" aria-label="Search chats" autocomplete="off" spellcheck="false" data-i18n-placeholder data-i18n-aria-label>
</label>
</div>
<button type="button" class="btn btn--primary side__new" id="new-chat-btn"><span data-i18n>New agent chat</span></button>
<ul class="list" id="chat-list"></ul>
<section class="pane" id="pane-local" data-open="true" aria-labelledby="pane-local-h">
<div class="pane__head">
<button type="button" class="pane__toggle" aria-expanded="true" id="pane-local-h"><span data-i18n>This computer</span></button>
<button type="button" class="pane__btn" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
<div class="side__nav">
<button type="button" class="navrow" id="nav-scheduled" data-view="scheduled"><span class="navrow__icon"></span><span data-i18n>Scheduled</span></button>
<button type="button" class="navrow" id="nav-files" data-view="files"><span class="navrow__icon"></span><span data-i18n>Files</span></button>
</div>
<div class="pane__body">
<div class="pane__where" id="local-where"></div>
<div class="pane__tree" id="local-tree"></div>
<ul class="list" id="chat-list" aria-label="Chats" data-i18n-aria-label></ul>
<div class="side__foot">
<button type="button" class="navrow navrow--account" id="settings-btn" data-view="settings"></button>
</div>
</section>
<section class="pane" id="pane-server" data-open="false" aria-labelledby="pane-server-h">
<div class="pane__head">
<button type="button" class="pane__toggle" aria-expanded="false" id="pane-server-h"><span data-i18n>Server files</span></button>
<button type="button" class="pane__btn" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="pane__body">
<div class="pane__tree" id="server-tree"></div>
</div>
</section>
<button type="button" class="side__settings" id="settings-btn"></button>
</nav>
<div class="scrim" id="scrim" aria-hidden="true"></div>
<main class="main" id="main" aria-labelledby="chat-title">
<main class="main" id="main" aria-labelledby="chat-title" data-new="false">
<header class="head">
<h1 class="head__title" id="chat-title"></h1>
<div class="head__place" id="chat-place"></div>
<div class="head__tools" id="chat-tools"></div>
</header>
<div class="thread" id="thread">
<p class="thread__note is-error" id="thread-note" hidden></p>
<div class="new" id="new-chat" hidden></div>
<div class="hello" id="new-chat" hidden></div>
<div class="messages" id="messages"></div>
</div>
<div class="dock" id="dock">
<p class="thread__note is-error" id="thread-note" hidden></p>
<div class="cards" id="cards" hidden></div>
<div class="notice" id="link-notice" hidden></div>
<div class="strip" id="activity-strip" hidden aria-live="polite"></div>
<form class="composer" id="composer" hidden data-running="false" data-drag="false">
<ul class="composer__files" id="composer-files" hidden></ul>
<button type="button" class="btn composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
<div class="composer__actions">
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
<button type="submit" class="btn btn--primary" id="send" data-i18n>Send</button>
<div class="composer__bar">
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
<div class="composer__chips" id="composer-chips"></div>
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
</div>
<p class="composer__drop" aria-hidden="true" data-i18n>Drop the files to attach them</p>
</form>
</div>
</main>
<section class="settings" id="settings" hidden aria-labelledby="settings-title">
<section class="page" id="page-files" hidden aria-labelledby="files-title">
<header class="head">
<h1 class="head__title" id="files-title" data-i18n>Files</h1>
<div class="head__place"></div>
<div class="head__tools"><div class="tabs" role="tablist" id="files-tabs"></div></div>
</header>
<div class="page__body files">
<section class="files__pane" id="pane-local" aria-label="This computer" data-i18n-aria-label>
<div class="files__head">
<div class="files__where" id="local-where"></div>
<button type="button" class="tool" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="files__tree" id="local-tree"></div>
</section>
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
<div class="files__head">
<div class="files__where muted" id="server-where"></div>
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="files__tree" id="server-tree"></div>
</section>
</div>
</section>
<section class="page" id="page-scheduled" hidden aria-labelledby="scheduled-title">
<header class="head">
<h1 class="head__title" id="scheduled-title" data-i18n>Scheduled</h1>
<div class="head__place" id="scheduled-place"></div>
<div class="head__tools" id="scheduled-tools"></div>
</header>
<div class="page__body" id="scheduled-body"></div>
</section>
<section class="page settings" id="settings" hidden aria-labelledby="settings-title">
<header class="head">
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
<div class="head__place"></div>
<div class="head__tools"><button type="button" class="pane__btn" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
<div class="head__tools"><button type="button" class="tool" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
</header>
<div class="settings__layout">
<nav class="settings__nav" id="settings-nav" aria-label="Settings" data-i18n-aria-label></nav>
<div class="settings__body" id="settings-body"></div>
</div>
</section>
<aside class="activity" id="activity" hidden aria-labelledby="activity-title">
<header class="viewer__head activity__head">
<button type="button" class="pane__btn" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="activity-back" hidden aria-label="Back" title="Back" data-i18n-aria-label data-i18n-title></button>
<h2 class="viewer__name" id="activity-title"></h2>
<button type="button" class="pane__btn" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="activity-close" aria-label="Close the activity panel" title="Close the activity panel" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="activity__body" id="activity-body"></div>
</aside>
@@ -125,7 +151,7 @@
<p class="viewer__where" id="viewer-where"></p>
</div>
<div class="viewer__modes" id="viewer-modes"></div>
<button type="button" class="pane__btn" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
<button type="button" class="tool" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="viewer__body" id="viewer-body"></div>
</aside>
+17 -7
View File
@@ -273,16 +273,26 @@ async function read(withContext) {
function renderStrip() {
const strip = $("activity-strip")
// While something runs, as the server's strip: a chat whose helpers all finished long ago does
// not carry "12 finished" above its message box for ever (the panel still lists them).
const text = chat && summary?.active ? summaryText(summary) : ""
strip.hidden = !text
if (!text) return clear(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")
strip.replaceChildren(
h(
"button",
{ type: "button", class: "strip__btn", "aria-label": `${text} — ${t("Open the activity panel")}`, onclick: () => showPanel(true) },
h("span", { class: "dot is-busy", "aria-hidden": "true" }),
{
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")}`,
"aria-controls": "activity",
"aria-expanded": String(panelOpen()),
onclick: togglePanel,
},
h("span", { class: "dot", "aria-hidden": "true" }),
h("span", { class: "strip__text", text }),
icon(ICONS.chevron),
),
+134
View File
@@ -0,0 +1,134 @@
// The agent on this computer as the person sees it: one sentence for its state, and the one thing
// to do about it -- link this computer, link it again, add a folder, start it. The same words in
// three places: the title bar's chip (its popover), above the composer of a new agent chat, and
// Settings. The state is the CLI's word for its link (src-tauri/src/agent.rs); "refused" is the
// server's word only, with the server's reason.
import { h, icon, ICONS } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
let linking = null // { user_code, uri } while a link waits for its approval
let linkError = ""
const watchers = new Set()
/** Linked and allowed into a folder: what an agent chat needs. */
export function agentReady(s) {
const a = s?.agent || {}
const linked = a.state === "running" || (a.state === "elsewhere" && a.detail?.linked)
return linked && !!(s.remote?.enabled && s.remote.roots?.length)
}
/**
* The agent's state for the page: `tone` (ok, busy, warn, off), the sentence, and `action`: what
* the one button does ("link", "relink", "folder", "start" or null).
*/
export function agentState(s) {
const a = s?.agent || { state: "off" }
const why = typeof a.detail === "string" ? a.detail : ""
const noFolders = s?.remote && !(s.remote.enabled && s.remote.roots?.length)
switch (a.state) {
case "not_linked":
return { tone: "warn", action: "link", sentence: t("This computer is not linked yet."), more: t("Link it to your server, and agent chats can work in its folders.") }
case "refused":
return { tone: "warn", action: "relink", sentence: t("The server refused this computer's link: %(why)s", { why: why || "…" }), more: t("Link it again to give the agent a new token.") }
case "starting":
return { tone: "busy", action: null, sentence: t("The agent is starting…") }
case "connecting":
return { tone: "busy", action: null, sentence: t("The agent is connecting to the server…") }
case "waiting":
return { tone: "warn", action: null, sentence: t("The agent is not connected: %(why)s. It keeps trying.", { why: why || "…" }) }
case "failed":
return { tone: "warn", action: s.linked ? "start" : "link", sentence: t("The agent stopped: %(why)s. Trying again shortly.", { why: why || "…" }) }
case "elsewhere": {
const pid = a.detail?.pid
if (!a.detail?.linked) return { tone: "warn", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }) }
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
return { tone: "ok", action: null, sentence: t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }) }
}
case "running":
if (noFolders) return { tone: "warn", action: "folder", sentence: t("The agent is connected, but may open no folder yet.") }
return { tone: "ok", action: null, sentence: t("The agent is running: agent chats can use this computer.") }
default:
return { tone: "off", action: s?.linked ? "start" : "link", sentence: s?.linked ? t("The agent is off.") : t("This computer is not linked yet.") }
}
}
/** Start linking this computer: the approval opens in the browser; the page hears `linking`. */
export function link() {
linkError = ""
changed()
return invoke("link_computer").catch((err) => {
linkError = why(err)
changed()
})
}
const LABELS = {
link: () => t("Link this computer"),
relink: () => t("Link again"),
folder: () => t("Add folder…"),
start: () => t("Start the agent"),
}
function run(action) {
if (action === "link" || action === "relink") return link()
if (action === "folder") return invoke("remote_add")
if (action === "start") return invoke("set_agent", { on: true })
}
/**
* The agent's state as a block: its sentence, a line more where there is one, and its button;
* while a link waits for approval, the code to compare and Cancel instead. `compact` leaves the
* second line out.
*/
export function agentBlock(s, { compact = false } = {}) {
const st = agentState(s)
const el = h("div", { class: `agentbox is-${st.tone}`, role: "status", "aria-live": "polite" })
el.append(h("span", { class: "agentbox__dot", "aria-hidden": "true" }), h("p", { class: "agentbox__text", text: st.sentence }))
if (!compact && st.more) el.append(h("p", { class: "agentbox__more", text: st.more }))
if (linking) {
el.append(
h("p", { class: "agentbox__more", text: t("Approve this code in the page that opened in your browser:") }),
h("p", { class: "code agentbox__code", text: linking.user_code }),
h(
"div",
{ class: "agentbox__actions" },
h("button", { type: "button", class: "btn btn--sm", text: t("Open the page again"), onclick: () => invoke("open_external", { url: linking.uri }).catch(() => {}) }),
h("button", { type: "button", class: "btn btn--sm", text: t("Cancel"), onclick: () => invoke("link_cancel") }),
),
)
} else if (st.action) {
el.append(
h(
"div",
{ class: "agentbox__actions" },
h("button", { type: "button", class: `btn btn--sm ${st.tone === "warn" || st.tone === "off" ? "btn--primary" : ""}`, onclick: () => run(st.action) }, st.action === "link" || st.action === "relink" ? icon(ICONS.link) : null, LABELS[st.action]()),
),
)
}
if (linkError) el.append(h("p", { class: "agentbox__error", text: linkError }))
return el
}
/** Something that shows `agentBlock` and draws it again when linking moves on. */
export function onAgentChange(fn) {
watchers.add(fn)
return () => watchers.delete(fn)
}
function changed() {
for (const fn of watchers) fn()
}
export function linkingNow() {
return linking
}
export function initAgent() {
listen("linking", (e) => {
const p = e.payload
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
linkError = p.state === "failed" ? p.message : ""
changed()
})
}
+37 -8
View File
@@ -6,8 +6,9 @@
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, listen, del, why } from "./bridge.js"
import { picture } from "./models.js"
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error? }
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error?, thumb? }
let seq = 0
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
@@ -45,15 +46,21 @@ function remove(p) {
render()
}
/** One file as the composer shows it: the same three tracks for every row. */
/**
* One file as the composer shows it: a tile -- a picture's thumbnail (a pasted one from its own
* bytes, a sent one through the bridge), or the file's icon -- its name, its size or state, and
* a button to take it off. The same tracks on every tile.
*/
function row(p) {
const status =
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
return h(
const tile = h(
"li",
{ class: `file is-${p.state}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
h("span", { class: "file__name" }, h("bdi", { text: p.name })),
h("span", { class: "file__state", text: status }),
{ class: `tile file is-${p.state}${p.thumb ? " has-thumb" : ""}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
p.thumb ? h("img", { class: "tile__thumb", src: p.thumb, alt: "", draggable: "false" }) : null,
h("span", { class: "tile__icon" }, icon(ICONS.file)),
h("span", { class: "tile__name" }, h("bdi", { text: p.name })),
h("span", { class: "tile__size", text: status }),
h(
"button",
{
@@ -67,10 +74,12 @@ function row(p) {
icon(ICONS.close),
),
)
return tile
}
function render() {
const list = clear($("composer-files"))
list.className = "tiles composer__files"
list.hidden = !pending.length
for (const p of pending) list.append(row(p))
}
@@ -90,11 +99,31 @@ async function track(names, call) {
for (const r of results || []) {
pending.push(
r.attachment
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id }
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id, image: !!r.attachment.image, thumb: r.thumb }
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
)
}
render()
// A sent picture with no thumbnail of its own yet: the server's copy, through the bridge.
for (const p of pending) {
if (p.state !== "ready" || !p.image || p.thumb) continue
picture(`/attachments/${p.id}`).then((url) => {
if (!url || !url.startsWith("data:image/") || !pending.includes(p)) return
p.thumb = url
render()
})
}
}
/** A pasted picture's own bytes as a `data:` URL: the page's policy allows images from there. */
function dataUrl(file) {
return new Promise((resolve) => {
if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) return resolve(undefined)
const reader = new FileReader()
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : undefined)
reader.onerror = () => resolve(undefined)
reader.readAsDataURL(file)
})
}
/** Choose files in the system's dialog. */
@@ -118,7 +147,7 @@ async function paste(e) {
track([name], async () => {
const data = new Uint8Array(await f.arrayBuffer())
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
return [{ name, size: f.size, attachment }]
return [{ name, size: f.size, attachment, thumb: await dataUrl(f) }]
})
}
}
+315 -188
View File
@@ -1,6 +1,7 @@
// One chat: its transcript, the reply being written (followed as the server streams it), the
// cards that wait for an answer, and the composer. And the form a new chat starts from -- an
// agent chat on this computer, in one of the agent's folders, or a chat on the server.
// cards that wait for an answer, and the composer with the chat's settings as chips under the
// text (folder, mode, model, effort, agents). And a new chat, which is the same page empty: a
// greeting, the choice of an agent chat on this computer or a chat on the server, and the composer.
//
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
// repository is the contract): a person's message is their text as typed; a reply is its steps --
@@ -11,10 +12,12 @@ import { api, get, getText, post, patch, del, follow, why, invoke, openLink } fr
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal } from "./explorer.js"
import { menu, ask, showLine } from "./popup.js"
import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js"
import { menu, ask, showLine, popover, closePopover } from "./popup.js"
import { avatar, agentsControl, teamPossible, levelsOf, levelWord, picture } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, togglePanel, panelOpen, planList } from "./activity.js"
import { watchActivity, activityChanged, planList } from "./activity.js"
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
import { folderName } from "./list.js"
const MODES = ["manual", "edit", "auto", "plan"]
@@ -50,18 +53,34 @@ function copyText(text) {
navigator.clipboard?.writeText(text).catch(() => {})
}
/**
* A file a message carries, as a tile: a picture as its thumbnail (read through the bridge, as
* a `data:` URL), anything else as its icon, name and size.
*/
export function fileTile(a) {
const tile = h(
"li",
{ class: `tile${a.image ? " tile--image" : ""}`, title: a.name },
h("span", { class: "tile__icon" }, icon(ICONS.file)),
h("span", { class: "tile__name" }, h("bdi", { text: a.name })),
h("span", { class: "tile__size", text: a.size ? bytes(a.size) : "" }),
)
if (a.image && a.id) {
picture(`/attachments/${a.id}`).then((url) => {
if (!url || !url.startsWith("data:image/")) return
tile.classList.add("has-thumb")
tile.prepend(h("img", { class: "tile__thumb", src: url, alt: "", draggable: "false" }))
})
}
return tile
}
function userMessage(m) {
const el = h(
"article",
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
h("div", { class: "msg__text", text: m.text || "" }),
m.attachments?.length
? h(
"ul",
{ class: "msg__files" },
m.attachments.map((a) => h("li", { class: "chip", title: a.name }, h("bdi", { text: a.name }), a.size ? ` · ${bytes(a.size)}` : "")),
)
: null,
m.attachments?.length ? h("ul", { class: "tiles msg__files" }, m.attachments.map(fileTile)) : null,
m.text ? h("div", { class: "msg__bubble" }, h("div", { class: "msg__text", text: m.text })) : null,
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
)
// A background job's report is not the person's to rewrite (the server refuses it too).
@@ -168,7 +187,7 @@ function assistantMessage(m, chat) {
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
),
model ? h("span", { class: "chip chip--model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
model ? h("span", { class: "msg__model", title: model.id }, avatar(model), h("span", { text: model.name })) : null,
meta,
),
)
@@ -186,7 +205,7 @@ function step(s, chat) {
return h(
"details",
{ class: "step step--thinking", open: s.open || null },
h("summary", null, t("Thought"), s.label ? h("span", { class: "muted", text: ` ${s.label}` }) : null),
h("summary", { class: "step__row" }, h("span", { class: "step__icon" }, icon(ICONS.bulb)), h("span", { class: "step__label" }, t("Thought"), s.label ? h("span", { class: "step__meta", text: ` · ${s.label}` }) : null), h("span", { class: "step__chev" }, icon(ICONS.chevron))),
h("div", { class: "step__pre", text: s.text || "" }),
)
case "text":
@@ -242,8 +261,10 @@ function toolCall(c, chat) {
"summary",
{ class: "call__head" },
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "call__title", title: block.title || c.name, text: block.title || c.name || "" }),
h("span", { class: "call__name", text: c.name || "" }),
h("span", { class: "call__title", title: block.title || c.name, text: block.title && block.title !== c.name ? block.title : "" }),
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
h("span", { class: "step__chev" }, icon(ICONS.chevron)),
)
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
if (isShow) {
@@ -377,6 +398,7 @@ class Live {
}
}
/** While a reply is written, Stop takes Send's place (Send comes back beside it for a message typed meanwhile). */
function setRunning(on) {
$("send").hidden = on && !$("composer-text").value.trim()
$("stop").hidden = !on
@@ -504,6 +526,7 @@ function setTitle(title) {
$("chat-title").title = title || ""
}
/** The chat's header: its title, where it works (an agent chat's folder and device), and its tools. */
function renderHead(chat) {
setTitle(chat.title)
const place = clear($("chat-place"))
@@ -511,47 +534,25 @@ function renderHead(chat) {
if (chat.kind === "agent" && chat.device) {
const here = onThisComputer(chat)
place.append(
h("span", { class: `chip${here ? " chip--leaf" : ""}`, text: here ? t("This computer") : t("Another device") }),
h("span", { class: "head__dir", title: chat.device.directory }, h("bdi", { text: chat.device.directory })),
h(
"span",
{ class: `place${here ? " is-here" : ""}`, title: chat.device.directory },
icon(ICONS.folder),
h("span", { class: "place__name", text: folderName(chat.device.directory) }),
h("span", { class: "place__where", text: here ? t("This computer") : t("Another device") }),
),
)
const mode = h(
"select",
{ class: "head__mode", "aria-label": t("Mode"), onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { mode: e.target.value }).catch((err) => alertIn(why(err))) },
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
)
tools.append(mode)
if (chat.device_session_id) {
tools.append(
h(
"button",
{ type: "button", class: "pane__btn", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
{ type: "button", class: "tool", "aria-label": t("Continue in a terminal"), title: t("Continue in a terminal"), onclick: () => continueInTerminal(open?.chat || chat) },
icon(ICONS.terminal),
),
)
}
}
const slot = h("span", { class: "head__model" })
tools.append(slot)
chatModels(chat).then((models) => {
if (open?.chat?.id === chat.id) headModel(slot, chat, models)
})
tools.append(
h(
"button",
{
type: "button",
class: "pane__btn",
id: "activity-open",
"aria-label": t("Activity"),
title: t("Activity"),
"aria-controls": "activity",
"aria-expanded": String(panelOpen()),
onclick: togglePanel,
},
icon(ICONS.bolt),
),
h("button", { type: "button", class: "pane__btn", "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)))
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
@@ -573,70 +574,189 @@ async function teamOffered(chat) {
}
}
/**
* The chat's model; how hard it thinks, drawn as the model offers it (nothing for one level, the
* Thinking switch, or the selector); and the team's size where a team is offered.
*/
async function headModel(slot, chat, models) {
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
const parts = []
if (mayPick && models.length > 1) {
parts.push(
modelPicker({
models,
value: chat.model,
label: t("Model"),
onChange: async (id) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { model: id })
if (open?.chat?.id === chat.id) {
open.chat = r.chat
headModel(slot, r.chat, models)
activityChanged()
}
} catch (err) {
alertIn(why(err))
headModel(slot, chat, models)
}
},
}),
// --- The chips under the text -------------------------------------------------------------------
//
// A chat's settings are small buttons under what is being written, each opening a menu: the
// folder and the mode (an agent chat), the model with its picture, how hard it thinks (as the
// model offers it: nothing for one level), and the team's size (where a team is offered). For a
// new chat they say what it will start with; for an open one, changing one changes the chat.
/** One chip: an icon or a lead (an avatar), its words, and a chevron when it opens a menu. */
function chip({ ico, lead, label, title, onclick, menuish = true, cls = "", disabled = false }) {
return h(
"button",
{ type: "button", class: `cchip ${cls}`, title: title || label, "aria-haspopup": menuish ? "menu" : null, disabled, onclick },
lead || (ico ? icon(ico) : null),
h("span", { class: "cchip__label", text: label }),
menuish ? h("span", { class: "cchip__chev" }, icon(ICONS.chevronDown)) : null,
)
} else if (chat.model) {
parts.push(h("span", { class: "chip chip--model", title: current.id }, avatar(current), h("span", { text: current.name })))
}
const level = effortControl({
model: current,
value: chat.effort || levelsOf(current).start,
compact: true,
onChange: async (value) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { effort: value })
if (open?.chat?.id === chat.id) open.chat = r.chat
} catch (err) {
alertIn(why(err))
level.value = open?.chat?.effort || levelsOf(current).start
}
}
const MODE_WORDS = {
manual: () => t("Manual — asks first"),
edit: () => t("Edit — asks before commands"),
auto: () => t("Auto — asks nothing"),
plan: () => t("Plan — changes nothing"),
}
function modeChip(value, onChange, disabled) {
return chip({
ico: ICONS.shield,
label: modeName(value),
title: `${t("Mode")}: ${MODE_WORDS[value]?.() || value}`,
disabled,
onclick: (e) => menu(e.currentTarget, MODES.map((m) => ({ label: MODE_WORDS[m](), checked: m === value, run: () => m !== value && onChange(m) }))),
})
}
function modelChip(models, value, onChange, mayPick) {
const m = models.find((x) => x.id === value) || (value ? { id: value, name: value } : null)
if (!m) return chip({ ico: ICONS.info, label: t("No model is available to this account."), menuish: false, disabled: true })
return chip({
lead: avatar(m),
label: m.name,
title: `${t("Model")}: ${m.name} (${m.id})`,
menuish: mayPick && models.length > 1,
cls: "cchip--model",
onclick: (e) => {
if (!mayPick || models.length < 2) return
menu(e.currentTarget, models.map((x) => ({ label: x.name, lead: avatar(x), checked: x.id === value, run: () => x.id !== value && onChange(x.id) })))
},
})
if (level) parts.push(level)
slot.replaceChildren(...parts)
if (!(await teamOffered(chat)) || open?.chat?.id !== chat.id || !slot.isConnected) return
const team = agentsControl({
value: open.chat.agents || chat.agents,
compact: true,
onChange: async (wanted) => {
}
/** How hard the model thinks: nothing where it has one level; else its levels, in a menu. */
function effortChip(model, value, onChange) {
const { levels, start, control } = levelsOf(model)
if (control === "none") return null
const current = levels.includes(value) ? value : start
const words = (l) => (control === "switch" ? (l === "off" ? t("off") : t("Think before answering")) : levelWord(l))
return chip({
ico: ICONS.bulb,
label: control === "switch" ? (current === "off" ? t("off") : levelWord(current)) : levelWord(current),
title: `${t("Reasoning effort")}: ${levelWord(current)}`,
cls: current === "off" ? "is-dim" : "",
onclick: (e) => menu(e.currentTarget, levels.map((l) => ({ label: words(l), hint: control === "switch" && l !== "off" ? levelWord(l) : "", checked: l === current, run: () => l !== current && onChange(l) }))),
})
}
/** The team's size: a chip saying it, opening the two fields. `value` { min, max } or empty. */
function agentsChip(value, onChange) {
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."),
onclick: (e) => {
const fields = agentsControl({ value, id: "agents-min" })
popover(
e.currentTarget,
h(
"div",
{ class: "pop__body" },
h("label", { class: "pop__title", for: "agents-min", text: t("Agents") }),
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("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") },
)
},
})
}
/** The folder of a new agent chat: one of the agent's folders, or Add folder… where there is none. */
function folderChip(roots, value, onChange) {
if (!roots.length) return chip({ ico: ICONS.folder, label: t("Add folder…"), menuish: false, cls: "is-warn", onclick: () => invoke("remote_add") })
return chip({
ico: ICONS.folder,
label: folderName(value),
title: `${t("Folder")}: ${value}`,
onclick: (e) =>
menu(e.currentTarget, [
...roots.map((r) => ({ label: folderName(r), hint: r, checked: r === value, run: () => r !== value && onChange(r) })),
null,
{ label: t("Add folder…"), run: () => invoke("remote_add") },
]),
})
}
let chipsDrawn = 0 // which drawing is the latest: an older one's late answers are dropped
/** 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 drawing = ++chipsDrawn
const mayPick = ctx.me()?.permissions?.["chat.model_select"] !== false
if (draft) {
const s = ctx.status()
const models = ctx.models()
const model = models.find((m) => m.id === draft.model)
const parts = []
if (draft.kind === "agent") {
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())))
parts.push(modeChip(draft.mode, (m) => ((draft.mode = m), renderChips())))
}
parts.push(modelChip(models, draft.model, (id) => {
draft.model = id
draft.effort = levelsOf(models.find((m) => m.id === id)).start
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())))
box.replaceChildren(...parts.filter(Boolean))
return
}
const chat = open?.chat
if (!chat?.kind) return box.replaceChildren()
const save = async (body) => {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { agents: wanted })
if (open?.chat?.id === chat.id) open.chat = r.chat
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
if (open?.chat?.id === chat.id) {
open.chat = r.chat
renderChips()
if (body.model) activityChanged()
}
alertIn("")
} catch (err) {
alertIn(why(err))
team.value = open?.chat?.agents || chat.agents
}
},
})
slot.append(team)
}
const models = await chatModels(chat)
if (drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
const current = models.find((m) => m.id === chat.model) || { id: chat.model, name: chat.model, efforts: [] }
const parts = []
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 })))
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 })))
}
/**
* Above the composer, what stands in the way of an agent chat on this computer: not linked, the
* server refused the link, no folder. A new agent chat says it; an open one on this computer too.
*/
function renderNotice() {
const box = $("link-notice")
const s = ctx.status()
const wants = draft ? draft.kind === "agent" : open?.chat?.kind === "agent" && onThisComputer(open.chat)
const show = !!s && wants && !agentReady(s) && agentState(s).tone !== "busy"
box.hidden = !show
if (show) box.replaceChildren(agentBlock(s, { compact: !draft }))
else box.replaceChildren()
}
/** The app's status changed (the agent, its folders): what depends on it is drawn again. */
export function statusChanged() {
renderNotice()
if (draft) {
renderChips()
renderHello()
}
}
/** The chat's own menu, under its ⋯ button or a row of the sidebar: `chat` as the list has it. */
@@ -735,9 +855,14 @@ export async function openChat(id) {
if (open?.chat?.id !== id) discard()
if (open?.live) open.live.stop()
open = { chat: { id }, live: null, more: false }
draft = null
closePopover()
invoke("set_open_chat", { chat: id })
showComposer(true)
$("new-chat").hidden = true
$("main").dataset.new = "false"
clear($("composer-chips"))
renderNotice()
const list = clear($("messages"))
renderCards({ id }, [])
alertIn("")
@@ -755,6 +880,8 @@ export async function openChat(id) {
open.more = r.more
watchActivity(r.chat)
renderHead(r.chat)
renderChips()
renderNotice()
clear(list)
if (r.more) list.append(earlier())
for (const m of r.messages) {
@@ -805,121 +932,119 @@ export function closeChat() {
invoke("set_open_chat", { chat: null })
}
// --- A new chat ------------------------------------------------------------------------------------
let draft = null // { kind, level (the effort control), agents (the team's size) }
let draft = null // { kind, dir, mode, model, effort, agents } while a new chat is being written
let lastKind = "agent"
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
/** The kind a new chat starts as: the last one chosen (an agent chat, at first). */
export function draftKind() {
return draft?.kind || lastKind
}
/** The greeting of a new chat, and the choice of what it is. */
function renderHello() {
const box = $("new-chat")
if (!draft) return
const me = ctx.me()
const first = String(me?.name || "").trim().split(/\s+/)[0]
const kind = draft.kind
const s = ctx.status()
const server = (() => {
try {
return new URL(s?.server || "").host
} catch {
return ""
}
})()
const tab = (k, ico, label) =>
h(
"button",
{
type: "button",
role: "tab",
class: "kind__tab",
"aria-selected": String(kind === k),
onclick: () => {
if (draft.kind === k) return
draft.kind = lastKind = k
renderHello()
renderChips()
renderNotice()
ctx.drafted?.(k)
$("composer-text").focus()
},
},
icon(ico),
h("span", { text: label }),
)
box.replaceChildren(
h("img", { class: "hello__mark", src: "logo-mark.svg", alt: "", width: "40", height: "40" }),
h("h2", { class: "hello__title", text: first ? t("Hello, %(name)s", { name: first }) : t("Hello") }),
h(
"p",
{ class: "hello__sub" },
kind === "agent"
? t("An agent chat works in a folder on this computer, through the agent built into this app.")
: t("A chat on %(server)s, with your account's models and files.", { server: server || t("Server") }),
),
h("div", { class: "kind", role: "tablist", "aria-label": t("New chat") }, tab("agent", ICONS.terminal, t("Agent")), tab("chat", ICONS.chat, t("Chat"))),
)
}
/** A new chat in the main view: the page empty, the greeting, and the composer ready. */
export function newChat(kind) {
closeChat()
discard()
draft = { kind }
closePopover()
const models = ctx.models()
const start = models.find((m) => m.default) || models[0]
lastKind = kind || lastKind
draft = { kind: lastKind, dir: "", mode: "edit", model: start?.id || "", effort: levelsOf(start).start, agents: null }
clear($("messages"))
renderCards({ id: "" }, [])
alertIn("")
setTitle(kind === "agent" ? t("New agent chat") : t("New chat"))
setTitle(t("New chat"))
clear($("chat-place"))
clear($("chat-tools"))
const form = clear($("new-chat"))
form.hidden = false
const s = ctx.status()
const models = ctx.models()
const start = (models.find((m) => m.default) || models[0])?.id || ""
// How hard it thinks, and the team's size: drawn for the chosen model, again when it changes.
// Each slot is a field of its own (label, control, hint), so the row lines up whichever is shown.
const effortField = field(t("Reasoning effort"), h("span"), "")
const agentsField = field(t("Agents"), h("span"), t("Its own agent included. Above one, it can add members that do the work while it leads them."))
const showLevels = (id) => {
const model = models.find((m) => m.id === id)
const control = effortControl({ model, value: levelsOf(model).start, id: "new-effort" })
effortField.children[0].htmlFor = "new-effort"
effortField.children[1].replaceWith(control || h("span", { id: "new-effort" }))
effortField.hidden = !control
draft.level = control
const team = kind === "chat" && teamPossible(model)
draft.agents = team ? agentsControl({ value: null, id: "new-agents" }) : null
agentsField.children[1].replaceWith(draft.agents || h("span", { id: "new-agents" }))
agentsField.children[0].htmlFor = "new-agents"
agentsField.hidden = !team
}
const modelField = field(
t("Model"),
modelPicker({ id: "new-model", models, value: start, label: t("Model"), onChange: showLevels }),
models.length ? "" : t("No model is available to this account."),
)
showLevels(start)
if (kind === "agent") {
const roots = s.remote?.enabled ? s.remote.roots : []
const devices = ctx.thisDevices()
form.append(
h("p", { class: "muted", text: t("An agent chat works in a folder on this computer, through the agent built into this app.") }),
)
if (!devices.length) {
form.append(
h("p", { class: "new__warn", text: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }),
)
}
form.append(
h(
"div",
{ class: "fields" },
field(
t("Folder"),
h("select", { id: "new-dir" }, roots.map((r) => h("option", { value: r, text: r }))),
roots.length ? t("Only the agent's folders: Settings, Project folders.") : t("No folder yet: add one under Settings, Project folders."),
),
field(
t("Mode"),
h(
"select",
{ id: "new-mode" },
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: m === "edit" })),
),
t("A chat asking for more than the folder allows is lowered to it."),
),
modelField,
effortField,
),
)
} else {
form.append(h("div", { class: "fields" }, modelField, effortField, agentsField))
}
$("main").dataset.new = "true"
$("new-chat").hidden = false
renderHello()
renderChips()
renderNotice()
showComposer(true)
setRunning(false)
invoke("set_open_chat", { chat: null })
$("composer-text").focus()
}
/** A field: label, control, hint -- always all three, so a row of them lines up. */
function field(label, control, hint) {
const id = control.id || `f${Math.random().toString(36).slice(2)}`
control.id = id
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
}
async function createChat(text, attachments) {
const body = { text }
if (attachments.length) body.attachments = attachments
const model = $("new-model")?.value
if (model) body.model = model
const effort = draft.level?.value
if (typeof effort === "string" && effort) body.effort = effort
const agents = draft.agents?.value
if (draft.model) body.model = draft.model
if (typeof draft.effort === "string" && draft.effort) body.effort = draft.effort
const agents = draft.agents
if (agents && (agents.min || agents.max)) body.agents = agents
if (draft.kind === "agent") {
const device = ctx.thisDevices()[0]
const dir = $("new-dir")?.value
if (!device) throw { detail: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }
if (!dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
Object.assign(body, { kind: "agent", device, directory: dir, mode: $("new-mode")?.value || "edit" })
if (!device || !agentReady(ctx.status())) {
renderNotice()
throw { detail: agentState(ctx.status()).sentence }
}
if (!draft.dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
}
const r = await post("/chats", body)
draft = null
$("new-chat").hidden = true
$("main").dataset.new = "false"
open = { chat: r.chat, live: null, more: false }
invoke("set_open_chat", { chat: r.chat.id })
watchActivity(r.chat)
renderHead(r.chat)
renderChips()
renderNotice()
clear($("messages"))
if (r.message) $("messages").append(userMessage(r.message))
ctx.changed(r.chat)
@@ -973,6 +1098,8 @@ function grow() {
export function initChat(context) {
ctx = context
$("send").replaceChildren(icon(ICONS.send))
$("stop").replaceChildren(icon(ICONS.stop))
$("composer").addEventListener("submit", (e) => {
e.preventDefault()
submit()
@@ -996,6 +1123,6 @@ export function initChat(context) {
alertIn(why(err))
}
})
onAgentChange(renderNotice)
showComposer(false)
}
+13
View File
@@ -84,6 +84,19 @@ export const ICONS = {
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",
send: "M8 2.25l5.03 5.03-1.06 1.06-3.22-3.22V13.5h-1.5V5.12L4.03 8.34 2.97 7.28z",
clock: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 4.5h1.5v3.19l2.03 2.03-1.06 1.06-2.47-2.47z"],
chat: "M2 2.5h12v8.5H7l-3.5 3v-3H2zM3.5 4v5.5h1.5v1.25L6.45 9.5h6.05V4z",
bulb: ["M8 1.5a4.5 4.5 0 012.75 8.06v1.94h-5.5V9.56A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-1.83 5.38l.58.45V10h2.5V8.83l.58-.45A3 3 0 008 3z", "M5.75 12.5h4.5V14h-4.5z"],
users: ["M6 2.5a2.5 2.5 0 110 5 2.5 2.5 0 010-5zM6 4a1 1 0 100 2 1 1 0 000-2z", "M1.5 13.5c0-2.5 2-4.25 4.5-4.25s4.5 1.75 4.5 4.25H9c0-1.6-1.3-2.75-3-2.75S3 11.9 3 13.5z", "M11 3.5a2 2 0 110 4 2 2 0 010-4z", "M11.75 9.4c1.65.35 2.75 1.85 2.75 4.1H13c0-1.4-.55-2.4-1.6-2.7z"],
shield: "M8 1l5.5 2v4.5c0 3.3-2.3 6-5.5 7.5-3.2-1.5-5.5-4.2-5.5-7.5V3zM4 4.05V7.5c0 2.45 1.6 4.6 4 5.85 2.4-1.25 4-3.4 4-5.85V4.05L8 2.6z",
link: ["M6.47 8.47l2-2 1.06 1.06-2 2z", "M4.6 7.4l1.06 1.06-1.4 1.4a1.5 1.5 0 002.12 2.12l1.4-1.4 1.06 1.06-1.4 1.4a3 3 0 01-4.24-4.24z", "M11.4 8.6l-1.06-1.06 1.4-1.4a1.5 1.5 0 00-2.12-2.12l-1.4 1.4L7.16 4.36l1.4-1.4a3 3 0 014.24 4.24z"],
pause: ["M4.5 3.5H7v9H4.5z", "M9 3.5h2.5v9H9z"],
play: "M5 2.75L13 8l-8 5.25z",
user: ["M8 2a3 3 0 110 6 3 3 0 010-6zm0 1.5a1.5 1.5 0 100 3 1.5 1.5 0 000-3z", "M2.5 14c0-2.9 2.4-4.75 5.5-4.75S13.5 11.1 13.5 14H12c0-2-1.65-3.25-4-3.25S4 12 4 14z"],
check: "M6.5 10.4l6-6 1.06 1.06L6.5 12.53 2.94 8.97 4 7.9z",
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
}
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
+23 -14
View File
@@ -1,6 +1,6 @@
// The two explorers, kept apart: this computer's -- the agent's working directory, read through
// a command that reaches only the agent's folders (src-tauri/src/files.rs) -- and the server's
// tree, for the chats, read through /api/v2. A file opens in the viewer either way.
// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's
// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the
// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, get, why } from "./bridge.js"
@@ -84,7 +84,7 @@ function renderLocal() {
if (!local.roots.length) {
clear(box).append(
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
h("button", { type: "button", class: "btn btn--sm", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
)
return
}
@@ -155,17 +155,26 @@ export async function openServerFile(path, name) {
}
}
export function initExplorer() {
for (const pane of document.querySelectorAll(".pane")) {
const toggle = pane.querySelector(".pane__toggle")
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
toggle.addEventListener("click", () => {
const open = pane.dataset.open !== "true"
pane.dataset.open = String(open)
toggle.setAttribute("aria-expanded", String(open))
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
})
let tab = "local"
let serverRead = false
/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */
export function showFilesTab(which) {
tab = which === "server" ? "server" : "local"
for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab))
$("pane-local").hidden = tab !== "local"
$("pane-server").hidden = tab !== "server"
if (tab === "server" && !serverRead) {
serverRead = true
renderServer()
}
}
export function initExplorer() {
const tabBtn = (which, ico, label) =>
h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label }))
$("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server")))
$("server-where").textContent = t("Your files on the server, as the chats see them.")
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("local-refresh").replaceChildren(icon(ICONS.refresh))
+79 -33
View File
@@ -1,20 +1,28 @@
// The sidebar's chats: agent chats (the default -- the agent on this computer is what the app is
// for) or chats on the server. Agent chats on this computer come first; those on another device
// of the account follow under their own heading.
// The sidebar's chats: agent chats and chats on the server in one list, newest first, under
// Today and Earlier. An agent chat carries the agent's mark and its folder's name; one that works
// on another device of the account says so in its title. The search line above narrows the list
// by title (and an agent chat by its folder) as it is typed; Ctrl+K searches everything.
import { $, h, clear, when, append } from "./dom.js"
import { $, h, clear, append, icon, ICONS } from "./dom.js"
import { get, why } from "./bridge.js"
let ctx = null // { kind(), thisDevices(), open(id), current(), menu(anchor, chat) }
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
let rows = []
let more = false
let query = ""
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
function fold(text) {
return String(text || "")
.normalize("NFD")
.replace(/\p{M}+/gu, "")
.toLowerCase()
}
/** The list again, from the server. */
export async function refreshList() {
const kind = ctx.kind()
try {
const r = await get(`/chats?kind=${kind}&limit=50`)
if (ctx.kind() !== kind) return
const r = await get("/chats?limit=50")
rows = r.chats || []
more = !!r.more
render()
@@ -26,9 +34,7 @@ export async function refreshList() {
async function loadMore() {
const last = rows[rows.length - 1]
if (!last) return
const kind = ctx.kind()
const r = await get(`/chats?kind=${kind}&limit=50&before=${encodeURIComponent(last.id)}`)
if (ctx.kind() !== kind) return
const r = await get(`/chats?limit=50&before=${encodeURIComponent(last.id)}`)
rows = rows.concat(r.chats || [])
more = !!r.more
render()
@@ -39,16 +45,30 @@ export function markCurrent() {
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
}
/** The folder's last part: what tells one project from another at a glance. */
export function folderName(dir) {
const parts = String(dir || "").replace(/\/+$/, "").split("/")
return parts[parts.length - 1] || dir || ""
}
function row(c) {
const agent = c.kind === "agent"
const elsewhere = agent && c.device && !ctx.thisDevices().includes(c.device.token_id)
const title = c.title || t("New chat")
const b = h(
"button",
{ type: "button", class: `chatrow${c.unread ? " is-unread" : ""}`, dataset: { id: c.id }, title: c.title || "" },
h("span", { class: `dot${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
h("span", { class: "row__title", text: c.title || t("New chat") }),
h("span", { class: "row__when", text: when(c.updated_at) }),
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
{
type: "button",
class: `chatrow${c.unread ? " is-unread" : ""}`,
dataset: { id: c.id, kind: c.kind },
title: agent && c.device?.directory ? `${title}\n${c.device.directory}${elsewhere ? ` · ${t("Another device")}` : ""}` : title,
},
h("span", { class: `chatrow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
h("span", { class: "chatrow__title", text: title }),
agent && c.device?.directory ? h("span", { class: "chatrow__where", text: folderName(c.device.directory) }) : h("span", { class: "chatrow__where" }),
h("span", { class: `chatrow__state${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
)
b.addEventListener("click", () => ctx.open(c.id))
b.addEventListener("click", () => ctx.open(c.id, c.kind))
// The chat's own menu (rename, archive, delete…) on a right click, or the context-menu key.
b.addEventListener("contextmenu", (e) => {
e.preventDefault()
@@ -57,30 +77,56 @@ function row(c) {
return h("li", null, b)
}
function isToday(stamp) {
const d = new Date(stamp)
if (Number.isNaN(d.getTime())) return false
const now = new Date()
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
}
function render() {
const list = clear($("chat-list"))
const kind = ctx.kind()
const visible = rows.filter((r) => !r.archived && !r.temporary)
const words = fold(query).split(/\s+/).filter(Boolean)
const visible = rows
.filter((r) => !r.archived && !r.temporary)
.filter((r) => {
if (!words.length) return true
const text = fold(`${r.title || t("New chat")} ${r.device?.directory ? folderName(r.device.directory) : ""}`)
return words.every((w) => text.includes(w))
})
if (!visible.length) {
list.append(
h("li", {
class: "list__note",
text: kind === "agent" ? t("No agent chat yet. Start one: it works in a folder on this computer.") : t("No chat yet."),
}),
)
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
return
}
if (kind === "agent") {
const mine = ctx.thisDevices()
const here = visible.filter((r) => r.device && mine.includes(r.device.token_id))
const there = visible.filter((r) => !(r.device && mine.includes(r.device.token_id)))
if (here.length) append(list, [h("li", { class: "list__head", text: t("This computer") }), here.map(row)])
if (there.length) append(list, [h("li", { class: "list__head", text: t("Other devices") }), there.map(row)])
} else append(list, visible.map(row))
if (more) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
const today = visible.filter((r) => isToday(r.updated_at))
const earlier = visible.filter((r) => !isToday(r.updated_at))
if (today.length) append(list, [h("li", { class: "list__head", text: t("Today") }), today.map(row)])
if (earlier.length) append(list, [h("li", { class: "list__head", text: t("Earlier") }), earlier.map(row)])
if (more && !words.length) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
markCurrent()
}
/** The chats as the list has them (the palette's and the sidebar's first look). */
export function listed() {
return rows
}
export function initList(context) {
ctx = context
$("side-search-icon").replaceChildren(icon(ICONS.search))
$("side-search").addEventListener("input", (e) => {
query = e.target.value
render()
})
$("side-search").addEventListener("keydown", (e) => {
if (e.key === "Escape" && e.target.value) {
e.stopPropagation()
e.target.value = ""
query = ""
render()
} else if (e.key === "Enter") {
// The first match opens.
$("chat-list").querySelector(".chatrow")?.click()
}
})
}
+123 -69
View File
@@ -1,30 +1,36 @@
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar
// (agent chats by default, chats on the server, and the two explorers), the chat, the viewer and
// the settings. Everything it knows comes from Rust: `status` (the app, the agent, the server) and
// `api` (the server's /api/v2, as the signed-in account).
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar (New
// chat, search, Scheduled, Files, the chats, Settings), and in the main area one of: a chat (a new
// one is the same page, empty), the files, the scheduled tasks, or the settings; the activity
// panel and the viewer beside it. Everything it knows comes from Rust: `status` (the app, the
// agent, the server) and `api` (the server's /api/v2, as the signed-in account).
import { $, h, clear, icon, ICONS } from "./dom.js"
import { $, h, icon, ICONS } from "./dom.js"
import { invoke, listen, get, why } from "./bridge.js"
import { renderBar, initBar } from "./titlebar.js"
import { renderSetup, initSetup } from "./setup.js"
import { renderSettings, initSettings } from "./settings.js"
import { renderSettings, initSettings, scrollToSection } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, renderServer } from "./explorer.js"
import { initExplorer, showLocal, showFilesTab } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu } from "./chat.js"
import { initChat, openChat, newChat, closeChat, openChatId, chatMenu, statusChanged, draftKind } from "./chat.js"
import { initAttach, allowAttach } from "./attach.js"
import { initPalette, openPalette } from "./palette.js"
import { initActivity, showPanel, togglePanel, panelOpen } from "./activity.js"
import { initAgent, link } from "./agent.js"
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
import { closePopover } from "./popup.js"
let status = null
let view = "" // "setup" | "app"
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
let me = null
let models = []
let devices = [] // this account's linked devices
let kind = "agent"
let started = false
let currentChat = null
const SIDE_KEY = "lembas.side"
const remembered = (() => {
try {
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
@@ -76,8 +82,10 @@ function route() {
}
if (view === "setup") renderSetup(status)
if (view === "app") {
if (!$("settings").hidden) renderSettings(status)
if (page === "settings") renderSettings(status)
localPane()
statusChanged()
accountButton()
}
}
@@ -92,10 +100,11 @@ async function startApp() {
$("thread-note").hidden = false
}
await Promise.all([loadModels(), loadDevices()])
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
$("new-chat-btn").disabled = me?.permissions && me.permissions["chat.create"] === false
allowAttach(me?.permissions?.["files.upload"] !== false)
setKind(kind)
renderServer()
accountButton()
refreshList()
if (!openChatId()) startNew()
invoke("remote_refresh")
}
@@ -121,61 +130,87 @@ async function loadDevices() {
}
}
// --- The sidebar --------------------------------------------------------------------------------
function setKind(k) {
kind = k
for (const b of document.querySelectorAll(".switch button")) b.setAttribute("aria-selected", String(b.dataset.kind === k))
$("new-chat-btn").lastChild.textContent = k === "agent" ? t("New agent chat") : t("New chat")
clear($("chat-list"))
refreshList()
// With no chat open, the main view is where a chat of this kind starts.
if (!openChatId() && $("settings").hidden) {
currentChat = null
newChat(k)
localPane()
}
/** The sidebar's foot: Settings, with the account it is signed in as. */
function accountButton() {
const who = status?.account?.name || me?.name || status?.account?.email || ""
const initial = (who.trim()[0] || "?").toUpperCase()
$("settings-btn").replaceChildren(
h("span", { class: "navrow__avatar", "aria-hidden": "true", text: initial }),
h("span", { class: "navrow__two" }, h("span", { class: "navrow__label", text: t("Settings") }), who ? h("span", { class: "navrow__sub", text: who }) : null),
h("span", { class: "navrow__icon" }, icon(ICONS.gear)),
)
}
/** This computer's pane: the open agent chat's folder when it runs here, else a folder chosen. */
// --- The pages ----------------------------------------------------------------------------------
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
function showPage(name) {
page = name
closePopover()
$("shell").dataset.view = name
$("main").hidden = name !== "chat"
$("page-files").hidden = name !== "files"
$("page-scheduled").hidden = name !== "scheduled"
$("settings").hidden = name !== "settings"
for (const b of document.querySelectorAll(".navrow[data-view]")) b.setAttribute("aria-current", String(b.dataset.view === name))
if (name !== "chat") showPanel(false)
if (narrow()) sideOpen(false)
}
/** This computer's tree: the open agent chat's folder when it runs here, else the one chosen. */
function localPane(chat) {
const roots = status?.remote?.enabled ? status.remote.roots : []
const c = chat || currentChat
const here = c?.device && thisDevices().includes(c.device.token_id)
if (here && c.device.directory) showLocal({ roots, root: c.device.directory, fixed: true })
else showLocal({ roots, root: "", fixed: false })
showLocal({ roots, root: here && roots.includes(c.device.directory) ? c.device.directory : "", fixed: false })
}
/** A new chat of `k`'s kind, in the main view. */
function startNew(k) {
showSettings(false)
if (k !== kind) setKind(k)
/** A new chat in the main view, of the kind chosen last (an agent chat, at first). */
function startNew(kind) {
showPage("chat")
currentChat = null
if (narrow()) sideOpen(false)
newChat(k)
newChat(kind || draftKind())
markCurrent()
localPane()
}
/** Open a chat, from the list or the palette: its kind's tab comes with it. */
function showChat(id, k) {
showSettings(false)
if (k && k !== kind) setKind(k)
if (narrow()) sideOpen(false)
/** Open a chat, from the list or the palette. */
function showChat(id) {
showPage("chat")
openChat(id).then(markCurrent)
}
function showSettings(on, section) {
if (!on) {
showPage("chat")
return
}
showPage("settings")
renderSettings(status)
invoke("remote_refresh")
if (section) scrollToSection(section)
}
function showFiles(which) {
showPage("files")
if (which) showFilesTab(which)
invoke("remote_refresh")
}
/** The palette's commands: what the menus and buttons do, by name. */
function commands() {
const out = [
{ label: t("New agent chat"), run: () => startNew("agent") },
{ label: t("New chat"), run: () => startNew("chat") },
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
...scheduledCommands(),
{ label: t("Files"), run: () => showFiles() },
{ label: t("Settings"), run: () => showSettings(true) },
]
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
out.push(
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: () => invoke("link_computer").catch(() => showSettings(true)) },
{ label: status?.linked ? t("Link again") : t("Link this computer"), run: link },
{ label: status?.update ? t("Install LLeMbas %(version)s…", { version: status.update }) : t("Check for updates…"), run: () => invoke("update_app") },
)
return out
@@ -185,37 +220,33 @@ function narrow() {
return window.matchMedia("(max-width: 48rem)").matches
}
/** The sidebar: shown or folded away (remembered); a drawer over the page on a narrow window. */
function sideOpen(on) {
$("shell").dataset.side = String(on)
$("side-toggle").setAttribute("aria-expanded", String(on))
}
// --- Settings -----------------------------------------------------------------------------------
function showSettings(on) {
if (on) showPanel(false)
$("settings").hidden = !on
$("main").hidden = on
if (on) {
renderSettings(status)
invoke("remote_refresh")
$("settings-close").focus()
if (!narrow()) {
try {
localStorage.setItem(SIDE_KEY, on ? "open" : "closed")
} catch {}
}
}
// --- Start --------------------------------------------------------------------------------------
async function boot() {
initBar()
initAgent()
initBar({ status: () => status, openSettings: (section) => showSettings(true, section) })
initViewer()
initExplorer()
initSetup(() => status)
initSettings(() => status)
initList({
kind: () => kind,
thisDevices,
current: () => openChatId(),
open: (id) => showChat(id),
open: (id) => {
if (narrow()) sideOpen(false)
showChat(id)
},
menu: (anchor, c) => chatMenu(anchor, c),
})
initChat({
@@ -233,14 +264,15 @@ async function boot() {
gone: (chat) => {
if (!openChatId() || openChatId() === chat.id) {
currentChat = null
newChat(kind)
newChat()
localPane()
}
refreshList()
},
})
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
initPalette({ commands, open: (c) => showChat(c.id, c.kind === "agent" ? "agent" : "chat") })
initPalette({ commands, open: (c) => showChat(c.id) })
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
onViewer((on) => {
$("shell").dataset.viewer = String(on)
})
@@ -250,33 +282,55 @@ async function boot() {
},
})
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
$("new-chat-btn").prepend(icon(ICONS.plus))
$("new-chat-btn").addEventListener("click", () => startNew(kind))
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
$("new-chat-btn").addEventListener("click", () => {
if (narrow()) sideOpen(false)
startNew()
})
$("nav-scheduled").querySelector(".navrow__icon").replaceChildren(icon(ICONS.clock))
$("nav-scheduled").addEventListener("click", () => {
showPage("scheduled")
showScheduled()
})
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
$("nav-files").addEventListener("click", () => showFiles())
accountButton()
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
$("settings-close").replaceChildren(icon(ICONS.close))
$("settings-close").addEventListener("click", () => showSettings(false))
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
$("scrim").addEventListener("click", () => sideOpen(false))
// A wide window starts with the sidebar as it was left; a narrow one with it closed.
let saved = "open"
try {
saved = localStorage.getItem(SIDE_KEY) || "open"
} catch {}
sideOpen(!narrow() && saved !== "closed")
window.matchMedia("(max-width: 48rem)").addEventListener("change", (e) => {
if (e.matches) sideOpen(false)
})
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
if (e.key === "Escape" && narrow() && $("shell").dataset.side === "true") sideOpen(false)
if (view !== "app" || document.querySelector("dialog[open]")) return
const mod = e.ctrlKey || e.metaKey
// Ctrl+K: the palette. Ctrl+N: a new chat of the tab's kind. Ctrl+,: the settings.
if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k") {
const key = e.key.toLowerCase()
// Ctrl+K: the palette. Ctrl+N: a new chat. Ctrl+,: the settings. Ctrl+B: the sidebar.
if (mod && !e.shiftKey && !e.altKey && key === "k") {
e.preventDefault()
openPalette()
} else if (mod && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "n") {
} else if (mod && !e.shiftKey && !e.altKey && key === "n") {
e.preventDefault()
startNew(kind)
startNew()
} else if (mod && e.key === ",") {
e.preventDefault()
showSettings(true)
} else if (mod && !e.shiftKey && !e.altKey && key === "b") {
e.preventDefault()
sideOpen($("shell").dataset.side !== "true")
}
})
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished."), scheduled: t("A scheduled task has a result.") } })
listen("status", (e) => {
const before = status
status = e.payload
+1 -1
View File
@@ -14,7 +14,7 @@ import { menu } from "./popup.js"
// so a missing picture is not asked for again with every redraw).
const pictures = new Map()
function picture(path) {
export function picture(path) {
if (!pictures.has(path)) pictures.set(path, invoke("model_image", { path }).catch(() => null))
return pictures.get(path)
}
+73 -3
View File
@@ -21,11 +21,12 @@ function outside(e) {
}
/**
* A menu of `items` ({ label, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
* A menu of `items` ({ label, hint?, run, lead?, checked?, danger?, disabled? }, or null for a separator), under
* `anchor` (an element) or at `{ x, y }` (page pixels). Arrow keys move, Enter chooses.
*/
export function menu(anchor, items) {
closeMenu()
closePopover()
const plain = !items.some((i) => i?.lead)
const el = h("div", { class: `menu${plain ? " menu--plain" : ""}`, role: "menu" })
for (const item of items) {
@@ -50,7 +51,9 @@ export function menu(anchor, items) {
},
},
h("span", { class: "menu__lead" }, item.lead || null),
h("span", { class: "menu__label", text: item.label }),
item.hint
? h("span", { class: "menu__label menu__label--two" }, h("span", { text: item.label }), h("span", { class: "menu__hint", text: item.hint }))
: h("span", { class: "menu__label", text: item.label }),
h("span", { class: "menu__check", "aria-hidden": "true", text: item.checked ? "✓" : "" }),
),
)
@@ -76,7 +79,9 @@ export function menu(anchor, items) {
// Its width as measured at the left edge, kept: moved right, it would shrink to fit instead.
el.style.width = `${Math.ceil(r.width)}px`
el.style.left = `${Math.max(0, Math.min(at.left, window.innerWidth - r.width))}px`
el.style.top = `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
// Below the anchor where it fits, else above it (a chip under the message box opens upwards).
const above = at.top !== undefined && at.bottom + r.height > window.innerHeight && at.top - r.height >= 0
el.style.top = above ? `${at.top - r.height}px` : `${Math.max(0, Math.min(at.bottom, window.innerHeight - r.height))}px`
openMenu = { el, back: anchor instanceof Element ? anchor : document.activeElement }
document.addEventListener("pointerdown", outside, true)
el.querySelector(".menu__item:not(:disabled)")?.focus()
@@ -166,3 +171,68 @@ export function showLine({ title, text, code, copy, copied, close }) {
copyBtn.focus()
})
}
let openPop = null
/** Close the popover that is open, if any. */
export function closePopover() {
if (!openPop) return
const { el, back, onClose } = openPop
openPop = null
el.remove()
document.removeEventListener("pointerdown", outsidePop, true)
document.removeEventListener("keydown", escPop, true)
onClose?.()
back?.focus?.()
}
function outsidePop(e) {
if (openPop && !openPop.el.contains(e.target) && !openPop.back?.contains?.(e.target)) closePopover()
}
function escPop(e) {
if (e.key === "Escape" && openPop) {
e.preventDefault()
e.stopPropagation()
closePopover()
}
}
/**
* A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function
* that draws it and is called again by `redraw`). Escape and a click outside close it. Returns
* { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge).
*/
export function popover(anchor, content, { up = false, align = "start", label = "", onClose } = {}) {
closePopover()
closeMenu()
const el = h("div", { class: "pop", role: "dialog", "aria-label": label || null })
const draw = () => el.replaceChildren(typeof content === "function" ? content() : content)
draw()
document.body.append(el)
const place = () => {
const at = anchor.getBoundingClientRect()
const r = el.getBoundingClientRect()
const left = align === "end" ? at.right - r.width : at.left
el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px`
const top = up ? at.top - r.height - 6 : at.bottom + 6
el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px`
}
place()
openPop = { el, back: anchor, onClose }
document.addEventListener("pointerdown", outsidePop, true)
document.addEventListener("keydown", escPop, true)
el.querySelector("button, input, select")?.focus()
return {
redraw() {
if (openPop?.el !== el) return
draw()
place()
},
close: closePopover,
}
}
export function popoverOpen() {
return !!openPop
}
+86 -109
View File
@@ -1,96 +1,81 @@
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
// sees). The server and the app's sign-in, the agent and the folders it may work in, the
// notifications, and this release.
// sees). Grouped, with the groups listed beside them: the account (the server and the app's
// sign-in), the agent and the folders it may work in, notifications, and this release.
import { $, h, clear } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
const AGENT = {
off: () => [t("The agent is off."), ""],
not_linked: () => [t("This computer is not linked to the server yet."), ""],
starting: () => [t("The agent is starting…"), ""],
connecting: () => [t("The agent is connecting to the server…"), ""],
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
waiting: (why) => [t("The agent is not connected: %(why)s. It keeps trying.", { why }), "is-error"],
refused: (why) => [t("The agent will not connect: %(why)s. Link this computer again.", { why }), "is-error"],
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
}
import { $, h, clear, icon, ICONS } from "./dom.js"
import { invoke, listen } from "./bridge.js"
import { agentBlock, onAgentChange, link } from "./agent.js"
let folderError = ""
let linking = null // { user_code, uri } while the computer is being linked
let linkError = ""
let current = () => null
/** The agent's state in a sentence, and its class: the CLI's word for its link. */
export function agentWords(s) {
const a = s.agent
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
if (a.state === "elsewhere") {
const pid = a.detail?.pid
if (!a.detail?.linked) return [t("A LLeMbas CLI service already runs here (pid %(pid)s), but is not connected: %(why)s", { pid, why: a.detail?.why || "…" }), "is-error"]
if (noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
return [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"]
}
if (a.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
return (AGENT[a.state] ?? AGENT.off)(a.detail || "…")
const SECTIONS = [
["account", ICONS.user, () => t("Account")],
["agent", ICONS.terminal, () => t("Agent")],
["folders", ICONS.folder, () => t("Project folders")],
["notify", ICONS.bell, () => t("Notifications")],
["about", ICONS.info, () => t("About")],
]
/** Bring one group into view (the title bar's agent panel opens Settings at Agent). */
export function scrollToSection(id) {
const el = $(`set-${id}`)
if (!el) return
el.scrollIntoView({ block: "start" })
markNav(id)
el.querySelector("button, select, input")?.focus({ preventScroll: true })
}
function markNav(id) {
for (const b of $("settings-nav").querySelectorAll("button")) b.setAttribute("aria-current", String(b.dataset.id === id))
}
function section(id, title, ...body) {
return h("section", { class: "set", "aria-labelledby": id }, h("h2", { id, text: title }), body)
return h("section", { class: "set", id: `set-${id}`, "aria-labelledby": `set-${id}-h` }, h("h2", { class: "set__title", id: `set-${id}-h`, text: title }), h("div", { class: "set__card" }, body))
}
/** One line of a group: its words on the left (a label and a hint), its control on the right. */
function line(label, hint, control) {
return h("div", { class: "set__line" }, h("div", { class: "set__words" }, h("p", { class: "set__label", text: label }), h("p", { class: "set__hint", text: hint || "" })), h("div", { class: "set__control" }, control))
}
export function renderSettings(s) {
if (!s) return
const nav = clear($("settings-nav"))
for (const [id, ico, title] of SECTIONS) {
if (id === "folders" && !(s.linked && s.remote)) continue
nav.append(h("button", { type: "button", class: "navrow", dataset: { id }, onclick: () => scrollToSection(id) }, h("span", { class: "navrow__icon" }, icon(ico)), h("span", { text: title() })))
}
const box = clear($("settings-body"))
// The server and the account.
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
box.append(
section(
"set-server",
t("Server"),
h("p", null, h("bdi", { text: s.server || "" }), s.discovery ? h("span", { class: "muted", text: ` · LLeMbas ${s.discovery.version}` }) : null),
who ? h("p", { class: "muted", text: t("Signed in as %(who)s.", { who }) }) : null,
h("p", { class: "muted", text: t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.") }),
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Sign out"), onclick: () => invoke("sign_out") })),
"account",
t("Account"),
line(t("Server"), s.discovery ? `LLeMbas ${s.discovery.version}` : "", h("bdi", { class: "set__value", text: s.server || "" })),
line(
who ? t("Signed in as %(who)s.", { who }) : t("Sign in"),
t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server."),
h("button", { type: "button", class: "btn btn--sm", text: t("Sign out"), onclick: () => invoke("sign_out") }),
),
),
)
// The agent.
const [text, cls] = agentWords(s)
const agent = section(
"set-agent",
// The agent: what it says, and the one thing to do about it; then start or stop, link again.
box.append(
section(
"agent",
t("Agent"),
h("p", { class: "muted", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
h("p", { class: `status ${cls}`, "aria-live": "polite", text }),
)
if (linking) {
agent.append(h("p", null, t("Approve this code in the page that opened in your browser:")), h("p", { class: "code", text: linking.user_code }))
}
if (linkError) agent.append(h("p", { class: "status is-error", text: linkError }))
agent.append(
h("p", { class: "set__intro", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
agentBlock(s),
h(
"div",
{ class: "row--buttons" },
h("button", {
type: "button",
class: "btn",
text: s.linked ? t("Link again") : t("Link this computer"),
onclick: () => {
linkError = ""
invoke("link_computer").catch((err) => {
linkError = why(err)
renderSettings(s)
})
},
}),
h("button", {
type: "button",
class: "btn",
text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"),
disabled: !s.linked && !s.agent_wanted,
onclick: () => invoke("set_agent", { on: !s.agent_wanted }),
}),
{ class: "set__buttons" },
h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), disabled: !s.linked && !s.agent_wanted, onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }),
s.linked && s.agent.state !== "refused" ? h("button", { type: "button", class: "btn btn--sm", text: t("Link again"), title: t("Link it again to give the agent a new token."), onclick: () => link() }) : null,
),
),
)
box.append(agent)
// The folders.
const r = s.remote
@@ -102,14 +87,15 @@ export function renderSettings(s) {
h(
"li",
null,
h("span", { class: "folders__icon" }, icon(ICONS.folder)),
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
h("button", { type: "button", class: "btn", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
h("button", { type: "button", class: "btn btn--sm", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
),
)
}
const mode = h(
"select",
{ id: "max-mode", onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
{ id: "max-mode", "aria-label": t("The most an agent chat may do here"), onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
[
["manual", t("Manual — asks first")],
["edit", t("Edit — asks before commands")],
@@ -117,72 +103,63 @@ export function renderSettings(s) {
["plan", t("Plan — changes nothing")],
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
)
const terminal = h("input", { type: "checkbox", id: "terminal", checked: r.terminal, onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
const terminal = h("input", { type: "checkbox", role: "switch", id: "terminal", checked: r.terminal, "aria-label": t("Let the server open a terminal here"), onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
box.append(
section(
"set-folders",
"folders",
t("Project folders"),
h("p", { class: "muted", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
h("p", { class: "set__intro", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
list,
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
h(
"div",
{ class: "fields" },
h("div", { class: "field" }, h("label", { for: "max-mode", text: t("The most an agent chat may do here") }), mode, h("p", { class: "hint", text: t("A chat asking for more is lowered to this.") })),
h(
"div",
{ class: "field" },
h("span", { class: "label", text: t("Terminal") }),
h("label", { class: "check" }, terminal, h("span", { text: t("Let the server open a terminal here") })),
h("p", { class: "hint", text: t("A shell as you, in one of these folders.") }),
),
),
h("div", { class: "set__buttons" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
line(t("The most an agent chat may do here"), t("A chat asking for more is lowered to this."), mode),
line(t("Let the server open a terminal here"), t("A shell as you, in one of these folders."), h("label", { class: "toggle" }, terminal)),
),
)
}
// Notifications.
box.append(
section(
"set-notify",
"notify",
t("Notifications"),
h(
"label",
{ class: "check" },
h("input", { type: "checkbox", checked: s.notify, onchange: (e) => invoke("set_notify", { on: e.target.checked }) }),
h("span", { text: t("Tell me when a reply or an agent's work is finished while I am elsewhere") }),
line(
t("Tell me when a reply or an agent's work is finished while I am elsewhere"),
t("Scheduled tasks tell you of a result, too, while the app runs."),
h("label", { class: "toggle" }, h("input", { type: "checkbox", role: "switch", checked: s.notify, "aria-label": t("Notifications"), onchange: (e) => invoke("set_notify", { on: e.target.checked }) })),
),
),
)
// This release.
box.append(
section(
"set-about",
"about",
t("About"),
h("p", null, `LLeMbas Desktop ${s.version}`),
h(
"div",
{ class: "row--buttons" },
h("button", { type: "button", class: "btn", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
line(
`LLeMbas Desktop ${s.version}`,
s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : "",
h("button", { type: "button", class: "btn btn--sm", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
),
),
)
}
export function initSettings(current) {
export function initSettings(getStatus) {
current = getStatus
listen("remote-error", (e) => {
folderError = String(e.payload)
renderSettings(current())
if (!$("settings").hidden) renderSettings(current())
})
listen("status", () => {
folderError = ""
})
listen("linking", (e) => {
const p = e.payload
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
linkError = p.state === "failed" ? p.message : ""
renderSettings(current())
onAgentChange(() => {
if (!$("settings").hidden) renderSettings(current())
})
$("settings-body").addEventListener("scroll", () => {
// The group at the top is the one marked beside them.
const top = $("settings-body").getBoundingClientRect().top
let at = ""
for (const sec of $("settings-body").querySelectorAll(".set")) if (sec.getBoundingClientRect().top - top < 64) at = sec.id.replace(/^set-/, "")
if (at) markNav(at)
})
}
+6 -3
View File
@@ -1,8 +1,8 @@
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
// server's device login: the approval page opens in the system's browser; this page shows the
// code to compare and waits. Where the server offers it (`one_approval`) and this computer is not
// linked yet, the same approval links it for the agent: there is no second one.
// code to compare and waits. Where the server offers it (`one_approval`) and the agent's link does
// not work yet, the same approval links this computer for the agent: there is no second one.
import { $, h, clear } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
@@ -61,7 +61,10 @@ export function renderSetup(s) {
if (s.discovery && !s.account) {
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
const both = s.discovery.one_approval && !s.linked
// The same approval links this computer unless the agent's link works already (Rust decides
// the same way, `link_works`).
const linked = s.agent?.state === "running" || (s.agent?.state === "elsewhere" && s.agent.detail?.linked)
const both = s.discovery.one_approval && !linked
sign.append(h("p", { class: "muted", text: both ? t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does, and the same approval links this computer for the agent.") : t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
if (s.signing_in && code) {
sign.append(
+53 -5
View File
@@ -1,9 +1,12 @@
// The title bar: the app's own, in the page (the window has no system decoration). Moving and
// double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
// does that for an undecorated one); the menu and the agent pill open the app's native menu.
// does that for an undecorated one). The menu opens the app's native menu; the agent's chip opens
// a small panel with what the agent says and the one thing to do about it.
import { $, icon, ICONS } from "./dom.js"
import { $, h, icon, ICONS } from "./dom.js"
import { invoke, listen, win } from "./bridge.js"
import { agentBlock, onAgentChange } from "./agent.js"
import { popover, closePopover } from "./popup.js"
const WORDS = {
off: () => t("agent off"),
@@ -19,8 +22,11 @@ const WORDS = {
no_folders: () => t("agent: no folders"),
}
let ctx = null // { status(), openSettings() }
let pop = null
/**
* The pill's state: what the CLI says of its link, not whether a process runs. Linked but allowed
* The chip's state: what the CLI says of its link, not whether a process runs. Linked but allowed
* into no folder is said too -- a green light would say it works when it cannot.
*/
export function pillState(s) {
@@ -53,6 +59,46 @@ export function renderBar(s) {
$("update").hidden = !s.update
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
$("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
pop?.redraw()
}
/** The chip's panel: the agent's state and its button, then start or stop, and Settings. */
function agentPanel() {
const s = ctx.status()
const box = h("div", { class: "pop__body" }, h("p", { class: "pop__title", text: t("Agent") }), agentBlock(s))
const row = h("div", { class: "pop__actions" })
if (s.linked) {
row.append(h("button", { type: "button", class: "btn btn--sm", text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"), onclick: () => invoke("set_agent", { on: !s.agent_wanted }) }))
}
row.append(
h("button", {
type: "button",
class: "btn btn--sm",
text: t("Settings"),
onclick: () => {
closePopover()
ctx.openSettings("agent")
},
}),
)
box.append(row)
return box
}
function toggleAgent() {
if (pop) {
closePopover()
return
}
$("agent").setAttribute("aria-expanded", "true")
pop = popover($("agent"), agentPanel, {
align: "end",
label: t("Agent"),
onClose: () => {
pop = null
$("agent").setAttribute("aria-expanded", "false")
},
})
}
function maximized(on) {
@@ -68,7 +114,8 @@ function menuAt(button) {
return invoke("titlebar_menu", { x: r.left, y: r.bottom })
}
export function initBar() {
export function initBar(context) {
ctx = context
$("minimize").replaceChildren(icon(ICONS.min))
$("close").replaceChildren(icon(ICONS.close))
$("menu").replaceChildren(icon(ICONS.menu))
@@ -78,7 +125,8 @@ export function initBar() {
$("close").addEventListener("click", () => win.close())
$("update").addEventListener("click", () => invoke("update_app"))
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
$("agent").addEventListener("click", toggleAgent)
onAgentChange(() => pop?.redraw())
listen("maximized", (e) => maximized(!!e.payload))
win.isMaximized().then(maximized).catch(() => maximized(false))
}