Pinned chats and chat folders in the sidebar
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 4s

The server's /api/v2 sets `pinned` on PATCH /chats/{id} and makes, renames and deletes
folders. The sidebar lists the pinned chats first, then the folders (nested, folded as the
server keeps them, each counting its chats), then the rest by day; searching lists every
match flat. A chat's menu pins it and moves it (to no folder, any folder, or a new one);
a folder's menu renames it, makes one inside it, deletes it (its chats stay). Tests F8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 13:24:34 +00:00
1 parent 04736db057
commit be70e1a628
8 files changed
+480 -10

No files matched your search

+33 -1
View File
@@ -17,7 +17,7 @@ import { avatar, agentsControl, teamPossible, namedOnly, subagentsCap, noteSubag
import { readyIds, sending, sent, discard } from "./attach.js"
import { watchActivity, activityChanged, planList, activityButton, treeChanged } from "./activity.js"
import { agentBlock, agentReady, agentState, onAgentChange } from "./agent.js"
import { folderName, listed } from "./list.js"
import { folderName, listed, folderTree, newFolder } from "./list.js"
import { unarchive } from "./archived.js"
import { isProgress, progressLine, progressTicks } from "./progress.js"
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
@@ -1033,6 +1033,10 @@ 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.archived) {
items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) })
items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, 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, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
@@ -1068,6 +1072,34 @@ export async function continueInTerminal(chat) {
})
}
/** Where a chat can go: no folder, any of the account's, or a new one. */
function moveMenu(anchor, chat) {
const here = chat.folder_id || ""
menu(anchor, [
{ label: t("No folder"), checked: !here, run: () => here && setChat(chat, { folder_id: null }) },
...folderTree().map(({ folder, depth }) => ({ label: `${"\u2003".repeat(depth)}${folder.name}`, checked: folder.id === here, run: () => folder.id !== here && setChat(chat, { folder_id: folder.id }) })),
null,
{
label: t("New folder…"),
run: async () => {
const id = await newFolder()
if (id) setChat(chat, { folder_id: id })
},
},
])
}
/** A change to a chat from its menu (pinned, its folder): the list drawn again from the server. */
async function setChat(chat, body) {
try {
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, body)
if (open?.chat?.id === chat.id && r?.chat) open.chat = r.chat
ctx.changed(r?.chat)
} catch (err) {
alertIn(why(err))
}
}
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
+156 -9
View File
@@ -1,15 +1,23 @@
// 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.
// The sidebar's chats: agent chats and chats on the server in one list, newest first. The
// pinned ones first, then the account's chat folders (`GET /folders`, nested, each folding as the
// server keeps it), then the rest under Today and Earlier. An agent chat carries the agent's mark
// and its project 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, flat; Ctrl+K searches everything.
//
// Folders are made, renamed and deleted here (`POST/PATCH/DELETE /folders`); a chat is pinned and
// moved from its own menu (`PATCH /chats/{id}` `pinned`, `folder_id`). Deleting a folder never
// deletes a chat: its chats go back to the list.
import { $, h, clear, append, icon, ICONS } from "./dom.js"
import { get, why } from "./bridge.js"
import { get, post, patch, del, why } from "./bridge.js"
import { menu, ask } from "./popup.js"
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat), note(text) }
let rows = []
let more = false
let query = ""
let folders = [] // { id, name, parent_id, collapsed, position }
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
function fold(text) {
@@ -22,9 +30,10 @@ function fold(text) {
/** The list again, from the server. */
export async function refreshList() {
try {
const r = await get("/chats?limit=50")
const [r, f] = await Promise.all([get("/chats?limit=50"), get("/folders").catch(() => ({ folders: [] }))])
rows = r.chats || []
more = !!r.more
folders = f.folders || []
render()
} catch (err) {
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
@@ -77,6 +86,108 @@ function row(c) {
return h("li", null, b)
}
/** A folder's head: it folds and unfolds; its menu renames it, makes one inside, deletes it. */
function folderHead(f, depth, count) {
const open = !f.collapsed
const head = h(
"button",
{
type: "button",
class: "folderrow",
dataset: { folder: f.id },
style: depth ? `--depth: ${depth}` : null,
"aria-expanded": String(open),
title: f.name,
onclick: () => foldFolder(f, !f.collapsed),
},
h("span", { class: "folderrow__chev" }, icon(open ? ICONS.chevronDown : ICONS.chevron)),
h("span", { class: "folderrow__icon" }, icon(ICONS.folder)),
h("span", { class: "folderrow__name", text: f.name }),
h("span", { class: "folderrow__count", text: count ? String(count) : "" }),
)
head.addEventListener("contextmenu", (e) => {
e.preventDefault()
folderMenu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : head, f)
})
const more = h("button", { type: "button", class: "folderrow__more", "aria-label": t("More"), title: t("More"), onclick: (e) => folderMenu(e.currentTarget, f) }, icon(ICONS.more))
return h("li", { class: "list__folder" }, head, more)
}
async function foldFolder(f, collapsed) {
f.collapsed = collapsed
render()
try {
await patch(`/folders/${encodeURIComponent(f.id)}`, { collapsed })
} catch {
// Folding is the sidebar's own business: drawn as asked even where the server did not keep it.
}
}
function folderMenu(anchor, f) {
menu(anchor, [
{ label: t("Rename folder"), run: () => renameFolder(f) },
{ label: t("New folder"), run: () => newFolder(f.id) },
null,
{ label: t("Delete folder"), danger: true, run: () => deleteFolder(f) },
])
}
/** A new folder, at the top level or inside `parent`: its id once made, else "". */
export async function newFolder(parent = "") {
const name = await ask({ title: t("New folder"), text: t("Folder name"), value: "", ok: t("Create"), cancel: t("Cancel") })
if (!name) return ""
try {
const r = await post("/folders", parent ? { name, parent_id: parent } : { name })
await refreshList()
return r.folder?.id || ""
} catch (err) {
ctx.note?.(why(err))
return ""
}
}
async function renameFolder(f) {
const name = await ask({ title: t("Rename folder"), text: t("Folder name"), value: f.name, ok: t("Rename"), cancel: t("Cancel") })
if (!name || name === f.name) return
try {
await patch(`/folders/${encodeURIComponent(f.id)}`, { name })
await refreshList()
} catch (err) {
ctx.note?.(why(err))
}
}
async function deleteFolder(f) {
const ok = await ask({ title: t("Delete folder"), text: t("“%(name)s” and the folders inside it are deleted. Their chats stay, back in the list.", { name: f.name }), ok: t("Delete"), cancel: t("Cancel"), danger: true })
if (!ok) return
try {
await del(`/folders/${encodeURIComponent(f.id)}`)
await refreshList()
} catch (err) {
ctx.note?.(why(err))
}
}
/** The account's chat folders, as the list last read them. */
export function listedFolders() {
return folders
}
/** The folders as a tree, depth first, each with its depth: what a menu of them lists. */
export function folderTree() {
const out = []
const known = new Set(folders.map((f) => f.id))
const walk = (parent, depth) => {
const kids = folders.filter((f) => (f.parent_id && known.has(f.parent_id) ? f.parent_id : null) === parent).sort((a, b) => (a.position ?? 0) - (b.position ?? 0) || a.name.localeCompare(b.name))
for (const f of kids) {
out.push({ folder: f, depth })
walk(f.id, depth + 1)
}
}
walk(null, 0)
return out
}
function isToday(stamp) {
const d = new Date(stamp)
if (Number.isNaN(d.getTime())) return false
@@ -98,8 +209,44 @@ function render() {
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
return
}
const today = visible.filter((r) => isToday(r.updated_at))
const earlier = visible.filter((r) => !isToday(r.updated_at))
// Searching: every match, flat. Otherwise the pinned first, then the folders, then the rest.
let rest = visible
if (!words.length) {
const pinned = visible.filter((r) => r.pinned)
if (pinned.length) append(list, [h("li", { class: "list__head", text: t("Pinned chats") }), pinned.map(row)])
const known = new Set(folders.map((f) => f.id))
const filed = (r) => !r.pinned && r.folder_id && known.has(r.folder_id)
const tree = folderTree()
if (tree.length) {
const inside = (id) => {
// A folder's count: its own chats and those of the folders inside it.
const ids = new Set([id])
for (const { folder } of tree) if (folder.parent_id && ids.has(folder.parent_id)) ids.add(folder.id)
return visible.filter((r) => filed(r) && ids.has(r.folder_id)).length
}
const hidden = new Set() // folders under a folded one
for (const { folder, depth } of tree) {
if (folder.parent_id && hidden.has(folder.parent_id)) {
hidden.add(folder.id)
continue
}
list.append(folderHead(folder, depth, inside(folder.id)))
if (folder.collapsed) {
hidden.add(folder.id)
continue
}
for (const r of visible.filter((x) => filed(x) && x.folder_id === folder.id)) {
const li = row(r)
li.firstChild.style.setProperty("--depth", String(depth + 1))
li.firstChild.classList.add("is-filed")
list.append(li)
}
}
}
rest = visible.filter((r) => !r.pinned && !filed(r))
}
const today = rest.filter((r) => isToday(r.updated_at))
const earlier = rest.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 })))
+4
View File
@@ -267,6 +267,10 @@ async function boot() {
showChat(id)
},
menu: (anchor, c) => chatMenu(anchor, c),
note: (text) => {
$("thread-note").textContent = text
$("thread-note").hidden = !text
},
})
initChat({
status: () => status,