Memory page and search inside chats, files and memory (Ctrl+K, GET /search)
ci / ui (push) Waiting to run
ci / check (push) Failing after 7s
ci / start (push) Failing after 5s

The account's memory as the web's Settings -> Memory keeps it: add, change,
remove, over /api/v2/memories. Ctrl+K asks the server's search once two
letters are typed and lists the hits below the titles, the matched ranges
drawn as <mark> elements over plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 13:44:49 +00:00
1 parent be70e1a628
commit d691a4dcfa
9 files changed
+515 -11

No files matched your search

+70 -8
View File
@@ -1,12 +1,16 @@
// The command palette (Ctrl+K): one line to type into, and what matches -- the app's commands,
// and the chats of both kinds by title, newest first. Up and Down move, Enter does it, Escape
// closes. The chats are asked of the server each time it opens, so a chat started elsewhere
// is there.
// the chats of both kinds by title, newest first, and below them what the server's search finds
// inside them, in the file library and in memory (`GET /search`, once two letters are typed and
// the typing pauses). Up and Down move, Enter does it, Escape closes. The chats are asked of the
// server each time it opens, so a chat started elsewhere is there.
//
// A hit's snippet is plain text with the ranges that matched (`marks`): drawn as text, the
// ranges as <mark> elements. Titles and snippets may be a model's words; nothing is markup.
import { h, clear, icon, ICONS, when } from "./dom.js"
import { get } from "./bridge.js"
let ctx = null // { commands(): [{ label, hint?, run }], open(id) }
let ctx = null // { commands(): [{ label, hint?, run }], open(chat), openFile(path, name), openMemory(id) }
let dlg = null
/** Lower case, without diacritics: "Príliš" is found by "prilis". */
@@ -17,6 +21,22 @@ function fold(text) {
.toLowerCase()
}
/** `text` with the `[start, end)` ranges of `marks` as <mark> elements: text nodes only. */
function marked(text, marks) {
const el = h("span", { class: "palette__snippet" })
let at = 0
const chars = [...String(text || "")]
for (const [a, b] of (marks || []).filter((m) => Array.isArray(m) && m[1] > m[0]).sort((x, y) => x[0] - y[0])) {
if (a < at) continue
el.append(chars.slice(at, a).join(""), h("mark", { text: chars.slice(a, b).join("") }))
at = b
}
el.append(chars.slice(at).join(""))
return el
}
const SEARCH_KIND = { chats: () => t("Chats"), files: () => t("Files"), memories: () => t("Memory") }
function matches(text, words) {
const f = fold(text)
return words.every((w) => f.includes(w))
@@ -31,7 +51,11 @@ export async function openPalette() {
let chats = []
let shown = []
let at = 0
let found = [] // the server's hits for `asked`
let asked = ""
let timer = 0
const close = () => {
clearTimeout(timer)
if (!dlg) return
dlg.close()
dlg.remove()
@@ -44,16 +68,32 @@ export async function openPalette() {
const draw = () => {
const words = fold(input.value).split(/\s+/).filter(Boolean)
const commands = ctx.commands().filter((c) => matches(c.label, words))
const found = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
const titled = chats.filter((c) => matches(c.title || t("New chat"), words)).slice(0, 30)
shown = [
...commands.map((c) => ({ kind: "command", label: c.label, hint: c.hint || "", run: c.run })),
...found.map((c) => ({
...titled.map((c) => ({
kind: c.kind,
label: c.title || t("New chat"),
hint: `${c.kind === "agent" ? t("Agent") : t("Chats")} · ${when(c.updated_at)}`,
run: () => ctx.open(c),
})),
]
// What the server found inside: the chats already listed by their title once only.
const listed = new Set(titled.map((c) => c.id))
for (const group of input.value.trim() === asked ? found : []) {
for (const hit of group.hits || []) {
if (hit.kind === "chat" && listed.has(hit.id)) continue
const kind = { chat: "found-chat", file: "found-file", memory: "found-memory" }[hit.kind]
if (!kind) continue
shown.push({
kind,
label: hit.title || (hit.kind === "chat" ? t("New chat") : hit.kind === "file" ? String(hit.path || "").split("/").pop() : t("Memory")),
snippet: hit.snippet ? marked(hit.snippet, hit.marks) : null,
hint: SEARCH_KIND[group.kind]?.() || "",
run: () => (hit.kind === "chat" ? ctx.open({ id: hit.id, kind: hit.agent ? "agent" : "chat" }) : hit.kind === "file" ? ctx.openFile(hit.path, hit.title) : ctx.openMemory(hit.id)),
})
}
}
at = Math.min(at, Math.max(0, shown.length - 1))
clear(list)
if (!shown.length) list.append(h("li", { class: "palette__none", text: t("Nothing matches.") }))
@@ -74,8 +114,10 @@ export async function openPalette() {
}
},
},
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "agent" ? ICONS.folder : ICONS.file)),
h("span", { class: "palette__label", text: item.label }),
h("span", { class: "palette__icon" }, icon(item.kind === "command" ? ICONS.chevron : item.kind === "found-memory" ? ICONS.bulb : item.kind.startsWith("found") ? ICONS.search : item.kind === "agent" ? ICONS.folder : ICONS.file)),
item.snippet
? h("span", { class: "palette__label palette__label--two" }, h("span", { class: "palette__title", text: item.label }), item.snippet)
: h("span", { class: "palette__label", text: item.label }),
h("span", { class: "palette__hint", text: item.hint }),
),
)
@@ -87,9 +129,29 @@ export async function openPalette() {
input.setAttribute("aria-activedescendant", shown.length ? `palette-${at}` : "")
list.querySelector(`#palette-${at}`)?.scrollIntoView({ block: "nearest" })
}
// The server's search, once the typing pauses: an older server without it finds nothing more.
const search = () => {
clearTimeout(timer)
const q = input.value.trim()
if (q.length < 2) {
found = []
asked = ""
return
}
timer = setTimeout(async () => {
try {
const r = await get(`/search?q=${encodeURIComponent(q)}&in=all&limit=8`)
if (!dlg || input.value.trim() !== q) return
found = r.results || []
asked = q
draw()
} catch {}
}, 250)
}
input.addEventListener("input", () => {
at = 0
draw()
search()
})
input.addEventListener("keydown", (e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {