feat: upload client-local files from Web and TUI
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
acceptedAttachmentMediaType,
|
||||
MAX_UPLOADED_FILE_BYTES,
|
||||
validateAttachmentFile,
|
||||
} from "./composer-attachments.ts";
|
||||
|
||||
declare const Deno: { test(name: string, fn: () => void): void };
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("attachment validation accepts bounded text and image files", () => {
|
||||
assert(acceptedAttachmentMediaType("text/plain"), "text must be accepted");
|
||||
assert(acceptedAttachmentMediaType("image/png"), "png must be accepted");
|
||||
assert(
|
||||
!acceptedAttachmentMediaType("application/x-executable"),
|
||||
"executables must be rejected",
|
||||
);
|
||||
const valid = { name: "notes.md", type: "text/markdown", size: 32 } as File;
|
||||
assert(validateAttachmentFile(valid) === null, "bounded text should pass");
|
||||
});
|
||||
|
||||
Deno.test("attachment validation rejects over-limit files", () => {
|
||||
const tooLarge = {
|
||||
name: "large.txt",
|
||||
type: "text/plain",
|
||||
size: MAX_UPLOADED_FILE_BYTES + 1,
|
||||
} as File;
|
||||
assert(validateAttachmentFile(tooLarge)?.includes("10 MiB"), "limit should be explicit");
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { UploadedFileRef } from "$lib/generated/protocol.ts";
|
||||
|
||||
export const MAX_UPLOADED_FILE_BYTES = 10 * 1024 * 1024;
|
||||
export const MAX_FILES_PER_SUBMISSION = 8;
|
||||
|
||||
export type AttachmentUploadState = "uploading" | "uploaded" | "failed";
|
||||
|
||||
export type ComposerAttachment = {
|
||||
id: number;
|
||||
file: File;
|
||||
uploadPath: string;
|
||||
state: AttachmentUploadState;
|
||||
progress: number;
|
||||
reference: UploadedFileRef | null;
|
||||
error: string | null;
|
||||
request: XMLHttpRequest | null;
|
||||
};
|
||||
|
||||
export function acceptedAttachmentMediaType(mediaType: string): boolean {
|
||||
return mediaType.startsWith("text/") ||
|
||||
mediaType === "application/json" ||
|
||||
mediaType === "application/pdf" ||
|
||||
mediaType === "image/png" ||
|
||||
mediaType === "image/jpeg" ||
|
||||
mediaType === "image/gif" ||
|
||||
mediaType === "image/webp";
|
||||
}
|
||||
|
||||
export function validateAttachmentFile(file: File): string | null {
|
||||
if (file.size > MAX_UPLOADED_FILE_BYTES) {
|
||||
return `File exceeds the ${MAX_UPLOADED_FILE_BYTES / 1024 / 1024} MiB limit.`;
|
||||
}
|
||||
if (!acceptedAttachmentMediaType(file.type)) {
|
||||
return `Unsupported file type: ${file.type || "unknown"}.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type AttachmentUploadCallbacks = {
|
||||
progress(value: number): void;
|
||||
complete(reference: UploadedFileRef): void;
|
||||
failed(message: string): void;
|
||||
};
|
||||
|
||||
export function uploadAttachment(
|
||||
path: string,
|
||||
file: File,
|
||||
callbacks: AttachmentUploadCallbacks,
|
||||
): XMLHttpRequest {
|
||||
const request = new XMLHttpRequest();
|
||||
const query = new URLSearchParams({
|
||||
file_name: file.name,
|
||||
media_type: file.type,
|
||||
});
|
||||
request.open("POST", `${path}?${query.toString()}`);
|
||||
request.setRequestHeader("content-type", "application/octet-stream");
|
||||
request.upload.addEventListener("progress", (event) => {
|
||||
if (event.lengthComputable && event.total > 0) {
|
||||
callbacks.progress(Math.min(1, event.loaded / event.total));
|
||||
}
|
||||
});
|
||||
request.addEventListener("load", () => {
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
callbacks.failed(`Upload failed (${request.status}).`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(request.responseText);
|
||||
if (!isUploadedFileResponse(parsed)) {
|
||||
callbacks.failed("Upload returned an invalid attachment reference.");
|
||||
return;
|
||||
}
|
||||
callbacks.complete(parsed.file);
|
||||
} catch {
|
||||
callbacks.failed("Upload returned an invalid response.");
|
||||
}
|
||||
});
|
||||
request.addEventListener("error", () => callbacks.failed("Upload failed."));
|
||||
request.addEventListener("abort", () => callbacks.failed("Upload cancelled."));
|
||||
request.send(file);
|
||||
return request;
|
||||
}
|
||||
|
||||
function isUploadedFileResponse(
|
||||
value: unknown,
|
||||
): value is { file: UploadedFileRef } {
|
||||
if (!value || typeof value !== "object" || !("file" in value)) return false;
|
||||
const file = value.file;
|
||||
return !!file && typeof file === "object" &&
|
||||
"artifact_id" in file && typeof file.artifact_id === "string" &&
|
||||
"file_name" in file && typeof file.file_name === "string" &&
|
||||
"media_type" in file && typeof file.media_type === "string" &&
|
||||
"byte_len" in file && typeof file.byte_len === "number" &&
|
||||
"sha256" in file && typeof file.sha256 === "string" &&
|
||||
"availability" in file && file.availability === "available";
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
buildComposerRequest,
|
||||
buildComposerSegmentsRequest,
|
||||
parseSigilSegments,
|
||||
} from "./composer-command.ts";
|
||||
|
||||
@@ -25,6 +26,26 @@ Deno.test("parseSigilSegments leaves hash sigils as plain text", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
Deno.test("uploaded-file-only input remains a typed run request", () => {
|
||||
const file = {
|
||||
artifact_id: "01900000-0000-7000-8000-000000000001",
|
||||
file_name: "notes.md",
|
||||
media_type: "text/markdown",
|
||||
created_at_ms: 1,
|
||||
availability: "available" as const,
|
||||
byte_len: 12,
|
||||
sha256: "a".repeat(64),
|
||||
};
|
||||
assertEquals(buildComposerSegmentsRequest([{ kind: "uploaded_file", file }]), {
|
||||
ok: true,
|
||||
request: {
|
||||
kind: "user",
|
||||
content: "[Attached file: notes.md]",
|
||||
segments: [{ kind: "uploaded_file", file }],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("notify command exposes the operation instead of a System-role input", () => {
|
||||
assertEquals(buildComposerRequest(":notify reread the Ticket"), {
|
||||
ok: true,
|
||||
|
||||
@@ -88,8 +88,10 @@ export function buildComposerSegmentsRequest(
|
||||
sourceSegments: readonly Segment[],
|
||||
options: ComposerSegmentsRequestOptions = {},
|
||||
): ComposerCommandResult {
|
||||
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
|
||||
if (!hasPaste) {
|
||||
const hasRichSegment = sourceSegments.some((segment) =>
|
||||
segment.kind === "paste" || segment.kind === "uploaded_file"
|
||||
);
|
||||
if (!hasRichSegment) {
|
||||
const content = sourceSegments.map(segmentContent).join("");
|
||||
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
|
||||
return buildComposerRequest(content);
|
||||
@@ -121,7 +123,7 @@ export function buildComposerSegmentsRequest(
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
"Commands cannot include a paste chip. Remove the chip or send it as a message.",
|
||||
"Commands cannot include paste or attachment chips. Remove the chip or send it as a message.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +156,8 @@ function segmentContent(segment: Segment): string {
|
||||
return segment.selector;
|
||||
case "paste_artifact":
|
||||
return "";
|
||||
case "uploaded_file":
|
||||
return `[Attached file: ${segment.file.file_name}]`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -161,6 +161,26 @@ Deno.test("large paste segments project compact artifact metadata", () => {
|
||||
assert(!text.includes(body), "artifact body is not projected");
|
||||
});
|
||||
|
||||
Deno.test("uploaded files project bounded metadata without client paths or bytes", () => {
|
||||
const text = segmentsToText([{
|
||||
kind: "uploaded_file",
|
||||
file: {
|
||||
artifact_id: "019ca7c8-57b6-7f05-8edf-524147aba7b3",
|
||||
file_name: "report.pdf",
|
||||
media_type: "application/pdf",
|
||||
created_at_ms: 1_700_000_000_000,
|
||||
availability: "available",
|
||||
byte_len: 4096,
|
||||
sha256: "b".repeat(64),
|
||||
source_entry_id: "entry-2",
|
||||
},
|
||||
}]);
|
||||
assert(text.includes("report.pdf"), "display name is visible");
|
||||
assert(text.includes("application/pdf"), "media type is visible");
|
||||
assert(text.includes("4096 bytes"), "bounded size is visible");
|
||||
assert(!text.includes("/home/user"), "client path is not projected");
|
||||
});
|
||||
|
||||
Deno.test("console routing projects live errors but not completion replies", () => {
|
||||
const errorEvent = {
|
||||
event: "error",
|
||||
|
||||
@@ -1071,6 +1071,8 @@ export function segmentsToText(segments: Segment[]): string {
|
||||
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
|
||||
case "paste_artifact":
|
||||
return `[Large paste artifact ${segment.artifact.artifact_id}: ${segment.artifact.byte_len} bytes, ${segment.artifact.media_type}, ${segment.artifact.availability}, created ${segment.artifact.created_at_ms} ms, sha256 ${segment.artifact.sha256}]`;
|
||||
case "uploaded_file":
|
||||
return `[Attachment: ${segment.file.file_name} · ${segment.file.media_type} · ${segment.file.byte_len} bytes · ${segment.file.availability}]`;
|
||||
case "file_ref":
|
||||
return `@file ${segment.path}`;
|
||||
case "unknown":
|
||||
|
||||
+247
-2
@@ -32,6 +32,12 @@
|
||||
type ConsoleViewScroll,
|
||||
} from "$lib/workspace/console/model";
|
||||
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
||||
import {
|
||||
MAX_FILES_PER_SUBMISSION,
|
||||
uploadAttachment,
|
||||
validateAttachmentFile,
|
||||
type ComposerAttachment,
|
||||
} from "$lib/workspace/console/composer-attachments";
|
||||
import { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
|
||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
||||
@@ -129,6 +135,10 @@
|
||||
};
|
||||
|
||||
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
|
||||
let attachments = $state<ComposerAttachment[]>([]);
|
||||
let nextAttachmentId = 1;
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let isDraggingFiles = $state(false);
|
||||
let completionEntries = $state<ComposerCompletionEntry[]>([]);
|
||||
let completionToken = $state<ComposerCompletionToken | null>(null);
|
||||
let completionBusy = $state(false);
|
||||
@@ -582,9 +592,24 @@
|
||||
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
|
||||
}
|
||||
|
||||
function discardAllAttachments(): void {
|
||||
const discarded = attachments;
|
||||
attachments = [];
|
||||
for (const attachment of discarded) {
|
||||
attachment.request?.abort();
|
||||
if (attachment.reference) {
|
||||
void fetch(
|
||||
`${attachment.uploadPath}/${encodeURIComponent(attachment.reference.artifact_id)}`,
|
||||
{ method: "DELETE" },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function switchComposerTarget(target: ConsoleTarget) {
|
||||
const nextKey = `${target.workspaceId}:${target.runtimeId}:${target.workerId}`;
|
||||
if (nextKey === activeComposerTargetKey) return;
|
||||
if (activeComposerTargetKey) discardAllAttachments();
|
||||
if (composerInputElement) {
|
||||
composerDrafts.set(
|
||||
activeComposerTargetKey,
|
||||
@@ -613,8 +638,118 @@
|
||||
void submitDraft(composerInputElement?.snapshot() ?? draft);
|
||||
}
|
||||
|
||||
function attachmentPath(): string {
|
||||
return `/api/w/${encodeURIComponent(workspaceId)}/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}/attachments`;
|
||||
}
|
||||
|
||||
function updateAttachment(id: number, update: Partial<ComposerAttachment>): void {
|
||||
attachments = attachments.map((attachment) =>
|
||||
attachment.id === id ? { ...attachment, ...update } : attachment,
|
||||
);
|
||||
}
|
||||
|
||||
function startAttachmentUpload(attachment: ComposerAttachment): void {
|
||||
const error = validateAttachmentFile(attachment.file);
|
||||
if (error) {
|
||||
updateAttachment(attachment.id, { state: "failed", error, request: null });
|
||||
return;
|
||||
}
|
||||
updateAttachment(attachment.id, {
|
||||
state: "uploading",
|
||||
progress: 0,
|
||||
reference: null,
|
||||
error: null,
|
||||
request: null,
|
||||
});
|
||||
const request = uploadAttachment(attachment.uploadPath, attachment.file, {
|
||||
progress: (progress) => updateAttachment(attachment.id, { progress }),
|
||||
complete: (reference) =>
|
||||
updateAttachment(attachment.id, {
|
||||
state: "uploaded",
|
||||
progress: 1,
|
||||
reference,
|
||||
error: null,
|
||||
request: null,
|
||||
}),
|
||||
failed: (message) =>
|
||||
updateAttachment(attachment.id, {
|
||||
state: "failed",
|
||||
error: message,
|
||||
request: null,
|
||||
}),
|
||||
});
|
||||
updateAttachment(attachment.id, { request });
|
||||
}
|
||||
|
||||
function addAttachmentFiles(files: Iterable<File>): void {
|
||||
const available = Math.max(0, MAX_FILES_PER_SUBMISSION - attachments.length);
|
||||
for (const file of Array.from(files).slice(0, available)) {
|
||||
const attachment: ComposerAttachment = {
|
||||
id: nextAttachmentId++,
|
||||
file,
|
||||
uploadPath: attachmentPath(),
|
||||
state: "uploading",
|
||||
progress: 0,
|
||||
reference: null,
|
||||
error: null,
|
||||
request: null,
|
||||
};
|
||||
attachments = [...attachments, attachment];
|
||||
startAttachmentUpload(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAttachment(attachment: ComposerAttachment): Promise<void> {
|
||||
attachment.request?.abort();
|
||||
attachments = attachments.filter((candidate) => candidate.id !== attachment.id);
|
||||
if (attachment.reference) {
|
||||
await fetch(`${attachment.uploadPath}/${encodeURIComponent(attachment.reference.artifact_id)}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function retryAttachment(attachment: ComposerAttachment): void {
|
||||
startAttachmentUpload(attachment);
|
||||
}
|
||||
|
||||
function handleFileInput(event: Event): void {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
if (input.files) addAttachmentFiles(input.files);
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function handleFileDragOver(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes("Files")) return;
|
||||
event.preventDefault();
|
||||
isDraggingFiles = true;
|
||||
}
|
||||
|
||||
function handleFileDrop(event: DragEvent): void {
|
||||
if (!event.dataTransfer?.types.includes("Files")) return;
|
||||
event.preventDefault();
|
||||
isDraggingFiles = false;
|
||||
if (event.dataTransfer?.files) addAttachmentFiles(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
async function submitDraft(value: ComposerDraftSnapshot) {
|
||||
const command = buildComposerSegmentsRequest(value.segments, {
|
||||
const incompleteAttachment = attachments.find((attachment) =>
|
||||
attachment.state !== "uploaded" || !attachment.reference
|
||||
);
|
||||
if (incompleteAttachment) {
|
||||
composerNotice = null;
|
||||
sendError = incompleteAttachment.state === "uploading"
|
||||
? "Wait for file uploads to finish before sending."
|
||||
: incompleteAttachment.error ?? "Retry or remove the failed attachment.";
|
||||
return;
|
||||
}
|
||||
const attachmentSegments: Segment[] = attachments.map((attachment) => ({
|
||||
kind: "uploaded_file",
|
||||
file: attachment.reference!,
|
||||
}));
|
||||
const command = buildComposerSegmentsRequest(
|
||||
[...value.segments, ...attachmentSegments],
|
||||
{
|
||||
preserveExactText: value.textPastes.length > 0,
|
||||
});
|
||||
if (!command.ok) {
|
||||
@@ -637,6 +772,7 @@
|
||||
const method = composerRequestToProtocolMethod(command.request);
|
||||
sendProtocolMethod(method);
|
||||
composerInputElement?.clear();
|
||||
attachments = [];
|
||||
if (method.method === "run" || method.method === "notify") {
|
||||
liveWorkerState = "running";
|
||||
}
|
||||
@@ -1338,6 +1474,10 @@
|
||||
if (!targetWorker) void loadWorker(target, token);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => discardAllAttachments();
|
||||
});
|
||||
|
||||
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
|
||||
</script>
|
||||
|
||||
@@ -1599,7 +1739,23 @@
|
||||
/>
|
||||
|
||||
<form class="console-composer" onsubmit={sendMessage}>
|
||||
<div class="composer-input-shell">
|
||||
<div
|
||||
class="composer-input-shell"
|
||||
role="group"
|
||||
aria-label="Message composer and file drop area"
|
||||
class:dragging-files={isDraggingFiles}
|
||||
ondragover={handleFileDragOver}
|
||||
ondragleave={() => (isDraggingFiles = false)}
|
||||
ondrop={handleFileDrop}
|
||||
>
|
||||
<input
|
||||
class="attachment-file-input"
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
multiple
|
||||
accept="text/*,application/json,application/pdf,image/png,image/jpeg,image/gif,image/webp"
|
||||
onchange={handleFileInput}
|
||||
/>
|
||||
<ComposerInput
|
||||
bind:this={composerInputElement}
|
||||
ariaLabel="Console input"
|
||||
@@ -1609,8 +1765,37 @@
|
||||
onkeydown={handleComposerKeydown}
|
||||
onsubmit={handleComposerSubmit}
|
||||
/>
|
||||
{#if attachments.length > 0}
|
||||
<div class="composer-attachments" aria-live="polite">
|
||||
{#each attachments as attachment (attachment.id)}
|
||||
<div class:failed={attachment.state === "failed"} class="composer-attachment">
|
||||
<span class="attachment-name" title={attachment.file.name}>{attachment.file.name}</span>
|
||||
{#if attachment.state === "uploading"}
|
||||
<progress max="1" value={attachment.progress} aria-label={`Uploading ${attachment.file.name}`}></progress>
|
||||
<span>{Math.round(attachment.progress * 100)}%</span>
|
||||
{:else if attachment.state === "failed"}
|
||||
<span class="error">{attachment.error}</span>
|
||||
<button type="button" onclick={() => retryAttachment(attachment)}>Retry</button>
|
||||
{:else}
|
||||
<span>Ready</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
onclick={() => void removeAttachment(attachment)}
|
||||
>×</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="composer-input-footer">
|
||||
<div class="composer-footer-slot">
|
||||
<button
|
||||
class="composer-attach-button"
|
||||
type="button"
|
||||
disabled={!composerEditable || attachments.length >= MAX_FILES_PER_SUBMISSION}
|
||||
onclick={() => fileInput?.click()}
|
||||
>Attach file</button>
|
||||
{#if completionBusy || completionError || completionEntries.length > 0}
|
||||
<div class="composer-completions" aria-live="polite">
|
||||
{#if completionBusy}
|
||||
@@ -1933,6 +2118,66 @@
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-cyan) 18%, transparent);
|
||||
}
|
||||
|
||||
.composer-input-shell.dragging-files {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 8%, var(--bg-raised));
|
||||
}
|
||||
|
||||
.attachment-file-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
|
||||
.composer-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: 0 var(--space-3) 3rem;
|
||||
}
|
||||
|
||||
.composer-attachment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font: 500 0.75rem/1.2 var(--font-mono);
|
||||
}
|
||||
|
||||
.composer-attachment.failed {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.composer-attachment progress {
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
max-width: 16rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.composer-attachment button,
|
||||
.composer-attach-button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.composer-attach-button {
|
||||
padding: 0.35rem 0;
|
||||
}
|
||||
|
||||
.composer-input-footer {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
|
||||
Reference in New Issue
Block a user