Files
LLeMbas-Desktop/ui/js/attach.js
T
HomerandClaude Opus 5.5 af14f4a229
ci / check (push) Failing after 3s
ci / start (push) Failing after 2s
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>
2026-11-09 12:58:46 +00:00

177 lines
6.4 KiB
JavaScript

// The files the next message carries. Three ways in, each the person's own act: the attach
// button (the system's file dialog, in Rust), files dropped on the window (Rust holds the drop;
// the page never names a path), and an image pasted into the composer (its bytes, as pasted).
// Each is sent to the server at once (`/api/v2/attachments`), shown above the composer, and goes
// with the message that is sent next; one taken off before that is deleted on the server.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, listen, del, why } from "./bridge.js"
import { picture } from "./models.js"
let pending = [] // { key, name, size, state: "sending" | "ready" | "failed", id?, error?, thumb? }
let seq = 0
let ctx = null // { chat(): the open chat's id, or "" for one not made yet; allowed(): bool }
const REASONS = {
folder: () => t("This is a folder; attach the files in it."),
too_large: () => t("This file is too large to send."),
}
/** The ids of the files sent and ready to go with the message. */
export function readyIds() {
return pending.filter((p) => p.state === "ready").map((p) => p.id)
}
/** A file is still being sent. */
export function sending() {
return pending.some((p) => p.state === "sending")
}
/** After a send: the files belong to the message now. */
export function sent() {
pending = []
render()
}
/** Another chat opened, or a new one: files never sent are deleted on the server. */
export function discard() {
for (const p of pending) if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
pending = []
render()
}
function remove(p) {
if (p.state === "ready") del(`/attachments/${encodeURIComponent(p.id)}`).catch(() => {})
pending = pending.filter((x) => x !== p)
render()
}
/**
* One file as the composer shows it: a tile -- a picture's thumbnail (a pasted one from its own
* bytes, a sent one through the bridge), or the file's icon -- its name, its size or state, and
* a button to take it off. The same tracks on every tile.
*/
function row(p) {
const status =
p.state === "sending" ? t("Sending…") : p.state === "failed" ? p.error || "" : p.size ? bytes(p.size) : ""
const tile = h(
"li",
{ class: `tile file is-${p.state}${p.thumb ? " has-thumb" : ""}`, title: p.state === "failed" ? `${p.name}: ${p.error || ""}` : p.name },
p.thumb ? h("img", { class: "tile__thumb", src: p.thumb, alt: "", draggable: "false" }) : null,
h("span", { class: "tile__icon" }, icon(ICONS.file)),
h("span", { class: "tile__name" }, h("bdi", { text: p.name })),
h("span", { class: "tile__size", text: status }),
h(
"button",
{
type: "button",
class: "file__remove",
"aria-label": t("Remove %(dir)s", { dir: p.name }),
title: t("Remove"),
disabled: p.state === "sending",
onclick: () => remove(p),
},
icon(ICONS.close),
),
)
return tile
}
function render() {
const list = clear($("composer-files"))
list.className = "tiles composer__files"
list.hidden = !pending.length
for (const p of pending) list.append(row(p))
}
/** Show `names` as being sent while `call` sends them; then what became of each. */
async function track(names, call) {
const placeholders = names.map((name) => ({ key: ++seq, name, size: 0, state: "sending" }))
pending.push(...placeholders)
render()
let results
try {
results = await call()
} catch (err) {
results = placeholders.map((p) => ({ name: p.name, size: 0, error: why(err) }))
}
pending = pending.filter((p) => !placeholders.includes(p))
for (const r of results || []) {
pending.push(
r.attachment
? { key: ++seq, name: r.attachment.name || r.name, size: r.attachment.size || r.size, state: "ready", id: r.attachment.id, image: !!r.attachment.image, thumb: r.thumb }
: { key: ++seq, name: r.name, size: r.size, state: "failed", error: (REASONS[r.reason] || (() => r.error))() },
)
}
render()
// A sent picture with no thumbnail of its own yet: the server's copy, through the bridge.
for (const p of pending) {
if (p.state !== "ready" || !p.image || p.thumb) continue
picture(`/attachments/${p.id}`).then((url) => {
if (!url || !url.startsWith("data:image/") || !pending.includes(p)) return
p.thumb = url
render()
})
}
}
/** A pasted picture's own bytes as a `data:` URL: the page's policy allows images from there. */
function dataUrl(file) {
return new Promise((resolve) => {
if (!/^image\/(png|jpeg|webp|gif)$/.test(file.type)) return resolve(undefined)
const reader = new FileReader()
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : undefined)
reader.onerror = () => resolve(undefined)
reader.readAsDataURL(file)
})
}
/** Choose files in the system's dialog. */
async function pick() {
const b = $("attach")
b.disabled = true
try {
await track([], () => invoke("attach_pick", { chat: ctx.chat() || null, title: t("Attach files") }))
} finally {
b.disabled = false
}
}
/** Files pasted into the composer (a screenshot, mostly): sent from their bytes. */
async function paste(e) {
const files = [...(e.clipboardData?.files || [])]
if (!files.length || !ctx.allowed()) return
e.preventDefault()
for (const f of files) {
const name = f.name || "pasted"
track([name], async () => {
const data = new Uint8Array(await f.arrayBuffer())
const attachment = await invoke("attach_bytes", data, { headers: { "x-name": encodeURIComponent(name), "x-chat": ctx.chat() || "" } })
return [{ name, size: f.size, attachment, thumb: await dataUrl(f) }]
})
}
}
export function initAttach(context) {
ctx = context
$("attach").replaceChildren(icon(ICONS.attach))
$("attach").addEventListener("click", pick)
$("composer-text").addEventListener("paste", paste)
const usable = () => !$("composer").hidden && !$("shell").hidden && ctx.allowed()
listen("drag", (e) => {
$("composer").dataset.drag = String(!!e.payload && usable())
})
listen("drop", (e) => {
$("composer").dataset.drag = "false"
if (!usable()) return
const names = Array.isArray(e.payload) ? e.payload : []
track(names, () => invoke("attach_dropped", { chat: ctx.chat() || null }))
})
}
/** Whether this account may attach files at all: the button shows only then. */
export function allowAttach(on) {
$("attach").hidden = !on
$("composer").dataset.attach = String(on)
}