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::transport::websocket::{Socket as WebSocket, SocketError as WebSocketError};
|
||||||
use crate::{BackendApiClient, BackendApiClientError, Client};
|
use crate::{BackendApiClient, BackendApiClientError, Client};
|
||||||
use reqwest::Method as HttpMethod;
|
use reqwest::Method as HttpMethod;
|
||||||
|
use serde::Deserialize;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||||
@@ -51,6 +52,61 @@ impl BackendRuntimeTarget {
|
|||||||
pub fn display_label(&self) -> String {
|
pub fn display_label(&self) -> String {
|
||||||
format!("{}:{}", self.runtime_id, self.worker_id)
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -928,6 +928,14 @@ impl App {
|
|||||||
Some(self.method_for_run(queued.segments))
|
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>) {
|
pub fn push_error(&mut self, message: impl Into<String>) {
|
||||||
self.blocks.push(Block::Alert {
|
self.blocks.push(Block::Alert {
|
||||||
level: AlertLevel::Error,
|
level: AlertLevel::Error,
|
||||||
|
|||||||
+168
-12
@@ -15,9 +15,9 @@ use crossterm::event::{
|
|||||||
};
|
};
|
||||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||||
use crossterm::{Command, execute};
|
use crossterm::{Command, execute};
|
||||||
use protocol::{Event, Method, WorkerStatus};
|
use protocol::{Event, Method, Segment, UploadedFileRef, WorkerStatus};
|
||||||
#[cfg(feature = "e2e-test")]
|
#[cfg(feature = "e2e-test")]
|
||||||
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId, Segment};
|
use protocol::{Greeting, RewindSummary, RewindTarget, RewindTargetId};
|
||||||
use ratatui::Terminal;
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
use standalone::{StandaloneHost, StandaloneLaunchConfig};
|
||||||
@@ -123,20 +123,45 @@ fn copy_selection_to_terminal(app: &mut App) -> bool {
|
|||||||
struct ConsoleConnection<T> {
|
struct ConsoleConnection<T> {
|
||||||
client: Client<T>,
|
client: Client<T>,
|
||||||
standalone_host: Option<StandaloneHost>,
|
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> {
|
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 {
|
fn with_standalone_host(client: Client<T>, host: StandaloneHost) -> Self {
|
||||||
Self {
|
Self {
|
||||||
client,
|
client,
|
||||||
standalone_host: Some(host),
|
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>> {
|
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>> {
|
async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
self.clear_pending_attachments().await;
|
||||||
if let Some(host) = self.standalone_host.take() {
|
if let Some(host) = self.standalone_host.take() {
|
||||||
host.shutdown().await?;
|
host.shutdown().await?;
|
||||||
}
|
}
|
||||||
@@ -248,12 +344,13 @@ pub(crate) async fn run_backend_runtime(
|
|||||||
target: BackendRuntimeTarget,
|
target: BackendRuntimeTarget,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let worker_label = target.display_label();
|
let worker_label = target.display_label();
|
||||||
|
let attachment_target = target.clone();
|
||||||
let client = connect_backend_runtime(target).await?;
|
let client = connect_backend_runtime(target).await?;
|
||||||
let mut terminal = enter_fullscreen()?;
|
let mut terminal = enter_fullscreen()?;
|
||||||
let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
|
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);
|
let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root);
|
||||||
app.connected = true;
|
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 result = run_loop(&mut terminal, &mut app, &mut connection).await;
|
||||||
let _ = leave_fullscreen(&mut terminal);
|
let _ = leave_fullscreen(&mut terminal);
|
||||||
result
|
result
|
||||||
@@ -664,6 +761,27 @@ async fn run_loop<T: Socket>(
|
|||||||
Ok(())
|
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>(
|
async fn handle_terminal_event<T: Socket>(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
client: &mut ConsoleConnection<T>,
|
client: &mut ConsoleConnection<T>,
|
||||||
@@ -672,7 +790,22 @@ async fn handle_terminal_event<T: Socket>(
|
|||||||
match event {
|
match event {
|
||||||
TermEvent::Key(key) => {
|
TermEvent::Key(key) => {
|
||||||
if let Some(method) = handle_key(app, 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) => {
|
TermEvent::Mouse(mouse) => {
|
||||||
@@ -1144,6 +1277,29 @@ mod tests {
|
|||||||
assert!(app.connected);
|
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]
|
#[test]
|
||||||
fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
|
fn single_worker_mouse_capture_avoids_drag_and_all_motion_modes() {
|
||||||
let mut ansi = String::new();
|
let mut ansi = String::new();
|
||||||
|
|||||||
@@ -266,6 +266,13 @@ impl InputBuffer {
|
|||||||
protocol::Segment::PasteArtifact { artifact } => {
|
protocol::Segment::PasteArtifact { artifact } => {
|
||||||
self.atoms.push(Atom::PasteArtifact(artifact.clone()));
|
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 } => {
|
protocol::Segment::FileRef { path } => {
|
||||||
self.atoms
|
self.atoms
|
||||||
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
|
.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
|
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::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
|
||||||
Segment::Flow { selector } => (
|
Segment::Flow { selector } => (
|
||||||
Style::default().fg(Color::Yellow),
|
Style::default().fg(Color::Yellow),
|
||||||
@@ -1335,6 +1345,13 @@ fn segment_display_text(seg: &Segment) -> String {
|
|||||||
artifact.availability.as_str(),
|
artifact.availability.as_str(),
|
||||||
artifact.created_at_ms
|
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::FileRef { path } => format!("@{path}"),
|
||||||
Segment::Flow { selector } => format!("[Flow: {selector}]"),
|
Segment::Flow { selector } => format!("[Flow: {selector}]"),
|
||||||
Segment::Unknown => "[unknown segment]".to_owned(),
|
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 {
|
import {
|
||||||
buildComposerRequest,
|
buildComposerRequest,
|
||||||
|
buildComposerSegmentsRequest,
|
||||||
parseSigilSegments,
|
parseSigilSegments,
|
||||||
} from "./composer-command.ts";
|
} 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", () => {
|
Deno.test("notify command exposes the operation instead of a System-role input", () => {
|
||||||
assertEquals(buildComposerRequest(":notify reread the Ticket"), {
|
assertEquals(buildComposerRequest(":notify reread the Ticket"), {
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -88,8 +88,10 @@ export function buildComposerSegmentsRequest(
|
|||||||
sourceSegments: readonly Segment[],
|
sourceSegments: readonly Segment[],
|
||||||
options: ComposerSegmentsRequestOptions = {},
|
options: ComposerSegmentsRequestOptions = {},
|
||||||
): ComposerCommandResult {
|
): ComposerCommandResult {
|
||||||
const hasPaste = sourceSegments.some((segment) => segment.kind === "paste");
|
const hasRichSegment = sourceSegments.some((segment) =>
|
||||||
if (!hasPaste) {
|
segment.kind === "paste" || segment.kind === "uploaded_file"
|
||||||
|
);
|
||||||
|
if (!hasRichSegment) {
|
||||||
const content = sourceSegments.map(segmentContent).join("");
|
const content = sourceSegments.map(segmentContent).join("");
|
||||||
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
|
if (!options.preserveExactText || content.trimStart().startsWith(":")) {
|
||||||
return buildComposerRequest(content);
|
return buildComposerRequest(content);
|
||||||
@@ -121,7 +123,7 @@ export function buildComposerSegmentsRequest(
|
|||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message:
|
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;
|
return segment.selector;
|
||||||
case "paste_artifact":
|
case "paste_artifact":
|
||||||
return "";
|
return "";
|
||||||
|
case "uploaded_file":
|
||||||
|
return `[Attached file: ${segment.file.file_name}]`;
|
||||||
default:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,26 @@ Deno.test("large paste segments project compact artifact metadata", () => {
|
|||||||
assert(!text.includes(body), "artifact body is not projected");
|
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", () => {
|
Deno.test("console routing projects live errors but not completion replies", () => {
|
||||||
const errorEvent = {
|
const errorEvent = {
|
||||||
event: "error",
|
event: "error",
|
||||||
|
|||||||
@@ -1071,6 +1071,8 @@ export function segmentsToText(segments: Segment[]): string {
|
|||||||
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
|
`[paste ${segment.id}: ${segment.chars} chars / ${segment.lines} lines]`;
|
||||||
case "paste_artifact":
|
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}]`;
|
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":
|
case "file_ref":
|
||||||
return `@file ${segment.path}`;
|
return `@file ${segment.path}`;
|
||||||
case "unknown":
|
case "unknown":
|
||||||
|
|||||||
+247
-2
@@ -32,6 +32,12 @@
|
|||||||
type ConsoleViewScroll,
|
type ConsoleViewScroll,
|
||||||
} from "$lib/workspace/console/model";
|
} from "$lib/workspace/console/model";
|
||||||
import type { Event as ProtocolEvent, Method as ProtocolMethod, RewindTarget, Segment } from "$lib/generated/protocol";
|
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 { pushWorkspaceAlert } from "$lib/workspace/alerts/store";
|
||||||
import { workspaceApiPath } from "$lib/workspace/api/http";
|
import { workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
|
||||||
@@ -129,6 +135,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
let draft = $state<ComposerDraftSnapshot>(EMPTY_DRAFT);
|
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 completionEntries = $state<ComposerCompletionEntry[]>([]);
|
||||||
let completionToken = $state<ComposerCompletionToken | null>(null);
|
let completionToken = $state<ComposerCompletionToken | null>(null);
|
||||||
let completionBusy = $state(false);
|
let completionBusy = $state(false);
|
||||||
@@ -582,9 +592,24 @@
|
|||||||
composerDrafts.set(activeComposerTargetKey, cachedComposerDraft(snapshot));
|
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) {
|
function switchComposerTarget(target: ConsoleTarget) {
|
||||||
const nextKey = `${target.workspaceId}:${target.runtimeId}:${target.workerId}`;
|
const nextKey = `${target.workspaceId}:${target.runtimeId}:${target.workerId}`;
|
||||||
if (nextKey === activeComposerTargetKey) return;
|
if (nextKey === activeComposerTargetKey) return;
|
||||||
|
if (activeComposerTargetKey) discardAllAttachments();
|
||||||
if (composerInputElement) {
|
if (composerInputElement) {
|
||||||
composerDrafts.set(
|
composerDrafts.set(
|
||||||
activeComposerTargetKey,
|
activeComposerTargetKey,
|
||||||
@@ -613,8 +638,118 @@
|
|||||||
void submitDraft(composerInputElement?.snapshot() ?? draft);
|
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) {
|
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,
|
preserveExactText: value.textPastes.length > 0,
|
||||||
});
|
});
|
||||||
if (!command.ok) {
|
if (!command.ok) {
|
||||||
@@ -637,6 +772,7 @@
|
|||||||
const method = composerRequestToProtocolMethod(command.request);
|
const method = composerRequestToProtocolMethod(command.request);
|
||||||
sendProtocolMethod(method);
|
sendProtocolMethod(method);
|
||||||
composerInputElement?.clear();
|
composerInputElement?.clear();
|
||||||
|
attachments = [];
|
||||||
if (method.method === "run" || method.method === "notify") {
|
if (method.method === "run" || method.method === "notify") {
|
||||||
liveWorkerState = "running";
|
liveWorkerState = "running";
|
||||||
}
|
}
|
||||||
@@ -1338,6 +1474,10 @@
|
|||||||
if (!targetWorker) void loadWorker(target, token);
|
if (!targetWorker) void loadWorker(target, token);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => discardAllAttachments();
|
||||||
|
});
|
||||||
|
|
||||||
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
|
$effect(() => connectProtocolTransport(worker, reloadToken, consoleTarget));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -1599,7 +1739,23 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<form class="console-composer" onsubmit={sendMessage}>
|
<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
|
<ComposerInput
|
||||||
bind:this={composerInputElement}
|
bind:this={composerInputElement}
|
||||||
ariaLabel="Console input"
|
ariaLabel="Console input"
|
||||||
@@ -1609,8 +1765,37 @@
|
|||||||
onkeydown={handleComposerKeydown}
|
onkeydown={handleComposerKeydown}
|
||||||
onsubmit={handleComposerSubmit}
|
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-input-footer">
|
||||||
<div class="composer-footer-slot">
|
<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}
|
{#if completionBusy || completionError || completionEntries.length > 0}
|
||||||
<div class="composer-completions" aria-live="polite">
|
<div class="composer-completions" aria-live="polite">
|
||||||
{#if completionBusy}
|
{#if completionBusy}
|
||||||
@@ -1933,6 +2118,66 @@
|
|||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--tui-cyan) 18%, transparent);
|
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 {
|
.composer-input-footer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0.7rem;
|
right: 0.7rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user