feat(maestro): attach files in structured views

This commit is contained in:
Miguel Palhas
2026-09-07 10:20:08 +01:00
parent 0dc10ffe76
commit 4df8fdb7dc
10 changed files with 363 additions and 9 deletions
+106 -8
View File
@@ -10,6 +10,7 @@ import {
cancelStructuredTurn,
resolveStructuredApproval,
sendStructuredPrompt,
uploadStructuredFile,
setStructuredConfig,
structuredWsUrl,
} from "./api.js";
@@ -90,7 +91,12 @@ export function createStructuredConnection(
<span class="structured-activity-text">Thinking</span>
</div>
<form class="structured-composer">
<button type="button" class="btn btn-ghost btn-icon structured-attach" aria-label="Attach files" title="Attach files (up to 20 MiB each)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="m8 12 7-7a3 3 0 0 1 4 4L9 19a5 5 0 0 1-7-7L12 2M5 14l9-9"/></svg>
</button>
<input class="structured-file-input" type="file" multiple hidden>
<div class="structured-input-wrap">
<div class="structured-attachments" aria-label="Attachments" aria-live="polite" hidden></div>
<div id="structured-command-menu-${sessionId}" class="structured-command-menu"
role="listbox" hidden></div>
<textarea class="structured-input" rows="1" placeholder="Reply, or / for commands…"
@@ -128,6 +134,79 @@ export function createStructuredConnection(
const input = el.querySelector(".structured-input") as HTMLTextAreaElement;
const sendBtn = el.querySelector(".structured-send") as HTMLButtonElement;
const cancelBtn = el.querySelector(".structured-cancel") as HTMLButtonElement;
const attachBtn = el.querySelector(".structured-attach") as HTMLButtonElement;
const fileInput = el.querySelector(".structured-file-input") as HTMLInputElement;
const attachmentEl = el.querySelector(".structured-attachments") as HTMLDivElement;
type Attachment = { file: File; path?: string };
let attachments: Attachment[] = [];
let uploading = false;
let pasteCount = 0;
function renderAttachments(): void {
attachmentEl.replaceChildren();
attachmentEl.hidden = attachments.length === 0;
for (const attachment of attachments) {
const chip = document.createElement("span");
chip.className = "structured-attachment";
const label = document.createElement("span");
label.textContent = attachment.file.name;
label.title = `${attachment.file.name} (${attachment.file.size.toLocaleString()} bytes)`;
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = "×";
remove.setAttribute("aria-label", `Remove ${attachment.file.name}`);
remove.disabled = input.disabled;
remove.addEventListener("click", () => {
attachments = attachments.filter((item) => item !== attachment);
renderAttachments();
});
chip.append(label, remove);
attachmentEl.append(chip);
}
}
function addFiles(files: File[]): void {
if (input.disabled || disposed) return;
for (const file of files) {
if (file.size > 20 * 1024 * 1024) {
showError(`${file.name} exceeds 20 MiB. Choose a smaller file.`);
} else if (attachments.length >= 10) {
showError("Attach up to 10 files per message.");
} else {
attachments.push({ file });
}
}
renderAttachments();
}
attachBtn.addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", () => {
addFiles(Array.from(fileInput.files ?? []));
fileInput.value = "";
});
composerForm.addEventListener("dragover", (event) => {
if (event.dataTransfer?.types.includes("Files")) event.preventDefault();
});
composerForm.addEventListener("drop", (event) => {
if (!event.dataTransfer?.files.length) return;
event.preventDefault();
addFiles(Array.from(event.dataTransfer.files));
});
input.addEventListener("paste", (event) => {
const clipboard = event.clipboardData;
if (!clipboard || input.disabled) return;
if (clipboard.files.length) {
event.preventDefault();
addFiles(Array.from(clipboard.files));
return;
}
const text = clipboard.getData("text/plain");
if (text.length < 4000 && text.split("\n").length < 40) return;
if (attachments.length >= 10 || new Blob([text]).size > 20 * 1024 * 1024) return;
event.preventDefault();
addFiles([new File([text], `pasted-${++pasteCount}.txt`, { type: "text/plain" })]);
input.setRangeText("", input.selectionStart, input.selectionEnd, "end");
autoResize();
});
disconnectBtn.addEventListener("click", () => opts.onRequestClose?.(sessionId));
// Mobile-only (CSS hides it on desktop, where the sidebar is always in
@@ -527,9 +606,12 @@ export function createStructuredConnection(
}
function renderComposerState(): void {
const active = state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const active = uploading || state.turn.responding || state.turn.thinking || awaitingBeginGeneration !== null;
const projectedActive = state.turn.responding || state.turn.thinking;
input.disabled = active;
attachBtn.disabled = active;
fileInput.disabled = active;
renderAttachments();
sendBtn.disabled = active;
sendBtn.hidden = projectedActive;
cancelBtn.hidden = !projectedActive;
@@ -797,7 +879,9 @@ export function createStructuredConnection(
composerForm.addEventListener("submit", (e) => {
e.preventDefault();
const text = input.value;
if (!text.trim() || input.disabled) return;
if ((!text.trim() && attachments.length === 0) || input.disabled) return;
const submittedAttachments = attachments;
uploading = submittedAttachments.length > 0;
const generation = ++submissionGeneration;
const submissionSocketGeneration = socketGeneration;
awaitingBeginGeneration = generation;
@@ -807,13 +891,26 @@ export function createStructuredConnection(
autoResize();
clearError();
renderComposerState();
sendStructuredPrompt(sessionId, text).then(
const send = async () => {
for (const attachment of submittedAttachments) {
if (disposed) return;
if (!attachment.path) attachment.path = (await uploadStructuredFile(sessionId, attachment.file)).path;
}
if (disposed) return;
const references = submittedAttachments.map(({ file, path }) =>
`Attached file ${JSON.stringify(file.name)}: ${JSON.stringify(path)}`);
await sendStructuredPrompt(sessionId, [text, ...references].filter(Boolean).join("\n\n"));
};
void send().then(
() => {
if (
disposed
|| generation !== submissionGeneration
|| awaitingBeginGeneration !== generation
) return;
if (disposed || generation !== submissionGeneration) return;
uploading = false;
if (attachments === submittedAttachments) {
attachments = [];
renderAttachments();
}
renderComposerState();
if (awaitingBeginGeneration !== generation) return;
const acceptanceSocketGeneration = socketGeneration;
acceptedSubmission = { generation, socketGeneration: acceptanceSocketGeneration };
// A replacement socket may have captured idle before the POST claimed
@@ -821,6 +918,7 @@ export function createStructuredConnection(
if (acceptanceSocketGeneration > submissionSocketGeneration) reconnectForReconciliation();
},
(e: unknown) => {
uploading = false;
if (disposed || generation !== submissionGeneration) return;
if (awaitingBeginGeneration === generation) awaitingBeginGeneration = null;
if (input.value === "") {