A chat app's shape: sidebar, a new chat as a chat page, the chat's settings as composer chips
- Sidebar: New chat, search, Scheduled, Files, the chats of both kinds by day (Today, Earlier), Settings with the account at the foot; folds away (Ctrl+B), a drawer on a narrow window. - A new chat is the chat page empty: a greeting, Agent or Chat, and the composer. - The composer is a card: the text, then attach, the chips (folder and mode for an agent chat, model with its picture, effort as the model offers it, the team's size), and a round Send that Stop replaces while a reply is written. An open chat's chips change the chat. - Messages: the person's in a bubble on the right with attached pictures as thumbnails, a reply full width; thinking and tool calls as compact rows that unfold. - Activity: a chip above the composer for every open chat, the panel on the right as before. - The agent: a chip in the title bar opening its state and the one thing to do about it; "This computer is not linked yet" with Link this computer right above the composer of an agent chat, and "refused" only with the server's reason. - Files: a page with the two trees as tabs. Settings: grouped, the groups listed beside them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
15 files changed
+1856
-916
No files matched your search
+79
-33
@@ -1,20 +1,28 @@
|
||||
// The sidebar's chats: agent chats (the default -- the agent on this computer is what the app is
|
||||
// for) or chats on the server. Agent chats on this computer come first; those on another device
|
||||
// of the account follow under their own heading.
|
||||
// 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.
|
||||
|
||||
import { $, h, clear, when, append } from "./dom.js"
|
||||
import { $, h, clear, append, icon, ICONS } from "./dom.js"
|
||||
import { get, why } from "./bridge.js"
|
||||
|
||||
let ctx = null // { kind(), thisDevices(), open(id), current(), menu(anchor, chat) }
|
||||
let ctx = null // { thisDevices(), open(id, kind), current(), menu(anchor, chat) }
|
||||
let rows = []
|
||||
let more = false
|
||||
let query = ""
|
||||
|
||||
/** 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() {
|
||||
const kind = ctx.kind()
|
||||
try {
|
||||
const r = await get(`/chats?kind=${kind}&limit=50`)
|
||||
if (ctx.kind() !== kind) return
|
||||
const r = await get("/chats?limit=50")
|
||||
rows = r.chats || []
|
||||
more = !!r.more
|
||||
render()
|
||||
@@ -26,9 +34,7 @@ export async function refreshList() {
|
||||
async function loadMore() {
|
||||
const last = rows[rows.length - 1]
|
||||
if (!last) return
|
||||
const kind = ctx.kind()
|
||||
const r = await get(`/chats?kind=${kind}&limit=50&before=${encodeURIComponent(last.id)}`)
|
||||
if (ctx.kind() !== kind) return
|
||||
const r = await get(`/chats?limit=50&before=${encodeURIComponent(last.id)}`)
|
||||
rows = rows.concat(r.chats || [])
|
||||
more = !!r.more
|
||||
render()
|
||||
@@ -39,16 +45,30 @@ export function markCurrent() {
|
||||
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 }, title: c.title || "" },
|
||||
h("span", { class: `dot${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
|
||||
h("span", { class: "row__title", text: c.title || t("New chat") }),
|
||||
h("span", { class: "row__when", text: when(c.updated_at) }),
|
||||
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
|
||||
{
|
||||
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))
|
||||
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()
|
||||
@@ -57,30 +77,56 @@ function row(c) {
|
||||
return h("li", null, b)
|
||||
}
|
||||
|
||||
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 kind = ctx.kind()
|
||||
const visible = rows.filter((r) => !r.archived && !r.temporary)
|
||||
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: kind === "agent" ? t("No agent chat yet. Start one: it works in a folder on this computer.") : t("No chat yet."),
|
||||
}),
|
||||
)
|
||||
list.append(h("li", { class: "list__note", text: words.length ? t("Nothing matches.") : t("No chat yet. Start one with New chat.") }))
|
||||
return
|
||||
}
|
||||
if (kind === "agent") {
|
||||
const mine = ctx.thisDevices()
|
||||
const here = visible.filter((r) => r.device && mine.includes(r.device.token_id))
|
||||
const there = visible.filter((r) => !(r.device && mine.includes(r.device.token_id)))
|
||||
if (here.length) append(list, [h("li", { class: "list__head", text: t("This computer") }), here.map(row)])
|
||||
if (there.length) append(list, [h("li", { class: "list__head", text: t("Other devices") }), there.map(row)])
|
||||
} else append(list, visible.map(row))
|
||||
if (more) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
|
||||
const today = visible.filter((r) => isToday(r.updated_at))
|
||||
const earlier = visible.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()
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user