Pinned chats and chat folders in the sidebar
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:
8 files changed
+480
-10
No files matched your search
+33
-1
@@ -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
@@ -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 })))
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user