Attach any file, take shares from other apps, and survive a backwards highlight
Attachments were images only. Now any file can be attached: from the file chooser behind the "+" menu, or from Android's share sheet, which the app is now in. An image still goes to the model as a picture; anything else is stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`) and the Claude driver ends the message with `Attached file: /abs/path`, since the CLI reads files by path and a model cannot be shown a trace. The user-message field is renamed `images` -> `attachments` on both sides, with a serde alias reading the rows written before. A share arrives before anyone has said which session it is for, so it is held in AppRoot with a banner on the list until a session takes it; an open session takes it at once. Unreadable shares are reported beside the composer, not thrown. The tool card crashed the app when opened on a command holding a quoted glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers `x '*/a/*'` with a span whose end is before its start, and AnnotatedString refuses the range. Such spans are dropped; the library is the place for the fix. The echo driver gains `/bash <command>` so a card with a given command can be produced on the emulator. ui-sandbox.sh's token salvage read the tokens block's close only at a line start, ran past the compact `),],` the server writes, and copied `setups` into the new config twice, which the server then refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bc69e8f9c
commit
6180663f14
25 files changed
+672
-165
No files matched your search
+65
-11
@@ -33,7 +33,7 @@ use crate::config::{
|
||||
};
|
||||
use claude::ClaudeDriver;
|
||||
use driver::{
|
||||
Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, Unqueued, context_after,
|
||||
AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued, context_after,
|
||||
};
|
||||
use echo::EchoDriver;
|
||||
use llama::LlamaDriver;
|
||||
@@ -417,14 +417,14 @@ impl LiveSession {
|
||||
/// The message is deliberately not recorded here. Sent into a running
|
||||
/// turn it waits, and writing it down on the way past would put it
|
||||
/// above output that happened before the session ever saw it.
|
||||
pub fn send_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
pub fn send_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||
// The attachments ride *on* the message rather than as `Image`
|
||||
// events emitted just before it. They used to be the latter, which
|
||||
// drew a person's screenshot as a row floating above the bubble
|
||||
// that sent it, and left the phone inferring from adjacency which
|
||||
// message an image went with -- a thing the sender already knew.
|
||||
self.ask("take a message", |driver| {
|
||||
driver.send_user_message(text, images)
|
||||
driver.send_user_message(text, attachments)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,11 +488,24 @@ impl LiveSession {
|
||||
/// Stores one uploaded attachment, returning the id `POST /message`
|
||||
/// references it by. Removed with the session directory on delete --
|
||||
/// the same path out as everything else in it.
|
||||
pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result<String> {
|
||||
// An unrecognized type is almost always a phone photo whose
|
||||
// content type the picker didn't set; jpg is the useful guess.
|
||||
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
|
||||
let name = format!("{}.{extension}", random_hex());
|
||||
///
|
||||
/// An image is named `<hex>.<extension>` and nothing else, since the
|
||||
/// model is shown the picture rather than told its name. Anything else
|
||||
/// keeps the name it arrived with after the hex: the session is told
|
||||
/// the path, and a trace called `trace-komodo-….perfetto-trace` says
|
||||
/// more to it than `3f9a…` would. The name is cleaned to characters a
|
||||
/// path and a URL both take unquoted, and the hex keeps two uploads of
|
||||
/// the same name apart. `AttachmentRef` documents the two shapes.
|
||||
pub fn save_attachment(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
content_type: &str,
|
||||
file_name: Option<&str>,
|
||||
) -> Result<AttachmentRef> {
|
||||
let name = match crate::media::extension_for(content_type) {
|
||||
Some(extension) => format!("{}.{extension}", random_hex()),
|
||||
None => format!("{}-{}", random_hex(), safe_file_name(file_name)),
|
||||
};
|
||||
let dir = self.dir().join("attachments");
|
||||
wg_app_link::private::create_dir(&dir)?;
|
||||
std::fs::write(dir.join(&name), bytes)
|
||||
@@ -1442,14 +1455,19 @@ impl SessionManager {
|
||||
/// Started before the message rather than after, because starting
|
||||
/// replaces the driver and the driver that takes the message has to be
|
||||
/// the one with a process behind it.
|
||||
pub fn send_message(&self, id: &str, text: String, images: Vec<ImageRef>) -> Result<()> {
|
||||
pub fn send_message(
|
||||
&self,
|
||||
id: &str,
|
||||
text: String,
|
||||
attachments: Vec<AttachmentRef>,
|
||||
) -> Result<()> {
|
||||
// Only `Exited` starts anything -- see `start_if_exited`. A session
|
||||
// this cannot say has exited keeps the behaviour it always had: the
|
||||
// message goes to the driver, which answers for it.
|
||||
self.start_if_exited(id)?;
|
||||
self.session(id)
|
||||
.with_context(|| format!("no session {id}"))?
|
||||
.send_message(text, images);
|
||||
.send_message(text, attachments);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1767,6 +1785,34 @@ fn names<'a>(all: impl Iterator<Item = &'a str>) -> String {
|
||||
|
||||
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
|
||||
/// this scale.
|
||||
/// A file name reduced to what an attachment id may hold: letters, digits,
|
||||
/// `.`, `-` and `_`, no run of dots that could read as a parent directory,
|
||||
/// at most [`FILE_NAME_LIMIT`] characters keeping the tail (the extension
|
||||
/// is what identifies a file), and `file` when nothing usable is left.
|
||||
fn safe_file_name(name: Option<&str>) -> String {
|
||||
let cleaned: String = name
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cleaned = cleaned.replace("..", "_").trim_matches('.').to_string();
|
||||
if cleaned.is_empty() {
|
||||
return "file".to_string();
|
||||
}
|
||||
let excess = cleaned.chars().count().saturating_sub(FILE_NAME_LIMIT);
|
||||
cleaned.chars().skip(excess).collect()
|
||||
}
|
||||
|
||||
/// Longer than any name a person types, shorter than what a filesystem
|
||||
/// refuses once the hex and a dash are in front of it.
|
||||
const FILE_NAME_LIMIT: usize = 120;
|
||||
|
||||
pub fn random_hex() -> String {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 8];
|
||||
@@ -2232,7 +2278,15 @@ async fn pump(
|
||||
// the position is the only way a reader can put it back where it
|
||||
// happened -- see `Event::PeerMessage::turn_start`.
|
||||
let event = match event {
|
||||
Event::MessageTaken { id, text, images } => Event::UserMessage { id, text, images },
|
||||
Event::MessageTaken {
|
||||
id,
|
||||
text,
|
||||
attachments,
|
||||
} => Event::UserMessage {
|
||||
id,
|
||||
text,
|
||||
attachments,
|
||||
},
|
||||
Event::PeerMessage { from, text, .. } => Event::PeerMessage {
|
||||
from,
|
||||
text,
|
||||
|
||||
Reference in new issue
Block a user