Archived chats: a page to find them again, Restore chat, delete
Archive was one-way in the app. Archived (sidebar, under Files) reads
GET /api/v2/chats?archived=true, newest first and paged; a row opens the
chat, Restore chat PATCHes archived:false and reads the sidebar again, and
the row's menu deletes. An open archived chat says so in its header, with
Restore chat there and in its menu. The words are the web's own ("Archived",
"Restore chat"). Tests: D2 at 1280 and 390 px.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+323
-3
No files matched your search
@@ -0,0 +1,135 @@
|
||||
// Archived chats: the ones put away from the sidebar with Archive chat, newest first. Each can be
|
||||
// opened (read, and written to, as any chat), taken back into the sidebar (Restore chat), or deleted.
|
||||
//
|
||||
// GET /chats?archived=true&limit=&before= { chats: [chat], more }
|
||||
// PATCH /chats/{id} { archived: false } { chat }
|
||||
// DELETE /chats/{id} 204
|
||||
//
|
||||
// A chat's title is the person's or a model's words, set as text.
|
||||
|
||||
import { $, h, clear, icon, ICONS, when } from "./dom.js"
|
||||
import { get, patch, del, why } from "./bridge.js"
|
||||
import { ask, menu } from "./popup.js"
|
||||
import { folderName } from "./list.js"
|
||||
|
||||
let ctx = null // { open(id), changed(), me() }
|
||||
let rows = []
|
||||
let more = false
|
||||
let error = ""
|
||||
let loading = false
|
||||
|
||||
/** The page, read again from the server. */
|
||||
export async function showArchived() {
|
||||
loading = true
|
||||
error = ""
|
||||
render()
|
||||
try {
|
||||
const r = await get("/chats?archived=true&limit=50")
|
||||
rows = r.chats || []
|
||||
more = !!r.more
|
||||
} catch (err) {
|
||||
rows = []
|
||||
more = false
|
||||
error = why(err)
|
||||
}
|
||||
loading = false
|
||||
render()
|
||||
}
|
||||
|
||||
async function loadMore(button) {
|
||||
const last = rows[rows.length - 1]
|
||||
if (!last) return
|
||||
button.disabled = true
|
||||
try {
|
||||
const r = await get(`/chats?archived=true&limit=50&before=${encodeURIComponent(last.id)}`)
|
||||
rows = rows.concat(r.chats || [])
|
||||
more = !!r.more
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
/** Back into the sidebar. */
|
||||
export async function unarchive(chat) {
|
||||
try {
|
||||
const r = await patch(`/chats/${encodeURIComponent(chat.id)}`, { archived: false })
|
||||
rows = rows.filter((x) => x.id !== chat.id)
|
||||
error = ""
|
||||
render()
|
||||
ctx?.changed(r?.chat || chat)
|
||||
return true
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
render()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(chat) {
|
||||
const yes = await ask({
|
||||
title: t("Delete this chat?"),
|
||||
text: t("“%(title)s” is deleted on the server, with its messages and files. This cannot be undone.", { title: chat.title || t("New chat") }),
|
||||
ok: t("Delete"),
|
||||
cancel: t("Cancel"),
|
||||
danger: true,
|
||||
})
|
||||
if (!yes) return
|
||||
try {
|
||||
await del(`/chats/${encodeURIComponent(chat.id)}`)
|
||||
rows = rows.filter((x) => x.id !== chat.id)
|
||||
error = ""
|
||||
} catch (err) {
|
||||
error = why(err)
|
||||
}
|
||||
render()
|
||||
}
|
||||
|
||||
function row(c) {
|
||||
const agent = c.kind === "agent"
|
||||
const title = c.title || t("New chat")
|
||||
const mayDelete = ctx.me()?.permissions?.["chat.delete"] !== false
|
||||
const sub = [agent && c.device?.directory ? folderName(c.device.directory) : "", when(c.updated_at)].filter(Boolean).join(" · ")
|
||||
return h(
|
||||
"li",
|
||||
{ class: "arow", dataset: { id: c.id } },
|
||||
h("span", { class: `arow__icon${agent ? " is-agent" : ""}` }, icon(agent ? ICONS.terminal : ICONS.chat)),
|
||||
h(
|
||||
"button",
|
||||
{ type: "button", class: "arow__open", title, onclick: () => ctx.open(c.id) },
|
||||
h("span", { class: "arow__title" }, h("bdi", { text: title })),
|
||||
h("span", { class: "arow__sub", text: sub }),
|
||||
),
|
||||
h("button", { type: "button", class: "btn btn--sm arow__unarchive", text: t("Restore chat"), onclick: () => unarchive(c) }),
|
||||
mayDelete
|
||||
? h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "tool arow__more",
|
||||
"aria-label": t("More"),
|
||||
title: t("More"),
|
||||
onclick: (e) => menu(e.currentTarget, [{ label: t("Delete chat"), danger: true, run: () => remove(c) }]),
|
||||
},
|
||||
icon(ICONS.more),
|
||||
)
|
||||
: h("span", { class: "arow__more" }),
|
||||
)
|
||||
}
|
||||
|
||||
function render() {
|
||||
const box = clear($("archived-body"))
|
||||
const parts = []
|
||||
if (error) parts.push(h("p", { class: "status is-error", text: error }))
|
||||
if (loading && !rows.length) parts.push(h("p", { class: "muted page__loading", text: t("Loading…") }))
|
||||
else if (!rows.length && !error) parts.push(h("p", { class: "muted arch__empty", text: t("No archived chats.") }))
|
||||
if (rows.length) {
|
||||
parts.push(h("ul", { class: "arch", "aria-label": t("Archived") }, rows.map(row)))
|
||||
if (more) parts.push(h("button", { type: "button", class: "btn btn--sm arch__more", text: t("More"), onclick: (e) => loadMore(e.currentTarget) }))
|
||||
}
|
||||
box.append(h("div", { class: "arch__page" }, parts))
|
||||
}
|
||||
|
||||
export function initArchived(context) {
|
||||
ctx = context
|
||||
}
|
||||
+13
-1
@@ -18,6 +18,7 @@ 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 } from "./list.js"
|
||||
import { unarchive } from "./archived.js"
|
||||
import { isProgress, progressLine, progressTicks } from "./progress.js"
|
||||
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
|
||||
|
||||
@@ -691,6 +692,8 @@ function renderHead(chat) {
|
||||
// "Continue in a terminal" stays in the chat's menu.
|
||||
if (deviceOf(chat.device.token_id)?.terminal) tools.append(terminalButton())
|
||||
}
|
||||
// An archived chat says so where it is read, with the way back into the sidebar.
|
||||
if (chat.archived) tools.append(h("button", { type: "button", class: "btn btn--sm head__unarchive", text: t("Restore chat"), title: t("Archived"), onclick: () => unarchiveChat(open?.chat || chat) }))
|
||||
tools.append(activityButton())
|
||||
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)))
|
||||
}
|
||||
@@ -947,7 +950,7 @@ export function chatMenu(anchor, chat) {
|
||||
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) })
|
||||
items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) })
|
||||
if (mayDelete) items.push({ label: t("Delete chat"), danger: true, run: () => deleteChat(chat) })
|
||||
menu(anchor, items)
|
||||
}
|
||||
@@ -1004,6 +1007,15 @@ async function archiveChat(chat) {
|
||||
}
|
||||
}
|
||||
|
||||
/** An archived chat, open or in its page's menu, back into the sidebar. */
|
||||
async function unarchiveChat(chat) {
|
||||
if (!(await unarchive(chat))) return
|
||||
if (open?.chat?.id === chat.id) {
|
||||
open.chat = { ...open.chat, archived: false }
|
||||
renderHead(open.chat)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChat(chat) {
|
||||
const yes = await ask({
|
||||
title: t("Delete this chat?"),
|
||||
|
||||
@@ -99,6 +99,7 @@ export const ICONS = {
|
||||
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"],
|
||||
archive: ["M1.5 2.5h13v3.5h-13zM3 4v.5h10V4z", "M2.5 6.5H4V12h8V6.5h1.5v7h-11z", "M6 7.5h4V9H6z"],
|
||||
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"],
|
||||
}
|
||||
|
||||
|
||||
+20
-2
@@ -19,12 +19,13 @@ 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 { initArchived, showArchived } from "./archived.js"
|
||||
import { closePopover } from "./popup.js"
|
||||
import { noteSubagentsCap } from "./models.js"
|
||||
|
||||
let status = null
|
||||
let view = "" // "setup" | "app"
|
||||
let page = "chat" // "chat" | "files" | "scheduled" | "settings"
|
||||
let page = "chat" // "chat" | "files" | "scheduled" | "archived" | "settings"
|
||||
let me = null
|
||||
let models = []
|
||||
let devices = [] // this account's linked devices
|
||||
@@ -154,7 +155,7 @@ function accountButton() {
|
||||
|
||||
// --- The pages ----------------------------------------------------------------------------------
|
||||
|
||||
/** Show one page in the main area: the chat, the files, the scheduled tasks, or the settings. */
|
||||
/** Show one page in the main area: the chat, the files, the scheduled tasks, the archived chats, or the settings. */
|
||||
function showPage(name) {
|
||||
page = name
|
||||
closePopover()
|
||||
@@ -162,6 +163,7 @@ function showPage(name) {
|
||||
$("main").hidden = name !== "chat"
|
||||
$("page-files").hidden = name !== "files"
|
||||
$("page-scheduled").hidden = name !== "scheduled"
|
||||
$("page-archived").hidden = name !== "archived"
|
||||
$("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") {
|
||||
@@ -209,6 +211,11 @@ function showSettings(on, section) {
|
||||
if (section) scrollToSection(section)
|
||||
}
|
||||
|
||||
function showArchivedPage() {
|
||||
showPage("archived")
|
||||
showArchived()
|
||||
}
|
||||
|
||||
function showFiles(which) {
|
||||
showPage("files")
|
||||
if (which) showFilesTab(which)
|
||||
@@ -223,6 +230,7 @@ function commands() {
|
||||
{ label: t("Scheduled"), run: () => (showPage("scheduled"), showScheduled()) },
|
||||
...scheduledCommands(),
|
||||
{ label: t("Files"), run: () => showFiles() },
|
||||
{ label: t("Archived"), run: showArchivedPage },
|
||||
{ label: t("Settings"), run: () => showSettings(true) },
|
||||
]
|
||||
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
|
||||
@@ -291,6 +299,12 @@ async function boot() {
|
||||
},
|
||||
})
|
||||
initAttach({ chat: () => openChatId() || "", allowed: () => me?.permissions?.["files.upload"] !== false })
|
||||
initArchived({
|
||||
me: () => me,
|
||||
open: (id) => showChat(id),
|
||||
// Back in the sidebar: the list again.
|
||||
changed: () => refreshList(),
|
||||
})
|
||||
initPalette({ commands, open: (c) => showChat(c.id) })
|
||||
initScheduled({ status: () => status, models: () => models, me: () => me, openChat: (id) => showChat(id), show: () => showPage("scheduled") })
|
||||
onViewer((on) => {
|
||||
@@ -326,6 +340,10 @@ async function boot() {
|
||||
})
|
||||
$("nav-files").querySelector(".navrow__icon").replaceChildren(icon(ICONS.folder))
|
||||
$("nav-files").addEventListener("click", () => showFiles())
|
||||
$("nav-archived").querySelector(".navrow__icon").replaceChildren(icon(ICONS.archive))
|
||||
$("nav-archived").addEventListener("click", showArchivedPage)
|
||||
$("archived-refresh").replaceChildren(icon(ICONS.refresh))
|
||||
$("archived-refresh").addEventListener("click", () => showArchived())
|
||||
accountButton()
|
||||
$("settings-btn").addEventListener("click", () => showSettings(page !== "settings"))
|
||||
$("settings-close").replaceChildren(icon(ICONS.close))
|
||||
|
||||
Reference in new issue
Block a user