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>
280 lines
11 KiB
JavaScript
280 lines
11 KiB
JavaScript
// 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, post, patch, del, why } from "./bridge.js"
|
|
import { menu, ask } from "./popup.js"
|
|
|
|
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) {
|
|
return String(text || "")
|
|
.normalize("NFD")
|
|
.replace(/\p{M}+/gu, "")
|
|
.toLowerCase()
|
|
}
|
|
|
|
/** The list again, from the server. */
|
|
export async function refreshList() {
|
|
try {
|
|
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) }))
|
|
}
|
|
}
|
|
|
|
async function loadMore() {
|
|
const last = rows[rows.length - 1]
|
|
if (!last) return
|
|
const r = await get(`/chats?limit=50&before=${encodeURIComponent(last.id)}`)
|
|
rows = rows.concat(r.chats || [])
|
|
more = !!r.more
|
|
render()
|
|
}
|
|
|
|
export function markCurrent() {
|
|
const id = ctx.current()
|
|
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, 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, 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()
|
|
ctx.menu(e.clientX || e.clientY ? { x: e.clientX, y: e.clientY } : b, 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
|
|
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 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: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
|
return
|
|
}
|
|
// 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 })))
|
|
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()
|
|
}
|
|
})
|
|
}
|