feat: upload client-local files from Web and TUI

This commit is contained in:
2026-09-03 04:59:35 +09:00
parent 09a33e7283
commit e27b4feb25
12 changed files with 680 additions and 17 deletions
+56
View File
@@ -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<u8>,
) -> Result<protocol::UploadedFileRef, BackendRuntimeClientError> {
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::<UploadedFileResponse>()
.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)]
+8
View File
@@ -928,6 +928,14 @@ impl App {
Some(self.method_for_run(queued.segments))
}
pub fn push_notice(&mut self, message: impl Into<String>) {
self.blocks.push(Block::Alert {
level: AlertLevel::Info,
source: AlertSource::Worker,
message: message.into(),
});
}
pub fn push_error(&mut self, message: impl Into<String>) {
self.blocks.push(Block::Alert {
level: AlertLevel::Error,
+167 -11
View File
@@ -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<T> {
client: Client<T>,
standalone_host: Option<StandaloneHost>,
backend_target: Option<BackendRuntimeTarget>,
pending_attachments: Vec<UploadedFileRef>,
}
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<T: Socket> ConsoleConnection<T> {
fn new(client: Client<T>) -> Self {
Self {
client,
standalone_host: None,
}
}
fn with_standalone_host(client: Client<T>, host: StandaloneHost) -> Self {
Self {
client,
standalone_host: Some(host),
backend_target: None,
pending_attachments: Vec::new(),
}
}
fn with_backend_target(client: Client<T>, target: BackendRuntimeTarget) -> Self {
Self {
client,
standalone_host: None,
backend_target: Some(target),
pending_attachments: Vec::new(),
}
}
@@ -149,10 +174,81 @@ impl<T: Socket> ConsoleConnection<T> {
}
async fn send(&mut self, method: &Method) -> Result<(), Box<dyn std::error::Error>> {
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<UploadedFileRef, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<T: Socket>(
Ok(())
}
fn attachment_command_path(method: &Method) -> Option<PathBuf> {
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<T: Socket>(
app: &mut App,
client: &mut ConsoleConnection<T>,
@@ -672,9 +790,24 @@ async fn handle_terminal_event<T: Socket>(
match event {
TermEvent::Key(key) => {
if let Some(method) = handle_key(app, key) {
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) => {
handle_mouse(app, 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();
+7
View File
@@ -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() }));
+17
View File
@@ -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(),
@@ -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":
@@ -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;