Drafts survive reload

This commit is contained in:
Ian Gulliver
2026-10-16 08:42:48 -07:00
parent 14cf1f582b
commit cac24e534f
15 changed files with 556 additions and 92 deletions
-2
View File
@@ -4,8 +4,6 @@
# M2 — Seeing the work
- Drafts survive reload
Composer text drafts and inbox answer drafts (checked choices and free text) are mirrored into localStorage, keyed by user plus the existing composer key or inbox item id, with a debounced save and a flush when leaving the composer. A draft is cleared only after a successful send; a failed send keeps it. Drafts for closed tasks and vanished items are pruned. Storage that throws or holds malformed data falls back to the in-memory drafts. Pending images are not restored; the composer says how many need re-attaching instead of dropping them silently.
- Pick project and model when creating a task
The switcher's task creation lets a `proj:` prefix narrow to project rows instead of always using the focused or first project, and the "New task in X" row shows the model that will run.
+3 -1
View File
@@ -133,7 +133,9 @@ Agent activity becomes events with `agent_session_id` set: `user.message`, `agen
Event payloads are stored whole in `events.payload`, and `agent.tool_result` content (full command output, file reads, diffs) dominates the log's size; a task's transcript stream starts from `after=0`, so opening a task sends all of it. `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events GROUP BY kind ORDER BY 3 DESC"` shows the split, and `sqlite3 byre.db "SELECT kind, count(*), sum(length(payload)) FROM events WHERE task_id = '<task-id>' GROUP BY kind ORDER BY 3 DESC"` one task's.
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2px dots, 5px apart along an 84px track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It takes no height (`.scanner` is a zero-height element kept as the transcript's last child, with a negative top margin that cancels the flex gap before it, and its `::before` draws the bar inside the bottom padding), so showing or hiding it never changes the transcript's scroll height or position. It is one element with one CSS animation: the `cylon` keyframes in `web/style.css` slide a gradient's `background-position` along the track, under a static repeating mask that cuts it into dots. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) stream inline with the main agent's, in arrival order, each marked `↳ <subagent_type>` with the description as its tooltip; its messages also get an accent rule down their left edge. A run of tool calls groups only calls from the same agent, so a call from another agent starts a new group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task in memory, so it survives switching tabs or tasks but not a page reload. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
Messages byre itself sends an agent (actor `byre`: the restart notice, Sync and Merge prompts, Compact's handoff request) show collapsed like a tool call, labelled `byre` with the first line as the brief; expanding it shows the full text. The transcript view has a border in the task's colour (see "Task colours"; the accent in the lead pane) while the session is `running` and its transcript has an open turn (`ctx.open`: a drawn turn-starting `user.message`, not mid-turn, with no `turn.ended` after it), and a Cylon-style scanner in the same colour: a row of 2px dots, 5px apart along an 84px track, where a glow sweeping back and forth every 1.2s lights up the dots under it, drawn below the last message, in the transcript's bottom padding, so it scrolls with the transcript and sits just above the composer's top border when scrolled to the bottom. It takes no height (`.scanner` is a zero-height element kept as the transcript's last child, with a negative top margin that cancels the flex gap before it, and its `::before` draws the bar inside the bottom padding), so showing or hiding it never changes the transcript's scroll height or position. It is one element with one CSS animation: the `cylon` keyframes in `web/style.css` slide a gradient's `background-position` along the track, under a static repeating mask that cuts it into dots. Orion freezes animated GIFs and per-dot `step-end` opacity animations partway through a turn, so it stays a single element with a single animation. The session list (`GET /api/me/lead/sessions` or the task's sessions) is fetched separately from the event stream, so it says `running` a little before the turn's `user.message` arrives and a little after its `turn.ended`; requiring the open turn as well keeps the scanner and border from flashing on in those gaps. Consecutive tool calls (with any thinking between them) collapse into one group whose summary counts calls per tool and file, and file paths show relative to the worktree. byre's own tools show without their `mcp__byre__` prefix, in title case (`mcp__byre__create_task` shows as Create Task; `toolLabel` in `web/src/task.ts`); other tools show under the name the agent used. A Claude Code subagent call (the `Agent` or `Task` tool) stands on its own, collapsed, labelled with its `subagent_type` and `description`; expanding it shows the prompt and the call's result. Its summary carries a status from the `agent.vendor` `system` events whose `tool_use_id` names it (`task_started`: running, or running in the background; `task_progress`: its `description`; `task_notification`: its `status`, red unless `completed`). The subagent's own messages, thinking and tool calls (events whose `parent_tool_use_id` names the call) stream inline with the main agent's, in arrival order, each marked `↳ <subagent_type>` with the description as its tooltip; its messages also get an accent rule down their left edge. A run of tool calls groups only calls from the same agent, so a call from another agent starts a new group. A subagent's messages never end the main agent's live message, count as a turn's final message or hide a quiet turn. Claude Code streams a background subagent's activity, but for a foreground one only its prompt, so a foreground subagent shows only as its call, its status and its result. Selecting a task that has never had a session focuses the composer. In the composer, Enter sends and Shift-Enter adds a newline; unsent text is kept per task (see "Drafts" below), so it survives switching tabs or tasks and reloading the page. The composer has no `@`-file mentions or path picker: text reaches the agent exactly as typed (stream-json doesn't expand `@path` the way the interactive CLI does), and the agent reads any file the message names with its own tools. The composer is the same whether or not the task has a live session, and stays enabled while a turn runs: Send posts to the session's messages, or starts a session with the text when there is none or the last one completed. A sent message shows at once as a bubble reading "queued…" while the session is running and "sending…" otherwise, replaced by the server's `user.message` event with the same text when it arrives, and removed (text and images back in the composer) if the request fails.
**Drafts.** Unsent composer text (task and lead) and unsent inbox answers (checked choices and Other/Reply text) are kept in memory by `web/src/drafts.ts` and mirrored to the browser's localStorage under `byre.drafts.<login>`, one JSON object with `composer` (keyed by `message:<task id>` or `message:lead`, each `{text, images}`), `checked` and `texts` (both keyed by inbox item id). Writes are debounced and flushed at once when a composer or answer box loses focus, when the transcript view is disposed, and on `pagehide`. A composer draft is cleared only once its send succeeds, and an inbox answer only once its response is accepted; each send in flight is tracked on its own and the stored draft holds the text of every unconfirmed send (oldest first, then the composer's current text, separated by blank lines) and counts its images, so a reload mid-send brings back everything not yet confirmed. A failed send's text goes back into the composer ahead of whatever is there now (text typed since, or earlier failures), separated by a blank line, rather than replacing it. Drafts for tasks that are no longer active and for inbox items no longer open are pruned whenever the task list or inbox reloads. A storage read that throws or holds malformed JSON (or entries of the wrong shape) is ignored, and a write that throws is logged; drafts then live only in memory. Pending images are not stored, only their count: after a reload the composer shows how many need re-attaching, until dismissed or the next successful send.
The task switcher (⌘K, or the project ▸ task picker centred in the right pane's top row) lists active tasks with their session state and open inbox item count (the Project tab's task list shows the same state), and **merge held** on a task with `merge_queued_at`, live while it is open. Tasks with inbox items come first; within each group, the task least recently visited in this browser comes first (never-visited tasks before all others, ties in the server's order: projects by name, tasks newest first). Visit times are kept in the browser's localStorage under `byre.visits`, stamped when a task is selected and again when it is left. After the tasks come the projects, one row each; picking one points the right pane at the project with no task, on the Project tab. Typing filters both and adds a last row that creates a task with the typed text as its title, in the right pane's project (the first project when there is none); Enter picks the highlighted row. Picking a task points the right pane at it on the Inbox tab when it has open inbox items, otherwise on the Transcript tab (see "Lead agent").
+2
View File
@@ -1,5 +1,6 @@
import { get, put, subscribe } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { loadDrafts } from "./drafts.js";
import { leadPane, taskPane } from "./panes.js";
import { handleEvent, loadFocus, loadInbox, loadLeadSessions, loadMe, loadProjects, notify, on, selectTask, setFocus, startLeadStream, state, tabs } from "./state.js";
import { bindShortcuts } from "./switcher.js";
@@ -51,6 +52,7 @@ async function main() {
const banner = h("div", { class: "banner", hidden: true });
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox(), loadLeadSessions()]);
loadDrafts();
const linked = location.hash;
await loadFocus();
if (/^#\/(task|project)\/\w+(\/\w+)?$/.test(linked)) {
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
+187
View File
@@ -0,0 +1,187 @@
import { findTask, onEach, state } from "./state.js";
const composerTexts = new Map();
const composerImages = new Map();
const sending = new Set();
const lostImages = new Map();
const checked = new Map();
const texts = new Map();
let storeKey;
let timer = 0;
export function loadDrafts() {
storeKey = `byre.drafts.${state.me.login}`;
const saved = readSaved(storeKey);
for (const [key, d] of Object.entries(saved.composer)) {
if (d.text) {
composerTexts.set(key, d.text);
}
if (d.images > 0) {
lostImages.set(key, d.images);
}
}
for (const [id, opts] of Object.entries(saved.checked)) {
checked.set(id, opts);
}
for (const [id, text] of Object.entries(saved.texts)) {
texts.set(id, text);
}
prune();
onEach(["tasks", "inbox"], prune);
addEventListener("pagehide", flushDrafts);
}
function readSaved(key) {
const out = { composer: {}, checked: {}, texts: {} };
let raw;
try {
raw = JSON.parse(localStorage.getItem(key) ?? "{}");
}
catch (err) {
console.error("read drafts", err);
return out;
}
if (!isRecord(raw)) {
return out;
}
for (const [k, v] of Object.entries(isRecord(raw["composer"]) ? raw["composer"] : {})) {
if (isRecord(v) && typeof v["text"] === "string" && typeof v["images"] === "number") {
out.composer[k] = { text: v["text"], images: v["images"] };
}
}
for (const [k, v] of Object.entries(isRecord(raw["checked"]) ? raw["checked"] : {})) {
if (Array.isArray(v) && v.every((o) => typeof o === "string")) {
out.checked[k] = v;
}
}
for (const [k, v] of Object.entries(isRecord(raw["texts"]) ? raw["texts"] : {})) {
if (typeof v === "string") {
out.texts[k] = v;
}
}
return out;
}
function isRecord(v) {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function prune() {
let changed = false;
for (const map of [composerTexts, composerImages, lostImages]) {
for (const key of map.keys()) {
const taskId = key.replace(/^message:/, "");
if (taskId !== "lead" && findTask(taskId)?.status !== "active") {
map.delete(key);
changed = true;
}
}
}
for (const map of [checked, texts]) {
for (const id of map.keys()) {
if (!state.inbox.some((i) => i.id === id)) {
map.delete(id);
changed = true;
}
}
}
if (changed) {
save();
}
}
function save() {
clearTimeout(timer);
timer = setTimeout(flushDrafts, 400);
}
export function flushDrafts() {
clearTimeout(timer);
timer = 0;
if (!storeKey) {
return;
}
const out = { composer: {}, checked: Object.fromEntries(checked), texts: Object.fromEntries(texts) };
const keys = new Set([...composerTexts.keys(), ...composerImages.keys(), ...[...sending].map((s) => s.key), ...lostImages.keys()]);
for (const key of keys) {
const inFlight = [...sending].filter((s) => s.key === key);
const text = [...inFlight.map((s) => s.text), composerTexts.get(key) ?? ""].filter((t) => t).join("\n\n");
const images = (composerImages.get(key) ?? 0) + inFlight.reduce((n, s) => n + s.images, 0) + (lostImages.get(key) ?? 0);
if (text || images > 0) {
out.composer[key] = { text, images };
}
}
try {
localStorage.setItem(storeKey, JSON.stringify(out));
}
catch (err) {
console.error("save drafts", err);
}
}
export function composerText(key) {
return composerTexts.get(key) ?? "";
}
export function setComposerText(key, text) {
if (text) {
composerTexts.set(key, text);
}
else {
composerTexts.delete(key);
}
save();
}
export function setComposerImages(key, n) {
if (n > 0) {
composerImages.set(key, n);
}
else {
composerImages.delete(key);
}
save();
}
export function lostImageCount(key) {
return lostImages.get(key) ?? 0;
}
export function forgetLostImages(key) {
lostImages.delete(key);
save();
}
export function startSend(key, text, images) {
const send = { key, text, images };
sending.add(send);
composerTexts.delete(key);
return send;
}
export function endSend(send, ok) {
sending.delete(send);
if (ok) {
lostImages.delete(send.key);
}
else {
const text = [send.text, composerTexts.get(send.key) ?? ""].filter((t) => t).join("\n\n");
if (text) {
composerTexts.set(send.key, text);
}
}
flushDrafts();
}
export function checkedAnswer(id) {
return checked.get(id) ?? [];
}
export function setCheckedAnswer(id, options) {
checked.set(id, options);
save();
}
export function hasTextAnswer(id) {
return texts.has(id);
}
export function textAnswer(id) {
return texts.get(id);
}
export function setTextAnswer(id, text) {
texts.set(id, text);
save();
}
export function dropTextAnswer(id) {
texts.delete(id);
save();
}
export function clearAnswer(id) {
checked.delete(id);
texts.delete(id);
flushDrafts();
}
//# sourceMappingURL=drafts.js.map
+1
View File
File diff suppressed because one or more lines are too long
+16 -24
View File
@@ -2,12 +2,11 @@ import { post } from "./api.js";
import { mountChanges } from "./changes.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { image, inlineMarkdown, markdown } from "./markdown.js";
import { checkedAnswer, clearAnswer, dropTextAnswer, flushDrafts, hasTextAnswer, setCheckedAnswer, setTextAnswer, textAnswer } from "./drafts.js";
import { diagram } from "./mermaid.js";
import { reviewCard } from "./reviews.js";
import { focusTab, onEach, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
const checked = new Map();
const texts = new Map();
export function mountInbox(root) {
const list = h("div", { class: "inbox-list" });
const cards = new Map();
@@ -23,13 +22,6 @@ export function mountInbox(root) {
cards.delete(id);
}
}
for (const draft of [checked, texts]) {
for (const id of draft.keys()) {
if (!state.inbox.some((i) => i.id === id)) {
draft.delete(id);
}
}
}
const cardFor = (item) => {
const sig = JSON.stringify(item);
const cached = cards.get(item.id);
@@ -105,8 +97,7 @@ function card(item) {
lock(true);
try {
await post("/api/inbox/responses", { body: { responses: [response] } });
checked.delete(item.id);
texts.delete(item.id);
clearAnswer(item.id);
const others = state.inbox.some((i) => i.task_id === item.task_id && i.id !== item.id);
if (!others && state.focus.tab === "inbox" && state.selectedTaskId === item.task_id) {
focusTab("transcript");
@@ -136,24 +127,24 @@ function card(item) {
void submit({ item_id: item.id, type: "option", options: [text] });
});
}
const input = h("input", { type: "checkbox", checked: (checked.get(item.id) ?? []).includes(text) });
const input = h("input", { type: "checkbox", checked: checkedAnswer(item.id).includes(text) });
input.onchange = () => {
const now = (checked.get(item.id) ?? []).filter((o) => o !== text);
const now = checkedAnswer(item.id).filter((o) => o !== text);
if (input.checked) {
now.push(text);
}
checked.set(item.id, options.filter((o) => now.includes(o)));
setCheckedAnswer(item.id, options.filter((o) => now.includes(o)));
renderActions();
};
return h("label", { class: rec ? "option choice-box rec" : "option choice-box" }, h("div", { class: "choice-head" }, input, name, tag));
};
const textEntry = (label, placeholder, shared) => {
const open = texts.has(item.id);
const text = h("textarea", { rows: 3, placeholder, value: texts.get(item.id) ?? "" });
const open = hasTextAnswer(item.id);
const text = h("textarea", { rows: 3, placeholder, value: textAnswer(item.id) ?? "" });
const send = h("button", { type: "button", class: "primary", disabled: !text.value.trim() }, "Submit");
const editor = h("div", { class: "text-editor", hidden: !open }, text, !shared && h("div", { class: "submit-row" }, send));
const block = clickable(h("div", { class: "pick other", role: "button", tabIndex: 0, hidden: open }, label), () => {
texts.set(item.id, "");
setTextAnswer(item.id, "");
block.hidden = true;
editor.hidden = false;
text.focus();
@@ -169,17 +160,18 @@ function card(item) {
};
send.onclick = go;
text.oninput = () => {
texts.set(item.id, text.value);
setTextAnswer(item.id, text.value);
send.disabled = !text.value.trim();
shared?.onInput();
};
text.onblur = () => {
if (!text.value.trim() && !busy()) {
texts.delete(item.id);
dropTextAnswer(item.id);
editor.hidden = true;
block.hidden = false;
shared?.onInput();
}
flushDrafts();
};
text.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
@@ -198,18 +190,18 @@ function card(item) {
return;
}
const send = h("button", { type: "button", class: "primary" }, "Submit");
const typed = () => (texts.get(item.id) ?? "").trim();
const typed = () => (textAnswer(item.id) ?? "").trim();
const refresh = () => {
send.disabled = !typed() && (checked.get(item.id) ?? []).length === 0;
send.disabled = !typed() && checkedAnswer(item.id).length === 0;
};
const go = () => {
const options = checked.get(item.id) ?? [];
const options = checkedAnswer(item.id);
if (options.length > 0) {
void submit({ item_id: item.id, type: "option", options, text: typed() ? texts.get(item.id) : undefined });
void submit({ item_id: item.id, type: "option", options, text: typed() ? textAnswer(item.id) : undefined });
return;
}
if (typed()) {
void submit({ item_id: item.id, type: "other", text: texts.get(item.id) });
void submit({ item_id: item.id, type: "other", text: textAnswer(item.id) });
}
};
const other = textEntry("Other…", placeholder, { onInput: refresh, onSubmit: go });
+1 -1
View File
File diff suppressed because one or more lines are too long
+47 -19
View File
@@ -3,6 +3,7 @@ import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
import { fillTodoLinks } from "./todolink.js";
@@ -213,6 +214,10 @@ export function mountChat(root, src, makeHead) {
let focusStart = false;
const renderComposer = () => {
const key = src.key();
const old = composer.querySelector("textarea");
if (old) {
releaseInput(old);
}
if (!key) {
replace(composer);
return;
@@ -221,7 +226,6 @@ export function mountChat(root, src, makeHead) {
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, live?.vendor, showPending);
const old = composer.querySelector("textarea");
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
const text = form.querySelector("textarea");
@@ -473,20 +477,39 @@ export function mountChat(root, src, makeHead) {
resizes.disconnect();
mutations.disconnect();
dropStream();
const input = composer.querySelector("textarea");
if (input) {
releaseInput(input);
}
flushDrafts();
};
}
const composerDrafts = new Map();
const composerInputs = new Map();
function releaseInput(input) {
for (const [key, el] of composerInputs) {
if (el === input) {
composerInputs.delete(key);
}
}
}
function keepDraft(input, key) {
input.value = composerDrafts.get(key) ?? "";
input.addEventListener("input", () => {
if (input.value) {
composerDrafts.set(key, input.value);
}
else {
composerDrafts.delete(key);
}
});
return () => composerDrafts.delete(key);
composerInputs.set(key, input);
input.value = composerText(key);
input.addEventListener("input", () => setComposerText(key, input.value));
input.addEventListener("blur", flushDrafts);
}
function lostImagesNote(key) {
const n = lostImageCount(key);
if (n === 0) {
return null;
}
const dismiss = h("button", { type: "button", class: "remove", title: "Dismiss" }, "×");
const note = h("div", { class: "compose-note" }, `${n === 1 ? "1 image" : `${n} images`} from before the reload need re-attaching.`, dismiss);
dismiss.onclick = () => {
forgetLostImages(key);
note.remove();
};
return note;
}
const imageDrafts = new Map();
const imageTypes = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -495,6 +518,7 @@ function imageTray(form, input, key) {
const el = h("div", { class: "compose-images" });
const render = () => {
const imgs = imageDrafts.get(key) ?? [];
setComposerImages(key, imgs.length);
el.hidden = imgs.length === 0;
replace(el, ...imgs.map((img) => {
const remove = h("button", { type: "button", class: "remove", title: "Remove image" }, "×");
@@ -607,7 +631,8 @@ function modelSelect(key, current, vendor) {
}
function composeForm(src, key, sessionId, sessionState, currentModel, vendor, showPending) {
const text = h("textarea", { placeholder: src.placeholder, rows: 3 });
const clearDraft = keepDraft(text, `message:${key}`);
const draftKey = `message:${key}`;
keepDraft(text, draftKey);
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "Send");
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" }, "Interrupt");
interrupt.onclick = async () => {
@@ -624,27 +649,30 @@ function composeForm(src, key, sessionId, sessionState, currentModel, vendor, sh
const modelKey = `message:${sessionId ?? key}`;
const model = modelSelect(modelKey, currentModel, vendor);
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, `message:${key}`);
form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
const tray = imageTray(form, text, draftKey);
form.append(lostImagesNote(draftKey) ?? "", tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
const value = text.value;
if (!value.trim() && !imageDrafts.has(`message:${key}`)) {
if (!value.trim() && !imageDrafts.has(draftKey)) {
return;
}
const imgs = tray.take();
text.value = "";
clearDraft();
const sent = startSend(draftKey, value, imgs.length);
const cancel = showPending(value, imgs);
try {
const images = await uploadImages(imgs);
await src.send({ key, sessionId, text: value, images, model: model.value !== currentModel ? model.value : undefined, firstModel: model.value });
modelChoices.delete(modelKey);
endSend(sent, true);
form.querySelector(".compose-note")?.remove();
}
catch (err) {
cancel();
composerDrafts.set(`message:${key}`, value);
text.value = value;
endSend(sent, false);
const live = composerInputs.get(draftKey) ?? text;
live.value = composerText(draftKey);
tray.restore(imgs);
void showAlert(errorText(err));
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -1,5 +1,6 @@
import { get, put, subscribe } from "./api.js";
import { errorText, h, replace, showAlert } from "./dom.js";
import { loadDrafts } from "./drafts.js";
import { leadPane, taskPane } from "./panes.js";
import { handleEvent, loadFocus, loadInbox, loadLeadSessions, loadMe, loadProjects, notify, on, selectTask, setFocus, startLeadStream, state, tabs } from "./state.js";
import { bindShortcuts } from "./switcher.js";
@@ -53,6 +54,7 @@ async function main(): Promise<void> {
const status = await get("/api/admin/status");
await Promise.all([loadMe(), loadProjects(), loadInbox(), loadLeadSessions()]);
loadDrafts();
const linked = location.hash;
await loadFocus();
if (/^#\/(task|project)\/\w+(\/\w+)?$/.test(linked)) {
+211
View File
@@ -0,0 +1,211 @@
import { findTask, onEach, state } from "./state.js";
type Saved = {
composer: Record<string, { text: string; images: number }>;
checked: Record<string, string[]>;
texts: Record<string, string>;
};
const composerTexts = new Map<string, string>();
const composerImages = new Map<string, number>();
type Send = { key: string; text: string; images: number };
const sending = new Set<Send>();
const lostImages = new Map<string, number>();
const checked = new Map<string, string[]>();
const texts = new Map<string, string>();
let storeKey: string | undefined;
let timer = 0;
export function loadDrafts(): void {
storeKey = `byre.drafts.${state.me!.login}`;
const saved = readSaved(storeKey);
for (const [key, d] of Object.entries(saved.composer)) {
if (d.text) {
composerTexts.set(key, d.text);
}
if (d.images > 0) {
lostImages.set(key, d.images);
}
}
for (const [id, opts] of Object.entries(saved.checked)) {
checked.set(id, opts);
}
for (const [id, text] of Object.entries(saved.texts)) {
texts.set(id, text);
}
prune();
onEach(["tasks", "inbox"], prune);
addEventListener("pagehide", flushDrafts);
}
function readSaved(key: string): Saved {
const out: Saved = { composer: {}, checked: {}, texts: {} };
let raw: unknown;
try {
raw = JSON.parse(localStorage.getItem(key) ?? "{}");
} catch (err) {
console.error("read drafts", err);
return out;
}
if (!isRecord(raw)) {
return out;
}
for (const [k, v] of Object.entries(isRecord(raw["composer"]) ? raw["composer"] : {})) {
if (isRecord(v) && typeof v["text"] === "string" && typeof v["images"] === "number") {
out.composer[k] = { text: v["text"], images: v["images"] };
}
}
for (const [k, v] of Object.entries(isRecord(raw["checked"]) ? raw["checked"] : {})) {
if (Array.isArray(v) && v.every((o) => typeof o === "string")) {
out.checked[k] = v as string[];
}
}
for (const [k, v] of Object.entries(isRecord(raw["texts"]) ? raw["texts"] : {})) {
if (typeof v === "string") {
out.texts[k] = v;
}
}
return out;
}
function isRecord(v: unknown): v is Record<string, unknown> {
return typeof v === "object" && v !== null && !Array.isArray(v);
}
function prune(): void {
let changed = false;
for (const map of [composerTexts, composerImages, lostImages]) {
for (const key of map.keys()) {
const taskId = key.replace(/^message:/, "");
if (taskId !== "lead" && findTask(taskId)?.status !== "active") {
map.delete(key);
changed = true;
}
}
}
for (const map of [checked, texts]) {
for (const id of map.keys()) {
if (!state.inbox.some((i) => i.id === id)) {
map.delete(id);
changed = true;
}
}
}
if (changed) {
save();
}
}
function save(): void {
clearTimeout(timer);
timer = setTimeout(flushDrafts, 400);
}
export function flushDrafts(): void {
clearTimeout(timer);
timer = 0;
if (!storeKey) {
return;
}
const out: Saved = { composer: {}, checked: Object.fromEntries(checked), texts: Object.fromEntries(texts) };
const keys = new Set([...composerTexts.keys(), ...composerImages.keys(), ...[...sending].map((s) => s.key), ...lostImages.keys()]);
for (const key of keys) {
const inFlight = [...sending].filter((s) => s.key === key);
const text = [...inFlight.map((s) => s.text), composerTexts.get(key) ?? ""].filter((t) => t).join("\n\n");
const images = (composerImages.get(key) ?? 0) + inFlight.reduce((n, s) => n + s.images, 0) + (lostImages.get(key) ?? 0);
if (text || images > 0) {
out.composer[key] = { text, images };
}
}
try {
localStorage.setItem(storeKey, JSON.stringify(out));
} catch (err) {
console.error("save drafts", err);
}
}
export function composerText(key: string): string {
return composerTexts.get(key) ?? "";
}
export function setComposerText(key: string, text: string): void {
if (text) {
composerTexts.set(key, text);
} else {
composerTexts.delete(key);
}
save();
}
export function setComposerImages(key: string, n: number): void {
if (n > 0) {
composerImages.set(key, n);
} else {
composerImages.delete(key);
}
save();
}
export function lostImageCount(key: string): number {
return lostImages.get(key) ?? 0;
}
export function forgetLostImages(key: string): void {
lostImages.delete(key);
save();
}
export function startSend(key: string, text: string, images: number): Send {
const send = { key, text, images };
sending.add(send);
composerTexts.delete(key);
return send;
}
export function endSend(send: Send, ok: boolean): void {
sending.delete(send);
if (ok) {
lostImages.delete(send.key);
} else {
const text = [send.text, composerTexts.get(send.key) ?? ""].filter((t) => t).join("\n\n");
if (text) {
composerTexts.set(send.key, text);
}
}
flushDrafts();
}
export function checkedAnswer(id: string): string[] {
return checked.get(id) ?? [];
}
export function setCheckedAnswer(id: string, options: string[]): void {
checked.set(id, options);
save();
}
export function hasTextAnswer(id: string): boolean {
return texts.has(id);
}
export function textAnswer(id: string): string | undefined {
return texts.get(id);
}
export function setTextAnswer(id: string, text: string): void {
texts.set(id, text);
save();
}
export function dropTextAnswer(id: string): void {
texts.delete(id);
save();
}
export function clearAnswer(id: string): void {
checked.delete(id);
texts.delete(id);
flushDrafts();
}
+16 -25
View File
@@ -2,14 +2,12 @@ import { post, type Attachment, type InboxItem, type InboxResponseCreate } from
import { mountChanges } from "./changes.js";
import { ago, errorText, h, replace, showAlert } from "./dom.js";
import { image, inlineMarkdown, markdown } from "./markdown.js";
import { checkedAnswer, clearAnswer, dropTextAnswer, flushDrafts, hasTextAnswer, setCheckedAnswer, setTextAnswer, textAnswer } from "./drafts.js";
import { diagram } from "./mermaid.js";
import { reviewCard } from "./reviews.js";
import { focusTab, onEach, state } from "./state.js";
import { taskStyle } from "./taskcolor.js";
const checked = new Map<string, string[]>();
const texts = new Map<string, string>();
export function mountInbox(root: HTMLElement): () => void {
const list = h("div", { class: "inbox-list" });
const cards = new Map<string, { sig: string; el: HTMLElement }>();
@@ -26,13 +24,6 @@ export function mountInbox(root: HTMLElement): () => void {
cards.delete(id);
}
}
for (const draft of [checked, texts]) {
for (const id of draft.keys()) {
if (!state.inbox.some((i) => i.id === id)) {
draft.delete(id);
}
}
}
const cardFor = (item: InboxItem) => {
const sig = JSON.stringify(item);
@@ -128,8 +119,7 @@ function card(item: InboxItem): HTMLElement {
lock(true);
try {
await post("/api/inbox/responses", { body: { responses: [response] } });
checked.delete(item.id);
texts.delete(item.id);
clearAnswer(item.id);
const others = state.inbox.some((i) => i.task_id === item.task_id && i.id !== item.id);
if (!others && state.focus.tab === "inbox" && state.selectedTaskId === item.task_id) {
focusTab("transcript");
@@ -159,13 +149,13 @@ function card(item: InboxItem): HTMLElement {
void submit({ item_id: item.id, type: "option", options: [text] });
});
}
const input = h("input", { type: "checkbox", checked: (checked.get(item.id) ?? []).includes(text) });
const input = h("input", { type: "checkbox", checked: checkedAnswer(item.id).includes(text) });
input.onchange = () => {
const now = (checked.get(item.id) ?? []).filter((o) => o !== text);
const now = checkedAnswer(item.id).filter((o) => o !== text);
if (input.checked) {
now.push(text);
}
checked.set(
setCheckedAnswer(
item.id,
options.filter((o) => now.includes(o)),
);
@@ -175,12 +165,12 @@ function card(item: InboxItem): HTMLElement {
};
const textEntry = (label: string, placeholder: string, shared?: { onInput: () => void; onSubmit: () => void }) => {
const open = texts.has(item.id);
const text = h("textarea", { rows: 3, placeholder, value: texts.get(item.id) ?? "" });
const open = hasTextAnswer(item.id);
const text = h("textarea", { rows: 3, placeholder, value: textAnswer(item.id) ?? "" });
const send = h("button", { type: "button", class: "primary", disabled: !text.value.trim() }, "Submit");
const editor = h("div", { class: "text-editor", hidden: !open }, text, !shared && h("div", { class: "submit-row" }, send));
const block = clickable(h("div", { class: "pick other", role: "button", tabIndex: 0, hidden: open }, label), () => {
texts.set(item.id, "");
setTextAnswer(item.id, "");
block.hidden = true;
editor.hidden = false;
text.focus();
@@ -196,17 +186,18 @@ function card(item: InboxItem): HTMLElement {
};
send.onclick = go;
text.oninput = () => {
texts.set(item.id, text.value);
setTextAnswer(item.id, text.value);
send.disabled = !text.value.trim();
shared?.onInput();
};
text.onblur = () => {
if (!text.value.trim() && !busy()) {
texts.delete(item.id);
dropTextAnswer(item.id);
editor.hidden = true;
block.hidden = false;
shared?.onInput();
}
flushDrafts();
};
text.onkeydown = (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
@@ -226,18 +217,18 @@ function card(item: InboxItem): HTMLElement {
return;
}
const send = h("button", { type: "button", class: "primary" }, "Submit");
const typed = () => (texts.get(item.id) ?? "").trim();
const typed = () => (textAnswer(item.id) ?? "").trim();
const refresh = () => {
send.disabled = !typed() && (checked.get(item.id) ?? []).length === 0;
send.disabled = !typed() && checkedAnswer(item.id).length === 0;
};
const go = () => {
const options = checked.get(item.id) ?? [];
const options = checkedAnswer(item.id);
if (options.length > 0) {
void submit({ item_id: item.id, type: "option", options, text: typed() ? texts.get(item.id) : undefined });
void submit({ item_id: item.id, type: "option", options, text: typed() ? textAnswer(item.id) : undefined });
return;
}
if (typed()) {
void submit({ item_id: item.id, type: "other", text: texts.get(item.id)! });
void submit({ item_id: item.id, type: "other", text: textAnswer(item.id)! });
}
};
const other = textEntry("Other…", placeholder, { onInput: refresh, onSubmit: go });
+49 -18
View File
@@ -3,6 +3,7 @@ import { ago, copyable, errorText, h, replace, showAlert, showConfirm } from "./
import { markdown } from "./markdown.js";
import { previewURL } from "./preview.js";
import { findingCount, reviewCounts } from "./reviews.js";
import { composerText, endSend, flushDrafts, forgetLostImages, lostImageCount, setComposerImages, setComposerText, startSend } from "./drafts.js";
import { currentSession, findTask, focusTab, focusTask, onEach, pinSnapshot, setFocus, showReview, state, type Topic } from "./state.js";
import { taskStyle } from "./taskcolor.js";
import { fillTodoLinks } from "./todolink.js";
@@ -284,6 +285,10 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
let focusStart = false;
const renderComposer = () => {
const key = src.key();
const old = composer.querySelector("textarea");
if (old) {
releaseInput(old);
}
if (!key) {
replace(composer);
return;
@@ -292,7 +297,6 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
const live = sess && sess.state !== "completed" ? sess : undefined;
composerModel = live ? live.model || context.get(live.id)?.model || "" : "";
const form = composeForm(src, key, live?.id, live?.state ?? "idle", composerModel, live?.vendor, showPending);
const old = composer.querySelector("textarea");
const caret = old !== null && document.activeElement === old ? [old.selectionStart, old.selectionEnd] : undefined;
replace(composer, form);
const text = form.querySelector("textarea")!;
@@ -557,21 +561,43 @@ export function mountChat(root: HTMLElement, src: ChatSource, makeHead: (rerende
resizes.disconnect();
mutations.disconnect();
dropStream();
const input = composer.querySelector("textarea");
if (input) {
releaseInput(input);
}
flushDrafts();
};
}
const composerDrafts = new Map<string, string>();
const composerInputs = new Map<string, HTMLTextAreaElement>();
function keepDraft(input: HTMLTextAreaElement, key: string): () => void {
input.value = composerDrafts.get(key) ?? "";
input.addEventListener("input", () => {
if (input.value) {
composerDrafts.set(key, input.value);
} else {
composerDrafts.delete(key);
function releaseInput(input: HTMLTextAreaElement): void {
for (const [key, el] of composerInputs) {
if (el === input) {
composerInputs.delete(key);
}
});
return () => composerDrafts.delete(key);
}
}
function keepDraft(input: HTMLTextAreaElement, key: string): void {
composerInputs.set(key, input);
input.value = composerText(key);
input.addEventListener("input", () => setComposerText(key, input.value));
input.addEventListener("blur", flushDrafts);
}
function lostImagesNote(key: string): HTMLElement | null {
const n = lostImageCount(key);
if (n === 0) {
return null;
}
const dismiss = h("button", { type: "button", class: "remove", title: "Dismiss" }, "×");
const note = h("div", { class: "compose-note" }, `${n === 1 ? "1 image" : `${n} images`} from before the reload need re-attaching.`, dismiss);
dismiss.onclick = () => {
forgetLostImages(key);
note.remove();
};
return note;
}
type PendingImage = { file: Blob; url: string };
@@ -585,6 +611,7 @@ function imageTray(form: HTMLFormElement, input: HTMLTextAreaElement, key: strin
const el = h("div", { class: "compose-images" });
const render = () => {
const imgs = imageDrafts.get(key) ?? [];
setComposerImages(key, imgs.length);
el.hidden = imgs.length === 0;
replace(
el,
@@ -715,7 +742,8 @@ function composeForm(
showPending: (text: string, imgs: PendingImage[]) => () => void,
): HTMLElement {
const text = h("textarea", { placeholder: src.placeholder, rows: 3 });
const clearDraft = keepDraft(text, `message:${key}`);
const draftKey = `message:${key}`;
keepDraft(text, draftKey);
const send = h("button", { type: "submit", disabled: !src.canSend(sessionState) }, "Send");
const interrupt = h("button", { type: "button", class: "danger", disabled: sessionState !== "running" }, "Interrupt");
interrupt.onclick = async () => {
@@ -732,26 +760,29 @@ function composeForm(
const modelKey = `message:${sessionId ?? key}`;
const model = modelSelect(modelKey, currentModel, vendor);
const form = h("form", { class: "compose" });
const tray = imageTray(form, text, `message:${key}`);
form.append(tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
const tray = imageTray(form, text, draftKey);
form.append(lostImagesNote(draftKey) ?? "", tray.el, text, h("div", { class: "compose-actions" }, model, interrupt, send));
form.onsubmit = async (e) => {
e.preventDefault();
const value = text.value;
if (!value.trim() && !imageDrafts.has(`message:${key}`)) {
if (!value.trim() && !imageDrafts.has(draftKey)) {
return;
}
const imgs = tray.take();
text.value = "";
clearDraft();
const sent = startSend(draftKey, value, imgs.length);
const cancel = showPending(value, imgs);
try {
const images = await uploadImages(imgs);
await src.send({ key, sessionId, text: value, images, model: model.value !== currentModel ? model.value : undefined, firstModel: model.value });
modelChoices.delete(modelKey);
endSend(sent, true);
form.querySelector(".compose-note")?.remove();
} catch (err) {
cancel();
composerDrafts.set(`message:${key}`, value);
text.value = value;
endSend(sent, false);
const live = composerInputs.get(draftKey) ?? text;
live.value = composerText(draftKey);
tray.restore(imgs);
void showAlert(errorText(err));
}
+19
View File
@@ -1159,6 +1159,25 @@ details.note .md {
margin-bottom: 8px;
}
.compose-note {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 6px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--warn-tint);
color: var(--text);
font-size: 12px;
}
.compose-note .remove {
margin-left: auto;
padding: 0 6px;
line-height: 18px;
}
.compose-images .thumb {
position: relative;
}