feat: upload client-local files from Web and TUI
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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":
|
||||
|
||||
+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