The activity panel, and continuing an agent chat in a terminal
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

The activity panel beside a chat, from /api/v2's activity routes: Context
(used share, tokens against the window, a segmented bar and a row per
category), Outputs (the files written, lines added and removed, each opening
in the viewer), Background tasks (a team's members as a tree, helpers and
commands: state, elapsed time counting on between reads, model, latest step,
what it is writing, its whole conversation read-only, Stop -- a member after
a confirmation, as it takes those under it), the team's room (who to whom,
reports marked, the words through the page's own markdown) and the plan.

The chat follows GET /chats/{chat}/activity/events while it is open: the strip
above the message box sums the agents up and opens the panel; a new version
reads the panel again while it is open, the costly context part on opening,
after a reply and at most every 15 s. The stream is reopened when the server
closes it, and more slowly while it fails. Beside the chat on a wide window
(the viewer then goes over the chat), over it below --bp-wide, a full-height
sheet at --bp-narrow. The header's Activity button, the palette and Escape
open and close it.

An agent chat with a device session (device_session_id) can be continued in a
terminal: the chat's menu and a header button show 'cd <folder> && lembas -s
<id>' to copy, with lembas the CLI as it starts here (on PATH, else the app's
own copy by its path). Shown rather than run: no terminal opens the same way
on every desktop.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 10:50:46 +00:00
1 parent 844684a152
commit d04bc4b6bd
10 files changed
+1072 -40

No files matched your search

+65 -13
View File
@@ -11,9 +11,10 @@ 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 } from "./popup.js"
import { menu, ask, showLine } from "./popup.js"
import { avatar, modelPicker, effortControl, agentsControl, teamPossible, levelsOf } from "./models.js"
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, togglePanel, panelOpen, planList } from "./activity.js"
const MODES = ["manual", "edit", "auto", "plan"]
@@ -369,7 +370,10 @@ class Live {
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
this.status.classList.add("is-error")
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
} else reload()
} else {
reload()
activityChanged()
}
}
}
@@ -493,16 +497,6 @@ function cardOf(chat, card) {
return el
}
function planList(plan) {
return h(
"ol",
{ class: "plan" },
(plan.phases || []).map((p) =>
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
),
)
}
// --- Opening a chat --------------------------------------------------------------------------------
function setTitle(title) {
@@ -526,13 +520,38 @@ function renderHead(chat) {
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) },
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", "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: "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)),
)
}
/** The models this chat may switch to (its data group's), or the account's when that fails. */
@@ -574,6 +593,7 @@ async function headModel(slot, chat, models) {
if (open?.chat?.id === chat.id) {
open.chat = r.chat
headModel(slot, r.chat, models)
activityChanged()
}
} catch (err) {
alertIn(why(err))
@@ -624,12 +644,41 @@ export function chatMenu(anchor, chat) {
const base = ctx.status()?.server
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
const items = [{ label: t("Rename…"), run: () => renameChat(chat) }]
if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) })
if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) })
items.push(null, { label: t("Archive chat"), run: () => archiveChat(chat) })
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
menu(anchor, items)
}
/**
* Continue an agent chat in a terminal: the CLI's own session on the device it works on
* (`device_session_id`), opened with `lembas -s <id>` in the chat's folder. Shown to copy rather
* than run: a terminal is not something every desktop opens the same way, and the command is the
* person's to run where they want it. On this computer the first word is the CLI as it is
* started here (`lembas` on PATH, else the app's own copy by its path).
*/
export async function continueInTerminal(chat) {
const session = String(chat.device_session_id || "")
if (!/^[A-Za-z0-9_-]+$/.test(session)) return
const here = onThisComputer(chat)
let word = "lembas"
if (here) word = await invoke("terminal_word").catch(() => "lembas")
const dir = chat.device?.directory || ""
const quoted = /^[A-Za-z0-9/._+:@%=-]+$/.test(dir) ? dir : `'${dir.replace(/'/g, "'\\''")}'`
const code = `${dir ? `cd ${quoted} && ` : ""}${word} -s ${session}`
await showLine({
title: t("Continue in a terminal"),
text: here
? t("Run this in a terminal: the agent's session goes on there, in this chat's folder, and the chat keeps it.")
: t("Run this in a terminal on the computer this chat works on: the agent's session goes on there, and the chat keeps it."),
code,
copy: t("Copy"),
copied: t("Copied"),
close: t("Close"),
})
}
async function renameChat(chat) {
const title = await ask({ title: t("Rename chat"), value: chat.title || "", ok: t("Rename"), cancel: t("Cancel") })
if (!title || title === chat.title) return
@@ -704,6 +753,7 @@ export async function openChat(id) {
if (open?.chat?.id !== id) return
open.chat = r.chat
open.more = r.more
watchActivity(r.chat)
renderHead(r.chat)
clear(list)
if (r.more) list.append(earlier())
@@ -751,6 +801,7 @@ function reload() {
export function closeChat() {
if (open?.live) open.live.stop()
open = null
watchActivity(null)
invoke("set_open_chat", { chat: null })
}
@@ -869,6 +920,7 @@ async function createChat(text, attachments) {
$("new-chat").hidden = true
open = { chat: r.chat, live: null, more: false }
invoke("set_open_chat", { chat: r.chat.id })
watchActivity(r.chat)
renderHead(r.chat)
clear($("messages"))
if (r.message) $("messages").append(userMessage(r.message))