diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index a7ade800..c0461858 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -1,6 +1,7 @@ use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError}; use crate::{BackendApiClient, BackendApiClientError, Client}; use reqwest::Method as HttpMethod; +use serde::Deserialize; use std::fmt; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; @@ -51,6 +52,61 @@ impl BackendRuntimeTarget { pub fn display_label(&self) -> String { format!("{}:{}", self.runtime_id, self.worker_id) } + + pub async fn upload_file( + &self, + file_name: &str, + media_type: &str, + content: Vec, + ) -> Result { + let api = BackendApiClient::from_stored_token(&self.base_url)?; + let path = format!( + "/api/w/{}/runtimes/{}/workers/{}/attachments?file_name={}&media_type={}", + path_segment_encode(&self.workspace_id), + path_segment_encode(&self.runtime_id), + path_segment_encode(&self.worker_id), + path_segment_encode(file_name), + path_segment_encode(media_type), + ); + let response = api + .request(HttpMethod::POST, &path)? + .body(content) + .send() + .await + .map_err(BackendRuntimeClientError::Http)?; + api.check_status(response.status())?; + response + .json::() + .await + .map(|response| response.file) + .map_err(BackendRuntimeClientError::Http) + } + + pub async fn delete_uploaded_file( + &self, + artifact_id: &str, + ) -> Result<(), BackendRuntimeClientError> { + let api = BackendApiClient::from_stored_token(&self.base_url)?; + let path = format!( + "/api/w/{}/runtimes/{}/workers/{}/attachments/{}", + path_segment_encode(&self.workspace_id), + path_segment_encode(&self.runtime_id), + path_segment_encode(&self.worker_id), + path_segment_encode(artifact_id), + ); + let response = api + .request(HttpMethod::DELETE, &path)? + .send() + .await + .map_err(BackendRuntimeClientError::Http)?; + api.check_status(response.status())?; + Ok(()) + } +} + +#[derive(Deserialize)] +struct UploadedFileResponse { + file: protocol::UploadedFileRef, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index d3b2396d..41bd7030 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -928,6 +928,14 @@ impl App { Some(self.method_for_run(queued.segments)) } + pub fn push_notice(&mut self, message: impl Into) { + self.blocks.push(Block::Alert { + level: AlertLevel::Info, + source: AlertSource::Worker, + message: message.into(), + }); + } + pub fn push_error(&mut self, message: impl Into) { self.blocks.push(Block::Alert { level: AlertLevel::Error, diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 6a63dff0..7f4e393b 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -15,9 +15,9 @@ use crossterm::event::{ }; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::{Command, execute}; -use protocol::{Event, Method, WorkerStatus}; +use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus}; #[cfg(feature = "e2e-test")] -use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment}; +use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId}; use ratatui::Terminal; use ratatui::backend::CrosstermBackend; use standalone::{StandaloneHost, StandaloneLaunchConfig}; @@ -123,20 +123,45 @@ fn copy_selection_to_terminal(app: &mut App) -> bool { struct ConsoleConnection { client: Client, standalone_host: Option, + backend_target: Option, + pending_attachments: Vec, +} + +fn attachment_media_type(path: &Path) -> Option<&'static str> { + match path + .extension() + .and_then(|extension| extension.to_str())? + .to_ascii_lowercase() + .as_str() + { + "txt" | "log" | "rs" | "toml" | "yaml" | "yml" | "dcdl" | "csv" => Some("text/plain"), + "md" => Some("text/markdown"), + "json" => Some("application/json"), + "pdf" => Some("application/pdf"), + "png" => Some("image/png"), + "jpg" | "jpeg" => Some("image/jpeg"), + "gif" => Some("image/gif"), + "webp" => Some("image/webp"), + _ => None, + } } impl ConsoleConnection { - fn new(client: Client) -> Self { - Self { - client, - standalone_host: None, - } - } - fn with_standalone_host(client: Client, host: StandaloneHost) -> Self { Self { client, standalone_host: Some(host), + backend_target: None, + pending_attachments: Vec::new(), + } + } + + fn with_backend_target(client: Client, target: BackendRuntimeTarget) -> Self { + Self { + client, + standalone_host: None, + backend_target: Some(target), + pending_attachments: Vec::new(), } } @@ -149,10 +174,81 @@ impl ConsoleConnection { } async fn send(&mut self, method: &Method) -> Result<(), Box> { - Ok(self.client.send(method).await?) + let mut prepared = method.clone(); + let carries_attachments = + matches!(prepared, Method::Run { .. }) && !self.pending_attachments.is_empty(); + if let Method::Run { input } = &mut prepared { + input.extend( + self.pending_attachments + .iter() + .cloned() + .map(|file| Segment::UploadedFile { file }), + ); + } + self.client.send(&prepared).await?; + if carries_attachments { + self.pending_attachments.clear(); + } + Ok(()) + } + + async fn upload_path( + &mut self, + path: &Path, + ) -> Result> { + let target = self.backend_target.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::Unsupported, + "client-local file upload is available only for Backend Workers", + ) + })?; + let metadata = tokio::fs::metadata(path).await?; + if !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "attachment path is not a file", + ) + .into()); + } + if metadata.len() > 10 * 1024 * 1024 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "attachment exceeds the 10 MiB limit", + ) + .into()); + } + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "attachment file name is not valid UTF-8", + ) + })?; + let media_type = attachment_media_type(path).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "attachment file type is not supported", + ) + })?; + let bytes = tokio::fs::read(path).await?; + let reference = target.upload_file(file_name, media_type, bytes).await?; + self.pending_attachments.push(reference.clone()); + Ok(reference) + } + + async fn clear_pending_attachments(&mut self) { + let references = std::mem::take(&mut self.pending_attachments); + if let Some(target) = &self.backend_target { + for reference in references { + let _ = target.delete_uploaded_file(&reference.artifact_id).await; + } + } } async fn shutdown(&mut self) -> Result<(), Box> { + self.clear_pending_attachments().await; if let Some(host) = self.standalone_host.take() { host.shutdown().await?; } @@ -248,12 +344,13 @@ pub(crate) async fn run_backend_runtime( target: BackendRuntimeTarget, ) -> Result<(), Box> { let worker_label = target.display_label(); + let attachment_target = target.clone(); let client = connect_backend_runtime(target).await?; let mut terminal = enter_fullscreen()?; let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root); app.connected = true; - let mut connection = ConsoleConnection::new(client); + let mut connection = ConsoleConnection::with_backend_target(client, attachment_target); let result = run_loop(&mut terminal, &mut app, &mut connection).await; let _ = leave_fullscreen(&mut terminal); result @@ -664,6 +761,27 @@ async fn run_loop( Ok(()) } +fn attachment_command_path(method: &Method) -> Option { + let Method::Run { input } = method else { + return None; + }; + let [Segment::Text { content }] = input.as_slice() else { + return None; + }; + let path = content.strip_prefix("/attach ")?.trim(); + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +fn is_clear_attachments_command(method: &Method) -> bool { + let Method::Run { input } = method else { + return false; + }; + matches!( + input.as_slice(), + [Segment::Text { content }] if content.trim() == "/clear-attachments" + ) +} + async fn handle_terminal_event( app: &mut App, client: &mut ConsoleConnection, @@ -672,7 +790,22 @@ async fn handle_terminal_event( match event { TermEvent::Key(key) => { if let Some(method) = handle_key(app, key) { - client.send(&method).await?; + if let Some(path) = attachment_command_path(&method) { + match client.upload_path(&path).await { + Ok(reference) => app.push_notice(format!( + "Attached {} ({} bytes); it will be sent with the next message.", + reference.file_name, reference.byte_len + )), + Err(error) => { + app.push_error(format!("Attachment upload failed: {error}")); + } + } + } else if is_clear_attachments_command(&method) { + client.clear_pending_attachments().await; + app.push_notice("Removed pending attachments."); + } else { + client.send(&method).await?; + } } } TermEvent::Mouse(mouse) => { @@ -1144,6 +1277,29 @@ mod tests { assert!(app.connected); } + #[test] + fn client_local_attachment_commands_are_typed_and_do_not_send_the_path() { + let attach = Method::Run { + input: vec![Segment::text("/attach /tmp/report.md")], + }; + assert_eq!( + attachment_command_path(&attach), + Some(PathBuf::from("/tmp/report.md")) + ); + assert!(!is_clear_attachments_command(&attach)); + + let clear = Method::Run { + input: vec![Segment::text("/clear-attachments")], + }; + assert!(is_clear_attachments_command(&clear)); + assert_eq!(attachment_command_path(&clear), None); + assert_eq!( + attachment_media_type(Path::new("report.webp")), + Some("image/webp") + ); + assert_eq!(attachment_media_type(Path::new("program.exe")), None); + } + #[test] fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() { let mut ansi = String::new(); diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index afede024..2c136845 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -266,6 +266,13 @@ impl InputBuffer { protocol::Segment::PasteArtifact { artifact } => { self.atoms.push(Atom::PasteArtifact(artifact.clone())); } + protocol::Segment::UploadedFile { file } => { + self.atoms.extend( + format!("[Attached file: {}]", file.file_name) + .chars() + .map(Atom::Char), + ); + } protocol::Segment::FileRef { path } => { self.atoms .push(Atom::FileRef(FileRefAtom { path: path.clone() })); diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 5df06feb..35b4fcbc 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1308,6 +1308,16 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) { artifact.created_at_ms ), ), + Segment::UploadedFile { file } => ( + Style::default().fg(Color::Cyan), + format!( + "[Attached {} | {} bytes, {}, {}]", + file.file_name, + file.byte_len, + file.media_type, + file.availability.as_str() + ), + ), Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")), Segment::Flow { selector } => ( Style::default().fg(Color::Yellow), @@ -1335,6 +1345,13 @@ fn segment_display_text(seg: &Segment) -> String { artifact.availability.as_str(), artifact.created_at_ms ), + Segment::UploadedFile { file } => format!( + "[Attached {} | {} bytes, {}, {}]", + file.file_name, + file.byte_len, + file.media_type, + file.availability.as_str() + ), Segment::FileRef { path } => format!("@{path}"), Segment::Flow { selector } => format!("[Flow: {selector}]"), Segment::Unknown => "[unknown segment]".to_owned(), diff --git a/web/workspace/src/lib/workspace/console/composer-attachments.test.ts b/web/workspace/src/lib/workspace/console/composer-attachments.test.ts new file mode 100644 index 00000000..c6153f90 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-attachments.test.ts @@ -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"); +}); diff --git a/web/workspace/src/lib/workspace/console/composer-attachments.ts b/web/workspace/src/lib/workspace/console/composer-attachments.ts new file mode 100644 index 00000000..e50dacad --- /dev/null +++ b/web/workspace/src/lib/workspace/console/composer-attachments.ts @@ -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"; +} diff --git a/web/workspace/src/lib/workspace/console/composer-command.test.ts b/web/workspace/src/lib/workspace/console/composer-command.test.ts index 6d8924d4..91ad5869 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.test.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.test.ts @@ -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, diff --git a/web/workspace/src/lib/workspace/console/composer-command.ts b/web/workspace/src/lib/workspace/console/composer-command.ts index e8fcfc91..4560d9c2 100644 --- a/web/workspace/src/lib/workspace/console/composer-command.ts +++ b/web/workspace/src/lib/workspace/console/composer-command.ts @@ -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 ""; } diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 587b6a81..e2dfdceb 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -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", diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index a8662d98..4464c89a 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -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": diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index d8d9cdf4..6b20031e 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -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(EMPTY_DRAFT); + let attachments = $state([]); + let nextAttachmentId = 1; + let fileInput: HTMLInputElement | null = null; + let isDraggingFiles = $state(false); let completionEntries = $state([]); let completionToken = $state(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): 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): 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 { + 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)); @@ -1599,7 +1739,23 @@ />
-
+
(isDraggingFiles = false)} + ondrop={handleFileDrop} + > + + {#if attachments.length > 0} +
+ {#each attachments as attachment (attachment.id)} +
+ {attachment.file.name} + {#if attachment.state === "uploading"} + + {Math.round(attachment.progress * 100)}% + {:else if attachment.state === "failed"} + {attachment.error} + + {:else} + Ready + {/if} + +
+ {/each} +
+ {/if}