mirror of
https://github.com/block/buzz.git
synced 2026-11-11 20:01:09 +00:00
Merge remote-tracking branch 'origin/main' into kennylopez-agent-catalog-sharing
This commit is contained in:
@@ -71,7 +71,7 @@ export default defineConfig({
|
||||
"**/home-collapsed-top-chrome.spec.ts",
|
||||
"**/top-chrome-zoom-clearance.spec.ts",
|
||||
"**/thread-unread.spec.ts",
|
||||
"**/workspace-rail.spec.ts",
|
||||
"**/community-rail.spec.ts",
|
||||
"**/boot-splash.spec.ts",
|
||||
"**/thread-reply-anchor-roleplay.spec.ts",
|
||||
"**/threadpane-ultrawide.spec.ts",
|
||||
|
||||
@@ -155,9 +155,9 @@ pub(crate) fn sanitize_filename(name: &str) -> String {
|
||||
|
||||
/// Return true when a PNG/WebP payload declares animation.
|
||||
///
|
||||
/// Animated payloads are left byte-identical here so frame timing, looping,
|
||||
/// and disposal semantics are preserved. The relay's structural validator is
|
||||
/// still the authority that rejects any metadata-bearing animation.
|
||||
/// Animated payloads use structural sanitizers so frame timing, looping, and
|
||||
/// disposal semantics are preserved without flattening the image. The relay's
|
||||
/// validator remains the final authority for the sanitized container.
|
||||
fn is_animated_image(body: &[u8], mime: &str) -> bool {
|
||||
match mime {
|
||||
"image/png" if body.starts_with(b"\x89PNG\r\n\x1a\n") => {
|
||||
@@ -228,7 +228,40 @@ pub(crate) fn sanitize_image_for_upload(body: Vec<u8>, mime: &str) -> Result<Vec
|
||||
};
|
||||
|
||||
if is_animated_image(&body, mime) {
|
||||
return Ok(body);
|
||||
let oriented_format = match mime {
|
||||
"image/png" if super::media_animated::animated_png_uses_exif_orientation(&body) => {
|
||||
Some("PNG")
|
||||
}
|
||||
"image/webp" if super::media_animated::animated_webp_uses_exif_orientation(&body) => {
|
||||
Some("WebP")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(format) = oriented_format {
|
||||
return Err(format!(
|
||||
"animated {format} with EXIF orientation cannot be uploaded without changing its appearance"
|
||||
));
|
||||
}
|
||||
let color_profile_format = match mime {
|
||||
"image/png" if super::media_animated::animated_png_uses_icc_profile(&body) => {
|
||||
Some("PNG")
|
||||
}
|
||||
"image/webp" if super::media_animated::animated_webp_uses_icc_profile(&body) => {
|
||||
Some("WebP")
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(format) = color_profile_format {
|
||||
return Err(format!(
|
||||
"animated {format} with an ICC profile cannot be uploaded without changing its colors"
|
||||
));
|
||||
}
|
||||
let stripped = match mime {
|
||||
"image/png" => super::media_animated::strip_animated_png_metadata(&body),
|
||||
"image/webp" => super::media_animated::strip_animated_webp_metadata(&body),
|
||||
_ => None,
|
||||
};
|
||||
return Ok(stripped.unwrap_or(body));
|
||||
}
|
||||
|
||||
use image::ImageDecoder;
|
||||
@@ -900,18 +933,12 @@ mod tests {
|
||||
apng.extend_from_slice(&[0; 8]);
|
||||
apng.extend_from_slice(&[0; 4]);
|
||||
assert!(is_animated_image(&apng, "image/png"));
|
||||
assert_eq!(
|
||||
sanitize_image_for_upload(apng.clone(), "image/png").unwrap(),
|
||||
apng
|
||||
);
|
||||
assert!(sanitize_image_for_upload(apng, "image/png").is_ok());
|
||||
|
||||
let mut webp = b"RIFF\x0c\0\0\0WEBPANIM".to_vec();
|
||||
webp.extend_from_slice(&0u32.to_le_bytes());
|
||||
assert!(is_animated_image(&webp, "image/webp"));
|
||||
assert_eq!(
|
||||
sanitize_image_for_upload(webp.clone(), "image/webp").unwrap(),
|
||||
webp
|
||||
);
|
||||
assert!(sanitize_image_for_upload(webp, "image/webp").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
693
desktop/src-tauri/src/commands/media_animated.rs
Normal file
693
desktop/src-tauri/src/commands/media_animated.rs
Normal file
@@ -0,0 +1,693 @@
|
||||
//! Structural metadata stripping for animated PNG and WebP uploads.
|
||||
//!
|
||||
//! Re-encoding an animated image through `image::DynamicImage` keeps only its
|
||||
//! first frame. These helpers instead copy rendering chunks byte-for-byte while
|
||||
//! dropping the metadata channels rejected by the relay.
|
||||
|
||||
const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";
|
||||
const PNG_ALLOWED_ANCILLARY: &[[u8; 4]] = &[
|
||||
*b"cHRM", *b"gAMA", *b"sBIT", *b"sRGB", *b"bKGD", *b"hIST", *b"tRNS", *b"sPLT", *b"acTL",
|
||||
*b"fcTL", *b"fdAT",
|
||||
];
|
||||
const WEBP_ALLOWED_CHUNKS: &[[u8; 4]] =
|
||||
&[*b"VP8 ", *b"VP8L", *b"VP8X", *b"ALPH", *b"ANIM", *b"ANMF"];
|
||||
const WEBP_METADATA_FLAGS: u8 = 0x20 | 0x08 | 0x04;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum TiffEndian {
|
||||
Little,
|
||||
Big,
|
||||
}
|
||||
|
||||
fn tiff_u16(bytes: &[u8], offset: usize, endian: TiffEndian) -> Option<u16> {
|
||||
let value: [u8; 2] = bytes.get(offset..offset.checked_add(2)?)?.try_into().ok()?;
|
||||
Some(match endian {
|
||||
TiffEndian::Little => u16::from_le_bytes(value),
|
||||
TiffEndian::Big => u16::from_be_bytes(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn tiff_u32(bytes: &[u8], offset: usize, endian: TiffEndian) -> Option<u32> {
|
||||
let value: [u8; 4] = bytes.get(offset..offset.checked_add(4)?)?.try_into().ok()?;
|
||||
Some(match endian {
|
||||
TiffEndian::Little => u32::from_le_bytes(value),
|
||||
TiffEndian::Big => u32::from_be_bytes(value),
|
||||
})
|
||||
}
|
||||
|
||||
fn exif_orientation(payload: &[u8]) -> Option<u16> {
|
||||
let tiff = payload.strip_prefix(b"Exif\0\0").unwrap_or(payload);
|
||||
let endian = match tiff.get(..2)? {
|
||||
b"II" => TiffEndian::Little,
|
||||
b"MM" => TiffEndian::Big,
|
||||
_ => return None,
|
||||
};
|
||||
if tiff_u16(tiff, 2, endian)? != 42 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let ifd_offset = usize::try_from(tiff_u32(tiff, 4, endian)?).ok()?;
|
||||
let entry_count = usize::from(tiff_u16(tiff, ifd_offset, endian)?);
|
||||
let entries_start = ifd_offset.checked_add(2)?;
|
||||
for index in 0..entry_count {
|
||||
let entry = entries_start.checked_add(index.checked_mul(12)?)?;
|
||||
if tiff_u16(tiff, entry, endian)? == 0x0112
|
||||
&& tiff_u16(tiff, entry.checked_add(2)?, endian)? == 3
|
||||
&& tiff_u32(tiff, entry.checked_add(4)?, endian)? == 1
|
||||
{
|
||||
return tiff_u16(tiff, entry.checked_add(8)?, endian);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn png_contains_chunk(body: &[u8], target: &[u8; 4]) -> bool {
|
||||
if !body.starts_with(PNG_SIGNATURE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut offset = PNG_SIGNATURE.len();
|
||||
while offset < body.len() {
|
||||
let Some(header_end) = offset.checked_add(8).filter(|&end| end <= body.len()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(payload_len) = body
|
||||
.get(offset..offset + 4)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_be_bytes)
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(kind) = body.get(offset + 4..header_end) else {
|
||||
return false;
|
||||
};
|
||||
let Some(chunk_end) = header_end
|
||||
.checked_add(payload_len)
|
||||
.and_then(|end| end.checked_add(4))
|
||||
.filter(|&end| end <= body.len())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if kind == target {
|
||||
return true;
|
||||
}
|
||||
offset = chunk_end;
|
||||
if kind == b"IEND" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn webp_contains_top_level_chunk(body: &[u8], target: &[u8; 4]) -> bool {
|
||||
if body.len() < 12 || &body[..4] != b"RIFF" || &body[8..12] != b"WEBP" {
|
||||
return false;
|
||||
}
|
||||
let Some(declared) = body
|
||||
.get(4..8)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_le_bytes)
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(input_end) = declared
|
||||
.checked_add(8)
|
||||
.filter(|&end| (12..=body.len()).contains(&end))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut offset = 12usize;
|
||||
while offset < input_end {
|
||||
let Some(header_end) = offset.checked_add(8).filter(|&end| end <= input_end) else {
|
||||
return false;
|
||||
};
|
||||
let Some(kind) = body.get(offset..offset + 4) else {
|
||||
return false;
|
||||
};
|
||||
let Some(payload_len) = body
|
||||
.get(offset + 4..header_end)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_le_bytes)
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(chunk_end) = payload_len
|
||||
.checked_add(payload_len & 1)
|
||||
.and_then(|length| header_end.checked_add(length))
|
||||
.filter(|&end| end <= input_end)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if kind == target {
|
||||
return true;
|
||||
}
|
||||
offset = chunk_end;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Return true when removing an APNG ICC profile would change color rendering.
|
||||
pub(crate) fn animated_png_uses_icc_profile(body: &[u8]) -> bool {
|
||||
png_contains_chunk(body, b"iCCP")
|
||||
}
|
||||
|
||||
/// Return true when removing an animated WebP ICC profile would change colors.
|
||||
pub(crate) fn animated_webp_uses_icc_profile(body: &[u8]) -> bool {
|
||||
webp_contains_top_level_chunk(body, b"ICCP")
|
||||
}
|
||||
|
||||
/// Return true when an animated PNG relies on eXIf to rotate or mirror frames.
|
||||
pub(crate) fn animated_png_uses_exif_orientation(body: &[u8]) -> bool {
|
||||
if !body.starts_with(PNG_SIGNATURE) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut offset = PNG_SIGNATURE.len();
|
||||
while offset < body.len() {
|
||||
let Some(header_end) = offset.checked_add(8).filter(|&end| end <= body.len()) else {
|
||||
return false;
|
||||
};
|
||||
let Some(payload_len) = body
|
||||
.get(offset..offset + 4)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_be_bytes)
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(kind) = body.get(offset + 4..header_end) else {
|
||||
return false;
|
||||
};
|
||||
let payload_start = header_end;
|
||||
let Some(chunk_end) = payload_start
|
||||
.checked_add(payload_len)
|
||||
.and_then(|end| end.checked_add(4))
|
||||
.filter(|&end| end <= body.len())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if kind == b"eXIf"
|
||||
&& exif_orientation(&body[payload_start..payload_start + payload_len])
|
||||
.is_some_and(|orientation| (2..=8).contains(&orientation))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
offset = chunk_end;
|
||||
if kind == b"IEND" {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Return true when a WebP relies on EXIF to rotate or mirror its frames.
|
||||
///
|
||||
/// Structural metadata removal cannot bake this transform without decoding
|
||||
/// and re-encoding every frame, so callers must reject this uncommon case
|
||||
/// rather than silently changing how the animation renders.
|
||||
pub(crate) fn animated_webp_uses_exif_orientation(body: &[u8]) -> bool {
|
||||
if body.len() < 12 || &body[..4] != b"RIFF" || &body[8..12] != b"WEBP" {
|
||||
return false;
|
||||
}
|
||||
let Some(declared) = body
|
||||
.get(4..8)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_le_bytes)
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(input_end) = declared
|
||||
.checked_add(8)
|
||||
.filter(|&end| (12..=body.len()).contains(&end))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let mut offset = 12usize;
|
||||
while offset < input_end {
|
||||
let Some(header_end) = offset.checked_add(8).filter(|&end| end <= input_end) else {
|
||||
return false;
|
||||
};
|
||||
let Some(kind) = body.get(offset..offset + 4) else {
|
||||
return false;
|
||||
};
|
||||
let Some(payload_len) = body
|
||||
.get(offset + 4..header_end)
|
||||
.and_then(|bytes| bytes.try_into().ok())
|
||||
.map(u32::from_le_bytes)
|
||||
.and_then(|length| usize::try_from(length).ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let payload_start = header_end;
|
||||
let Some(chunk_end) = payload_len
|
||||
.checked_add(payload_len & 1)
|
||||
.and_then(|length| payload_start.checked_add(length))
|
||||
.filter(|&end| end <= input_end)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if kind == b"EXIF"
|
||||
&& exif_orientation(&body[payload_start..payload_start + payload_len])
|
||||
.is_some_and(|orientation| (2..=8).contains(&orientation))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
offset = chunk_end;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Strip metadata-bearing ancillary chunks from a PNG without touching frame
|
||||
/// control or image data. Bytes after `IEND` are truncated.
|
||||
pub(crate) fn strip_animated_png_metadata(body: &[u8]) -> Option<Vec<u8>> {
|
||||
if !body.starts_with(PNG_SIGNATURE) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut output = Vec::with_capacity(body.len());
|
||||
output.extend_from_slice(PNG_SIGNATURE);
|
||||
let mut offset = PNG_SIGNATURE.len();
|
||||
|
||||
while offset < body.len() {
|
||||
let header_end = offset.checked_add(8)?;
|
||||
if header_end > body.len() {
|
||||
return None;
|
||||
}
|
||||
let payload_len = u32::from_be_bytes(body[offset..offset + 4].try_into().ok()?) as usize;
|
||||
let kind: [u8; 4] = body[offset + 4..offset + 8].try_into().ok()?;
|
||||
let chunk_end = offset
|
||||
.checked_add(12)?
|
||||
.checked_add(payload_len)
|
||||
.filter(|&end| end <= body.len())?;
|
||||
|
||||
let ancillary = kind[0] & 0x20 != 0;
|
||||
if !ancillary || PNG_ALLOWED_ANCILLARY.contains(&kind) {
|
||||
output.extend_from_slice(&body[offset..chunk_end]);
|
||||
}
|
||||
|
||||
offset = chunk_end;
|
||||
if kind == *b"IEND" {
|
||||
return Some(output);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn append_webp_chunk(output: &mut Vec<u8>, kind: &[u8; 4], payload: &[u8]) -> Option<()> {
|
||||
output.extend_from_slice(kind);
|
||||
output.extend_from_slice(&u32::try_from(payload.len()).ok()?.to_le_bytes());
|
||||
output.extend_from_slice(payload);
|
||||
if payload.len() & 1 != 0 {
|
||||
output.push(0);
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn strip_anmf_metadata(payload: &[u8]) -> Option<Vec<u8>> {
|
||||
const FRAME_HEADER_LEN: usize = 16;
|
||||
if payload.len() < FRAME_HEADER_LEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut output = Vec::with_capacity(payload.len());
|
||||
output.extend_from_slice(&payload[..FRAME_HEADER_LEN]);
|
||||
let mut offset = FRAME_HEADER_LEN;
|
||||
let mut saw_alpha = false;
|
||||
let mut saw_image = false;
|
||||
|
||||
while offset < payload.len() {
|
||||
let header_end = offset.checked_add(8).filter(|&end| end <= payload.len())?;
|
||||
let kind: [u8; 4] = payload[offset..offset + 4].try_into().ok()?;
|
||||
let chunk_len =
|
||||
u32::from_le_bytes(payload[offset + 4..header_end].try_into().ok()?) as usize;
|
||||
let chunk_start = header_end;
|
||||
let chunk_end = chunk_len
|
||||
.checked_add(chunk_len & 1)
|
||||
.and_then(|length| chunk_start.checked_add(length))
|
||||
.filter(|&end| end <= payload.len())?;
|
||||
let chunk_payload = &payload[chunk_start..chunk_start + chunk_len];
|
||||
|
||||
match &kind {
|
||||
b"ALPH" if !saw_alpha && !saw_image => {
|
||||
append_webp_chunk(&mut output, &kind, chunk_payload)?;
|
||||
saw_alpha = true;
|
||||
}
|
||||
b"VP8 " if !saw_image => {
|
||||
append_webp_chunk(&mut output, &kind, chunk_payload)?;
|
||||
saw_image = true;
|
||||
}
|
||||
b"VP8L" if !saw_alpha && !saw_image => {
|
||||
append_webp_chunk(&mut output, &kind, chunk_payload)?;
|
||||
saw_image = true;
|
||||
}
|
||||
b"ALPH" | b"VP8 " | b"VP8L" => return None,
|
||||
_ => {}
|
||||
}
|
||||
|
||||
offset = chunk_end;
|
||||
}
|
||||
|
||||
saw_image.then_some(output)
|
||||
}
|
||||
|
||||
/// Strip metadata chunks and flags from a WebP container while retaining all
|
||||
/// still/animated rendering chunks. RIFF padding is canonicalized to zero and
|
||||
/// the container length is rewritten after removals.
|
||||
pub(crate) fn strip_animated_webp_metadata(body: &[u8]) -> Option<Vec<u8>> {
|
||||
if body.len() < 12 || &body[..4] != b"RIFF" || &body[8..12] != b"WEBP" {
|
||||
return None;
|
||||
}
|
||||
|
||||
let declared = u32::from_le_bytes(body[4..8].try_into().ok()?) as usize;
|
||||
let input_end = declared
|
||||
.checked_add(8)
|
||||
.filter(|&end| (12..=body.len()).contains(&end))?;
|
||||
let mut output = Vec::with_capacity(input_end);
|
||||
output.extend_from_slice(b"RIFF\0\0\0\0WEBP");
|
||||
let mut offset = 12usize;
|
||||
|
||||
while offset < input_end {
|
||||
if offset.checked_add(8)? > input_end {
|
||||
return None;
|
||||
}
|
||||
let kind: [u8; 4] = body[offset..offset + 4].try_into().ok()?;
|
||||
let payload_len =
|
||||
u32::from_le_bytes(body[offset + 4..offset + 8].try_into().ok()?) as usize;
|
||||
let payload_start = offset + 8;
|
||||
let padded_len = payload_len.checked_add(payload_len & 1)?;
|
||||
let chunk_end = payload_start
|
||||
.checked_add(padded_len)
|
||||
.filter(|&end| end <= input_end)?;
|
||||
|
||||
if WEBP_ALLOWED_CHUNKS.contains(&kind) {
|
||||
if kind == *b"VP8X" {
|
||||
let (&flags, rest) =
|
||||
body[payload_start..payload_start + payload_len].split_first()?;
|
||||
let mut payload = Vec::with_capacity(payload_len);
|
||||
payload.push(flags & !WEBP_METADATA_FLAGS);
|
||||
payload.extend_from_slice(rest);
|
||||
append_webp_chunk(&mut output, &kind, &payload)?;
|
||||
} else if kind == *b"ANMF" {
|
||||
let payload =
|
||||
strip_anmf_metadata(&body[payload_start..payload_start + payload_len])?;
|
||||
append_webp_chunk(&mut output, &kind, &payload)?;
|
||||
} else {
|
||||
append_webp_chunk(
|
||||
&mut output,
|
||||
&kind,
|
||||
&body[payload_start..payload_start + payload_len],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
offset = chunk_end;
|
||||
}
|
||||
|
||||
let riff_len = u32::try_from(output.len().checked_sub(8)?).ok()?;
|
||||
output[4..8].copy_from_slice(&riff_len.to_le_bytes());
|
||||
Some(output)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::media::sanitize_image_for_upload;
|
||||
|
||||
fn png_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
|
||||
let mut chunk = Vec::new();
|
||||
chunk.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||
chunk.extend_from_slice(kind);
|
||||
chunk.extend_from_slice(payload);
|
||||
// The structural sanitizer copies CRCs without interpreting them. A
|
||||
// zero placeholder keeps these focused tests dependency-free.
|
||||
chunk.extend_from_slice(&[0; 4]);
|
||||
chunk
|
||||
}
|
||||
|
||||
fn animated_png(metadata: bool) -> Vec<u8> {
|
||||
let mut png = PNG_SIGNATURE.to_vec();
|
||||
png.extend_from_slice(&png_chunk(b"IHDR", &[0; 13]));
|
||||
png.extend_from_slice(&png_chunk(b"acTL", &[0, 0, 0, 2, 0, 0, 0, 0]));
|
||||
if metadata {
|
||||
png.extend_from_slice(&png_chunk(b"tEXt", b"Location\0secret"));
|
||||
png.extend_from_slice(&png_chunk(b"pHYs", &[0; 9]));
|
||||
}
|
||||
png.extend_from_slice(&png_chunk(b"fcTL", &[0; 26]));
|
||||
png.extend_from_slice(&png_chunk(b"IDAT", &[1, 2, 3]));
|
||||
png.extend_from_slice(&png_chunk(b"fdAT", &[0, 0, 0, 1, 4, 5]));
|
||||
png.extend_from_slice(&png_chunk(b"IEND", &[]));
|
||||
png
|
||||
}
|
||||
|
||||
fn exif_orientation_payload(
|
||||
orientation: u16,
|
||||
endian: TiffEndian,
|
||||
include_preamble: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut exif = if include_preamble {
|
||||
b"Exif\0\0".to_vec()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
match endian {
|
||||
TiffEndian::Little => {
|
||||
exif.extend_from_slice(b"II");
|
||||
exif.extend_from_slice(&42u16.to_le_bytes());
|
||||
exif.extend_from_slice(&8u32.to_le_bytes());
|
||||
exif.extend_from_slice(&1u16.to_le_bytes());
|
||||
exif.extend_from_slice(&0x0112u16.to_le_bytes());
|
||||
exif.extend_from_slice(&3u16.to_le_bytes());
|
||||
exif.extend_from_slice(&1u32.to_le_bytes());
|
||||
exif.extend_from_slice(&orientation.to_le_bytes());
|
||||
exif.extend_from_slice(&[0; 2]);
|
||||
exif.extend_from_slice(&0u32.to_le_bytes());
|
||||
}
|
||||
TiffEndian::Big => {
|
||||
exif.extend_from_slice(b"MM");
|
||||
exif.extend_from_slice(&42u16.to_be_bytes());
|
||||
exif.extend_from_slice(&8u32.to_be_bytes());
|
||||
exif.extend_from_slice(&1u16.to_be_bytes());
|
||||
exif.extend_from_slice(&0x0112u16.to_be_bytes());
|
||||
exif.extend_from_slice(&3u16.to_be_bytes());
|
||||
exif.extend_from_slice(&1u32.to_be_bytes());
|
||||
exif.extend_from_slice(&orientation.to_be_bytes());
|
||||
exif.extend_from_slice(&[0; 2]);
|
||||
exif.extend_from_slice(&0u32.to_be_bytes());
|
||||
}
|
||||
}
|
||||
exif
|
||||
}
|
||||
|
||||
fn animated_png_with_orientation(orientation: u16, endian: TiffEndian) -> Vec<u8> {
|
||||
let clean = animated_png(false);
|
||||
let mut png = clean[..33].to_vec();
|
||||
png.extend_from_slice(&png_chunk(
|
||||
b"eXIf",
|
||||
&exif_orientation_payload(orientation, endian, false),
|
||||
));
|
||||
png.extend_from_slice(&clean[33..]);
|
||||
png
|
||||
}
|
||||
|
||||
fn webp_chunk(kind: &[u8; 4], payload: &[u8]) -> Vec<u8> {
|
||||
let mut chunk = Vec::new();
|
||||
chunk.extend_from_slice(kind);
|
||||
chunk.extend_from_slice(&(payload.len() as u32).to_le_bytes());
|
||||
chunk.extend_from_slice(payload);
|
||||
if payload.len() & 1 != 0 {
|
||||
chunk.push(0);
|
||||
}
|
||||
chunk
|
||||
}
|
||||
|
||||
fn animated_webp(metadata: bool) -> Vec<u8> {
|
||||
let metadata_flags = if metadata { WEBP_METADATA_FLAGS } else { 0 };
|
||||
let mut chunks = webp_chunk(b"VP8X", &[metadata_flags | 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
chunks.extend_from_slice(&webp_chunk(b"ANIM", &[0; 6]));
|
||||
if metadata {
|
||||
chunks.extend_from_slice(&webp_chunk(b"EXIF", b"location"));
|
||||
chunks.extend_from_slice(&webp_chunk(b"XMP ", b"<xmp/>"));
|
||||
chunks.extend_from_slice(&webp_chunk(b"JUNK", b"private"));
|
||||
}
|
||||
let mut frame = vec![0; 16];
|
||||
frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3]));
|
||||
chunks.extend_from_slice(&webp_chunk(b"ANMF", &frame));
|
||||
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&((chunks.len() + 4) as u32).to_le_bytes());
|
||||
webp.extend_from_slice(b"WEBP");
|
||||
webp.extend_from_slice(&chunks);
|
||||
webp
|
||||
}
|
||||
|
||||
fn animated_webp_with_orientation(orientation: u16, endian: TiffEndian) -> Vec<u8> {
|
||||
let exif = exif_orientation_payload(orientation, endian, true);
|
||||
let mut chunks = webp_chunk(
|
||||
b"VP8X",
|
||||
&[WEBP_METADATA_FLAGS | 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
);
|
||||
chunks.extend_from_slice(&webp_chunk(b"ANIM", &[0; 6]));
|
||||
chunks.extend_from_slice(&webp_chunk(b"EXIF", &exif));
|
||||
let mut frame = vec![0; 16];
|
||||
frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3]));
|
||||
chunks.extend_from_slice(&webp_chunk(b"ANMF", &frame));
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&((chunks.len() + 4) as u32).to_le_bytes());
|
||||
webp.extend_from_slice(b"WEBP");
|
||||
webp.extend_from_slice(&chunks);
|
||||
webp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_animated_png_metadata_preserves_animation_chunks() {
|
||||
assert_eq!(
|
||||
strip_animated_png_metadata(&animated_png(true)),
|
||||
Some(animated_png(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_animated_png_metadata_is_byte_identical_for_clean_input() {
|
||||
let clean = animated_png(false);
|
||||
assert_eq!(strip_animated_png_metadata(&clean), Some(clean));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_animated_webp_metadata_preserves_animation_chunks() {
|
||||
assert_eq!(
|
||||
strip_animated_webp_metadata(&animated_webp(true)),
|
||||
Some(animated_webp(false))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_animated_webp_metadata_is_byte_identical_for_clean_input() {
|
||||
let clean = animated_webp(false);
|
||||
assert_eq!(strip_animated_webp_metadata(&clean), Some(clean));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detects_non_identity_animated_webp_exif_orientation() {
|
||||
for endian in [TiffEndian::Little, TiffEndian::Big] {
|
||||
assert!(!animated_webp_uses_exif_orientation(
|
||||
&animated_webp_with_orientation(1, endian)
|
||||
));
|
||||
assert!(animated_webp_uses_exif_orientation(
|
||||
&animated_webp_with_orientation(6, endian)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detects_non_identity_animated_png_exif_orientation() {
|
||||
for endian in [TiffEndian::Little, TiffEndian::Big] {
|
||||
assert!(!animated_png_uses_exif_orientation(
|
||||
&animated_png_with_orientation(1, endian)
|
||||
));
|
||||
assert!(animated_png_uses_exif_orientation(
|
||||
&animated_png_with_orientation(6, endian)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detects_animated_icc_profiles() {
|
||||
let clean_png = animated_png(false);
|
||||
let mut png = clean_png[..33].to_vec();
|
||||
png.extend_from_slice(&png_chunk(b"iCCP", b"profile"));
|
||||
png.extend_from_slice(&clean_png[33..]);
|
||||
assert!(animated_png_uses_icc_profile(&png));
|
||||
assert!(sanitize_image_for_upload(png, "image/png").is_err());
|
||||
assert!(!animated_png_uses_icc_profile(&clean_png));
|
||||
|
||||
let mut chunks = webp_chunk(
|
||||
b"VP8X",
|
||||
&[WEBP_METADATA_FLAGS | 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||
);
|
||||
chunks.extend_from_slice(&webp_chunk(b"ICCP", b"profile"));
|
||||
chunks.extend_from_slice(&animated_webp(false)[30..]);
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&((chunks.len() + 4) as u32).to_le_bytes());
|
||||
webp.extend_from_slice(b"WEBP");
|
||||
webp.extend_from_slice(&chunks);
|
||||
assert!(animated_webp_uses_icc_profile(&webp));
|
||||
assert!(sanitize_image_for_upload(webp, "image/webp").is_err());
|
||||
assert!(!animated_webp_uses_icc_profile(&animated_webp(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_animated_webp_removes_nested_frame_metadata() {
|
||||
let mut dirty_frame = vec![0; 16];
|
||||
dirty_frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3]));
|
||||
dirty_frame.extend_from_slice(&webp_chunk(b"JUNK", b"location"));
|
||||
let mut clean_frame = vec![0; 16];
|
||||
clean_frame.extend_from_slice(&webp_chunk(b"VP8 ", &[1, 2, 3]));
|
||||
|
||||
assert_eq!(strip_anmf_metadata(&dirty_frame), Some(clean_frame.clone()));
|
||||
|
||||
clean_frame.extend_from_slice(&webp_chunk(b"VP8L", &[4]));
|
||||
assert!(strip_anmf_metadata(&clean_frame).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_sanitizers_truncate_trailing_bytes() {
|
||||
let clean_png = animated_png(false);
|
||||
let mut padded_png = clean_png.clone();
|
||||
padded_png.extend_from_slice(b"trailing metadata");
|
||||
assert_eq!(strip_animated_png_metadata(&padded_png), Some(clean_png));
|
||||
|
||||
let clean_webp = animated_webp(false);
|
||||
let mut padded_webp = clean_webp.clone();
|
||||
padded_webp.extend_from_slice(b"trailing metadata");
|
||||
assert_eq!(strip_animated_webp_metadata(&padded_webp), Some(clean_webp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_animated_sanitizers_reject_malformed_containers() {
|
||||
assert!(strip_animated_png_metadata(PNG_SIGNATURE).is_none());
|
||||
assert!(strip_animated_webp_metadata(b"RIFF\x20\0\0\0WEBP").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_upload_sanitizer_uses_structural_animation_scrubbers() {
|
||||
assert_eq!(
|
||||
sanitize_image_for_upload(animated_png(true), "image/png"),
|
||||
Ok(animated_png(false))
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_image_for_upload(animated_webp(true), "image/webp"),
|
||||
Ok(animated_webp(false))
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_image_for_upload(
|
||||
animated_webp_with_orientation(1, TiffEndian::Little),
|
||||
"image/webp"
|
||||
),
|
||||
Ok(animated_webp(false))
|
||||
);
|
||||
assert_eq!(
|
||||
sanitize_image_for_upload(
|
||||
animated_png_with_orientation(1, TiffEndian::Little),
|
||||
"image/png"
|
||||
),
|
||||
Ok(animated_png(false))
|
||||
);
|
||||
assert!(sanitize_image_for_upload(
|
||||
animated_webp_with_orientation(6, TiffEndian::Little),
|
||||
"image/webp"
|
||||
)
|
||||
.is_err());
|
||||
assert!(sanitize_image_for_upload(
|
||||
animated_png_with_orientation(6, TiffEndian::Little),
|
||||
"image/png"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -92,9 +92,17 @@ pub(crate) fn strip_gif_metadata(body: &[u8]) -> Option<Vec<u8>> {
|
||||
}
|
||||
let app = &body[i + 1..i + 12];
|
||||
let keep = app == b"NETSCAPE2.0" || app == b"ANIMEXTS1.0";
|
||||
i = gif_sub_blocks_end(body, i + 12)?;
|
||||
let data_start = i + 12;
|
||||
i = gif_sub_blocks_end(body, data_start)?;
|
||||
if keep {
|
||||
out.extend_from_slice(&body[start..i]);
|
||||
if body.get(data_start) != Some(&3)
|
||||
|| body.get(data_start + 1) != Some(&1)
|
||||
|| data_start.checked_add(5)? > body.len()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&body[start..data_start + 4]);
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
// Comment (0xFE), plain-text (0x01), and unknown
|
||||
@@ -182,6 +190,17 @@ mod tests {
|
||||
assert_eq!(strip_gif_metadata(&clean).unwrap(), clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_gif_metadata_canonicalizes_loop_extension() {
|
||||
let clean = minimal_gif();
|
||||
let mut dirty = clean[..37].to_vec();
|
||||
dirty.extend_from_slice(&[8]);
|
||||
dirty.extend_from_slice(b"location");
|
||||
dirty.push(0);
|
||||
dirty.extend_from_slice(&clean[38..]);
|
||||
assert_eq!(strip_gif_metadata(&dirty).unwrap(), clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_gif_metadata_truncates_bytes_after_trailer() {
|
||||
let mut padded = minimal_gif();
|
||||
|
||||
@@ -23,6 +23,7 @@ mod identity_archive;
|
||||
mod legacy_storage;
|
||||
mod link_preview;
|
||||
pub(crate) mod media;
|
||||
mod media_animated;
|
||||
mod media_download;
|
||||
mod media_gif;
|
||||
mod media_transcode;
|
||||
|
||||
@@ -148,6 +148,10 @@ pub fn build_create_channel(
|
||||
about: Option<&str>,
|
||||
ttl_seconds: Option<i32>,
|
||||
) -> Result<EventBuilder, String> {
|
||||
let name = buzz_sdk_pkg::canonical_channel_name(name);
|
||||
if name.trim().is_empty() {
|
||||
return Err("channel name is required".into());
|
||||
}
|
||||
let mut tags = vec![
|
||||
tag(vec!["h", &channel_id.to_string()])?,
|
||||
tag(vec!["name", name])?,
|
||||
@@ -194,6 +198,10 @@ pub fn build_update_channel(
|
||||
return Err("visibility must be \"open\" or \"private\"".into());
|
||||
}
|
||||
}
|
||||
let name = name.map(buzz_sdk_pkg::canonical_channel_name);
|
||||
if name.is_some_and(|name| name.trim().is_empty()) {
|
||||
return Err("channel name is required".into());
|
||||
}
|
||||
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
|
||||
if let Some(n) = name {
|
||||
tags.push(tag(vec!["name", n])?);
|
||||
@@ -842,7 +850,12 @@ pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result<EventBuild
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::Keys;
|
||||
|
||||
#[test]
|
||||
fn channel_builders_reject_hash_only_names() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
assert!(build_create_channel(channel_id, "###", "open", "stream", None, None).is_err());
|
||||
assert!(build_update_channel(channel_id, Some("###"), None, None, None).is_err());
|
||||
}
|
||||
/// Builder layout regression for the NIP-IA owner-of-agent archive flow.
|
||||
/// Compares against `docs/nips/NIP-IA.md` §Vector 1.
|
||||
#[test]
|
||||
|
||||
@@ -714,6 +714,7 @@ export function AppShell() {
|
||||
}
|
||||
onAddCommunity={addCommunityDialog.openDialog}
|
||||
onRemoveCommunity={communitiesHook.removeCommunity}
|
||||
onReorderCommunities={communitiesHook.reorderCommunities}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
communities={communitiesHook.communities}
|
||||
|
||||
@@ -68,20 +68,34 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
sole onboarding surface that chooses and persists `preferred_runtime`.
|
||||
`onboarding-agent-defaults.spec.ts` is the acceptance gate for anything
|
||||
touching this flow or the shared renderer.
|
||||
8. **Omit the Model control only after a confirmed successful empty
|
||||
discovery on an optional-model harness.** When the field model marks model
|
||||
as `acpNative` (Claude Code / Codex), `shouldRenderModelControl` hides the
|
||||
picker while discovery is in flight and after IPC resolves with no usable
|
||||
options (`modelDiscoverySuccessfulEmpty` / `isSuccessfulEmptyDiscovery`).
|
||||
A thrown or unavailable discovery keeps the control so #2246 failure UI can
|
||||
render, and must not heal/clear persisted model or effort. Full disclosure
|
||||
still shows the control when Custom model is available. Required-model
|
||||
harnesses always keep the field. Gate: `defaults hides model when optional
|
||||
harness has empty discovery` (and the failed-discovery counterpart) in
|
||||
`onboarding-agent-defaults.spec.ts`.
|
||||
|
||||
## The tests that enforce this
|
||||
|
||||
- `lib/agentConfigCore.test.mjs` — field model per harness × scope, clearing
|
||||
policy. Update when the capability model changes.
|
||||
- `ui/agentConfigFieldsContract.test.mjs` — canonical behaviors + disclosure
|
||||
presets + `shouldShowModelStatusMessage` status-bypass rule. If this fails,
|
||||
you probably reintroduced a per-surface flag or broke the status-bypass.
|
||||
presets + `shouldShowModelStatusMessage` status-bypass +
|
||||
`shouldRenderModelControl` (successful-empty omit vs failure keep). If this
|
||||
fails, you probably reintroduced a per-surface flag or conflated empty with
|
||||
failed discovery.
|
||||
- `ui/usePersonaModelDiscovery.test.mjs` — `synthesizeEmptyDiscoveryStatus`,
|
||||
`isCacheableDiscoveryResponse`, `deriveModelDiscoveryPending`. If the
|
||||
"reopen to retry" copy becomes inert again, these tests will catch it.
|
||||
`isCacheableDiscoveryResponse`, `deriveModelDiscoveryPending`,
|
||||
`isSuccessfulEmptyDiscovery`. If the "reopen to retry" copy becomes inert
|
||||
again, these tests will catch it.
|
||||
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
|
||||
acceptance coverage for readiness, failure states, defaults, navigation, and
|
||||
persistence races.
|
||||
acceptance coverage for readiness, failure states, defaults, navigation,
|
||||
successful-empty vs failed optional-model discovery, and persistence races.
|
||||
- Rust: `runtime_metadata_env_vars` tests pin spawn-time key application.
|
||||
|
||||
## Keep this file true
|
||||
|
||||
@@ -125,6 +125,41 @@ export function shouldShowModelStatusMessage(
|
||||
return showDescriptions || status !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the Model control should render given discovery state.
|
||||
*
|
||||
* Optional-model harnesses (Claude Code / Codex, `acpNative`) omit the control
|
||||
* while discovery is in flight and after a **confirmed successful empty**
|
||||
* catalog (IPC resolved, no usable options) — there is nothing useful to pick.
|
||||
* Discovery failures / unavailable runtimes keep the control so #2246 failure
|
||||
* UI can render. Full disclosure still shows the control when Custom model is
|
||||
* available. Required-model harnesses always render the control.
|
||||
*/
|
||||
export function shouldRenderModelControl({
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryLoading,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
modelIsOptional,
|
||||
showCustomModelOption,
|
||||
}: {
|
||||
discoveredModelOptions: readonly { id: string }[] | null;
|
||||
modelDiscoveryLoading: boolean;
|
||||
/** True only when discovery IPC resolved with a response that yielded no options. */
|
||||
modelDiscoverySuccessfulEmpty: boolean;
|
||||
modelIsOptional: boolean;
|
||||
showCustomModelOption: boolean;
|
||||
}): boolean {
|
||||
if (!modelIsOptional) return true;
|
||||
if (modelDiscoveryLoading) return false;
|
||||
const hasExplicitModel = (discoveredModelOptions ?? []).some(
|
||||
(option) => option.id.trim().length > 0,
|
||||
);
|
||||
if (hasExplicitModel) return true;
|
||||
if (showCustomModelOption) return true;
|
||||
// Omit only on confirmed successful empty — not on failure/unavailable.
|
||||
return !modelDiscoverySuccessfulEmpty;
|
||||
}
|
||||
|
||||
export type AgentConfigFieldsProps = {
|
||||
bakedEnv: BakedEnvEntry[];
|
||||
selectedRuntime: AcpRuntimeCatalogEntry | undefined;
|
||||
@@ -277,6 +312,7 @@ export function AgentConfigFields({
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryLoading,
|
||||
modelDiscoveryStatus,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
} = usePersonaModelDiscovery({
|
||||
envVars: config.env_vars,
|
||||
isCustomProviderEditing: isCustomProvider,
|
||||
@@ -285,6 +321,18 @@ export function AgentConfigFields({
|
||||
provider: providerForDiscovery,
|
||||
selectedRuntime,
|
||||
});
|
||||
const modelControlVisible = shouldRenderModelControl({
|
||||
discoveredModelOptions: dependentFieldsDisabled
|
||||
? null
|
||||
: discoveredModelOptions,
|
||||
modelDiscoveryLoading: dependentFieldsDisabled
|
||||
? false
|
||||
: modelDiscoveryLoading,
|
||||
modelDiscoverySuccessfulEmpty:
|
||||
!dependentFieldsDisabled && modelDiscoverySuccessfulEmpty,
|
||||
modelIsOptional,
|
||||
showCustomModelOption,
|
||||
});
|
||||
|
||||
// Mount-time healing policy: onboarding page 4 edits the root config during
|
||||
// first-run (no higher layers to inherit from), so acting on open is safe
|
||||
@@ -353,16 +401,23 @@ export function AgentConfigFields({
|
||||
// the old harness. In onboarding, heal that stale value as soon as the new
|
||||
// harness catalog proves it is unsupported; otherwise a Codex id like
|
||||
// `gpt-5.5[low]` appears as a Claude Code custom model.
|
||||
// Also clear when the Model control is omitted after a confirmed successful
|
||||
// empty catalog — never while discovery failed/unavailable (transient
|
||||
// failures must not erase saved model/effort).
|
||||
React.useEffect(() => {
|
||||
if (!healOnMount) return;
|
||||
const currentModel = (config.model ?? "").trim();
|
||||
if (currentModel.length === 0) return;
|
||||
if (modelDiscoveryLoading || discoveredModelOptions === null) return;
|
||||
if (
|
||||
discoveredModelOptions.some((option) => option.id.trim() === currentModel)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (modelDiscoveryLoading) return;
|
||||
|
||||
const catalogMiss =
|
||||
discoveredModelOptions !== null &&
|
||||
!discoveredModelOptions.some(
|
||||
(option) => option.id.trim() === currentModel,
|
||||
);
|
||||
const omittedAfterSuccessfulEmpty =
|
||||
modelIsOptional && !modelControlVisible && modelDiscoverySuccessfulEmpty;
|
||||
if (!catalogMiss && !omittedAfterSuccessfulEmpty) return;
|
||||
|
||||
const nextEnvVars = { ...config.env_vars };
|
||||
if (effortPersistenceKey) delete nextEnvVars[effortPersistenceKey];
|
||||
@@ -371,7 +426,10 @@ export function AgentConfigFields({
|
||||
}, [
|
||||
config,
|
||||
discoveredModelOptions,
|
||||
modelControlVisible,
|
||||
modelDiscoveryLoading,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
modelIsOptional,
|
||||
onConfigChange,
|
||||
onCustomModelEditingChange,
|
||||
healOnMount,
|
||||
@@ -659,53 +717,55 @@ export function AgentConfigFields({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Model field */}
|
||||
<div className={showDescriptions ? fieldClassName : undefined}>
|
||||
<AgentModelField
|
||||
allowDefaultModel={fallbackModel !== null}
|
||||
defaultModelLabel={
|
||||
fallbackModel ? `Default model (${fallbackModel})` : undefined
|
||||
}
|
||||
disableSelectDuringDiscovery={disableModelSelectDuringDiscovery}
|
||||
disabled={dependentFieldsDisabled}
|
||||
discoveredModelOptions={
|
||||
dependentFieldsDisabled ? null : discoveredModelOptions
|
||||
}
|
||||
globalModel={fallbackModel ?? undefined}
|
||||
id="global-agent-model"
|
||||
isCustomModelEditing={isCustomModelEditing}
|
||||
isRequired={
|
||||
showRequiredIndicators &&
|
||||
!modelIsOptional &&
|
||||
fallbackModel === null &&
|
||||
!dependentFieldsDisabled
|
||||
}
|
||||
keepSelectedModelValueLabel
|
||||
model={dependentFieldsDisabled ? "" : (config.model ?? "")}
|
||||
modelDiscoveryLoading={
|
||||
dependentFieldsDisabled ? false : modelDiscoveryLoading
|
||||
}
|
||||
modelDiscoveryStatus={
|
||||
dependentFieldsDisabled ? null : modelDiscoveryStatus
|
||||
}
|
||||
onIsCustomModelEditingChange={onCustomModelEditingChange}
|
||||
onModelChange={handleModelChange}
|
||||
placeholderClassName={placeholderClassName}
|
||||
placeholder="Select a model"
|
||||
provider={providerForDiscovery}
|
||||
fieldClassName={unstyled ? fieldClassName : undefined}
|
||||
labelClassName={fieldLabelClassName}
|
||||
selectClassName={selectClassName}
|
||||
showCustomModelOption={showCustomModelOption}
|
||||
showStatusMessage={shouldShowModelStatusMessage(
|
||||
showDescriptions,
|
||||
modelDiscoveryStatus,
|
||||
)}
|
||||
testId="global-agent-model"
|
||||
useCustomSelect={useCustomSelect}
|
||||
useChevronIcon={useChevronSelectIcon}
|
||||
/>
|
||||
</div>
|
||||
{/* Model field — omitted only after confirmed successful empty discovery */}
|
||||
{modelControlVisible ? (
|
||||
<div className={showDescriptions ? fieldClassName : undefined}>
|
||||
<AgentModelField
|
||||
allowDefaultModel={fallbackModel !== null}
|
||||
defaultModelLabel={
|
||||
fallbackModel ? `Default model (${fallbackModel})` : undefined
|
||||
}
|
||||
disableSelectDuringDiscovery={disableModelSelectDuringDiscovery}
|
||||
disabled={dependentFieldsDisabled}
|
||||
discoveredModelOptions={
|
||||
dependentFieldsDisabled ? null : discoveredModelOptions
|
||||
}
|
||||
globalModel={fallbackModel ?? undefined}
|
||||
id="global-agent-model"
|
||||
isCustomModelEditing={isCustomModelEditing}
|
||||
isRequired={
|
||||
showRequiredIndicators &&
|
||||
!modelIsOptional &&
|
||||
fallbackModel === null &&
|
||||
!dependentFieldsDisabled
|
||||
}
|
||||
keepSelectedModelValueLabel
|
||||
model={dependentFieldsDisabled ? "" : (config.model ?? "")}
|
||||
modelDiscoveryLoading={
|
||||
dependentFieldsDisabled ? false : modelDiscoveryLoading
|
||||
}
|
||||
modelDiscoveryStatus={
|
||||
dependentFieldsDisabled ? null : modelDiscoveryStatus
|
||||
}
|
||||
onIsCustomModelEditingChange={onCustomModelEditingChange}
|
||||
onModelChange={handleModelChange}
|
||||
placeholderClassName={placeholderClassName}
|
||||
placeholder="Select a model"
|
||||
provider={providerForDiscovery}
|
||||
fieldClassName={unstyled ? fieldClassName : undefined}
|
||||
labelClassName={fieldLabelClassName}
|
||||
selectClassName={selectClassName}
|
||||
showCustomModelOption={showCustomModelOption}
|
||||
showStatusMessage={shouldShowModelStatusMessage(
|
||||
showDescriptions,
|
||||
dependentFieldsDisabled ? null : modelDiscoveryStatus,
|
||||
)}
|
||||
testId="global-agent-model"
|
||||
useCustomSelect={useCustomSelect}
|
||||
useChevronIcon={useChevronSelectIcon}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Thinking / Effort */}
|
||||
{effortFieldVisible ? (
|
||||
|
||||
@@ -63,6 +63,7 @@ export function AgentDropdownSelect({
|
||||
ariaRequired,
|
||||
className,
|
||||
disabled = false,
|
||||
emptyOptionsLabel = "No options available",
|
||||
id,
|
||||
onValueChange,
|
||||
options,
|
||||
@@ -77,6 +78,8 @@ export function AgentDropdownSelect({
|
||||
ariaRequired?: boolean;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
/** Shown when the option list is empty (not a search filter miss). */
|
||||
emptyOptionsLabel?: string;
|
||||
id: string;
|
||||
onValueChange: (value: string) => void;
|
||||
options: readonly AgentDropdownOption[];
|
||||
@@ -184,8 +187,15 @@ export function AgentDropdownSelect({
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{showSearch && filteredOptions.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-foreground/55">No matches</p>
|
||||
{filteredOptions.length === 0 ? (
|
||||
<p
|
||||
className="px-3 py-2 text-sm text-foreground/55"
|
||||
data-testid={testId ? `${testId}-empty` : undefined}
|
||||
>
|
||||
{showSearch && query.trim().length > 0
|
||||
? "No matches"
|
||||
: emptyOptionsLabel}
|
||||
</p>
|
||||
) : null}
|
||||
{filteredOptions.map((option) => {
|
||||
const selected = option.value === value;
|
||||
@@ -470,6 +480,7 @@ export function AgentModelField({
|
||||
ariaRequired={isRequired}
|
||||
className={selectClassName}
|
||||
disabled={selectDisabled}
|
||||
emptyOptionsLabel="Couldn't load models"
|
||||
id={id}
|
||||
onValueChange={handleModelSelectChange}
|
||||
options={modelOptions}
|
||||
|
||||
@@ -21,6 +21,7 @@ import test from "node:test";
|
||||
import {
|
||||
CANONICAL_CONFIG_BEHAVIORS,
|
||||
resolveDisclosure,
|
||||
shouldRenderModelControl,
|
||||
shouldShowModelStatusMessage,
|
||||
} from "./AgentConfigFields.tsx";
|
||||
|
||||
@@ -83,3 +84,94 @@ test("shouldShowModelStatusMessage_onboardingPreset_warningStatus_showsMessage",
|
||||
};
|
||||
assert.equal(shouldShowModelStatusMessage(showDescriptions, warning), true);
|
||||
});
|
||||
|
||||
// ── shouldRenderModelControl ──────────────────────────────────────────────────
|
||||
// Optional-model harnesses omit the control only after a confirmed successful
|
||||
// empty catalog. Failures keep the control so #2246 status UI can render.
|
||||
|
||||
test("optional model control hides while loading", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: true,
|
||||
modelDiscoverySuccessfulEmpty: false,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
false,
|
||||
"optional + loading must hide the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("optional model control hides after successful empty discovery", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: true,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
false,
|
||||
"optional + successful empty + no custom must hide the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("optional model control stays visible on discovery failure", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: false,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
true,
|
||||
"optional + failed/unavailable discovery must keep the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("optional model control shows when explicit models are available", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: [
|
||||
{ id: "", label: "Default model" },
|
||||
{ id: "claude-sonnet-4", label: "Claude Sonnet 4" },
|
||||
],
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: false,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
true,
|
||||
"explicit models must show the control",
|
||||
);
|
||||
});
|
||||
|
||||
test("full disclosure keeps Custom escape hatch when discovery is empty", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: true,
|
||||
modelIsOptional: true,
|
||||
showCustomModelOption: true,
|
||||
}),
|
||||
true,
|
||||
"full disclosure keeps Custom escape hatch when discovery is empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("required-model harnesses always keep the control", () => {
|
||||
assert.equal(
|
||||
shouldRenderModelControl({
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryLoading: false,
|
||||
modelDiscoverySuccessfulEmpty: true,
|
||||
modelIsOptional: false,
|
||||
showCustomModelOption: false,
|
||||
}),
|
||||
true,
|
||||
"required-model harnesses always keep the control",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
deriveModelDiscoveryPending,
|
||||
getDiscoveredPersonaModelOptions,
|
||||
isCacheableDiscoveryResponse,
|
||||
isSuccessfulEmptyDiscovery,
|
||||
synthesizeEmptyDiscoveryStatus,
|
||||
} from "./usePersonaModelDiscovery.ts";
|
||||
|
||||
@@ -258,3 +259,56 @@ test("deriveModelDiscoveryPending_noKey_isNotPending", () => {
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ── isSuccessfulEmptyDiscovery ────────────────────────────────────────────────
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_resolvedEmptyResponse_isTrue", () => {
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: response({ models: [] }),
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryPending: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_thrownFailure_isFalse", () => {
|
||||
// Failure path leaves data null — must not be treated as successful empty.
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: null,
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryPending: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_withUsableModels_isFalse", () => {
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: response({
|
||||
models: [
|
||||
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", description: null },
|
||||
],
|
||||
}),
|
||||
discoveredModelOptions: [
|
||||
{ id: "claude-sonnet-5", label: "Claude Sonnet 5" },
|
||||
],
|
||||
modelDiscoveryPending: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isSuccessfulEmptyDiscovery_stillPending_isFalse", () => {
|
||||
assert.equal(
|
||||
isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData: null,
|
||||
discoveredModelOptions: null,
|
||||
modelDiscoveryPending: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -139,6 +139,28 @@ export function deriveModelDiscoveryPending({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when discovery IPC resolved with a response that yielded no usable
|
||||
* model options. Distinct from a thrown/unavailable failure (data stays null).
|
||||
* Callers that omit the Model control or heal persisted values must gate on
|
||||
* this — not on `discoveredModelOptions === null` alone.
|
||||
*/
|
||||
export function isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData,
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryPending,
|
||||
}: {
|
||||
activeModelDiscoveryData: AgentModelsResponse | null;
|
||||
discoveredModelOptions: readonly PersonaModelOption[] | null;
|
||||
modelDiscoveryPending: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
!modelDiscoveryPending &&
|
||||
activeModelDiscoveryData !== null &&
|
||||
discoveredModelOptions === null
|
||||
);
|
||||
}
|
||||
|
||||
export function usePersonaModelDiscovery({
|
||||
envVars,
|
||||
isCustomProviderEditing,
|
||||
@@ -358,6 +380,11 @@ export function usePersonaModelDiscovery({
|
||||
activeModelDiscoveryData,
|
||||
activeModelDiscoveryStatus,
|
||||
});
|
||||
const modelDiscoverySuccessfulEmpty = isSuccessfulEmptyDiscovery({
|
||||
activeModelDiscoveryData,
|
||||
discoveredModelOptions,
|
||||
modelDiscoveryPending,
|
||||
});
|
||||
|
||||
return {
|
||||
discoveredModelOptions,
|
||||
@@ -366,5 +393,6 @@ export function usePersonaModelDiscovery({
|
||||
modelDiscoveryPending || discoveredModelOptions !== null
|
||||
? null
|
||||
: activeModelDiscoveryStatus,
|
||||
modelDiscoverySuccessfulEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,6 +215,7 @@ export function useTeamActions(
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
description: team.description ?? "",
|
||||
instructions: team.instructions ?? undefined,
|
||||
personaIds: [...team.personaIds],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
canonicalChannelName,
|
||||
channelNamesMatch,
|
||||
} from "./canonicalChannelName.ts";
|
||||
|
||||
test("canonicalChannelName strips interleaved leading hashes and whitespace", () => {
|
||||
assert.equal(canonicalChannelName("channel"), "channel");
|
||||
assert.equal(canonicalChannelName("#channel"), "channel");
|
||||
assert.equal(canonicalChannelName(" ### channel "), "channel");
|
||||
assert.equal(canonicalChannelName("# #"), "");
|
||||
assert.equal(canonicalChannelName("### ###"), "");
|
||||
assert.equal(canonicalChannelName("channel#topic"), "channel#topic");
|
||||
});
|
||||
|
||||
test("channelNamesMatch canonicalizes both legacy names and search input", () => {
|
||||
assert.equal(channelNamesMatch("#general", "general"), true);
|
||||
assert.equal(channelNamesMatch("general", " #GENERAL "), true);
|
||||
assert.equal(channelNamesMatch("#random", "general"), false);
|
||||
});
|
||||
14
desktop/src/features/channels/lib/canonicalChannelName.ts
Normal file
14
desktop/src/features/channels/lib/canonicalChannelName.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Returns the stored form of a channel name after removing the display prefix.
|
||||
* Keep this aligned with `buzz_core::channel::canonical_channel_name`.
|
||||
*/
|
||||
export function canonicalChannelName(name: string): string {
|
||||
return name.replace(/^[#\s]+/u, "").trimEnd();
|
||||
}
|
||||
|
||||
export function channelNamesMatch(left: string, right: string): boolean {
|
||||
return (
|
||||
canonicalChannelName(left).toLowerCase() ===
|
||||
canonicalChannelName(right).toLowerCase()
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import {
|
||||
canonicalChannelName,
|
||||
channelNamesMatch,
|
||||
} from "@/features/channels/lib/canonicalChannelName";
|
||||
import { scoreChannelMatch } from "@/features/channels/lib/channelSearchScore";
|
||||
import {
|
||||
type ChannelSortMode,
|
||||
@@ -127,8 +131,9 @@ export function ChannelBrowserDialog({
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const deferredQuery = React.useDeferredValue(query.trim().toLowerCase());
|
||||
const trimmedQuery = query.trim();
|
||||
const canonicalQuery = canonicalChannelName(query);
|
||||
const deferredQuery = React.useDeferredValue(canonicalQuery.toLowerCase());
|
||||
const trimmedQuery = canonicalQuery;
|
||||
// Immediate (non-deferred) lowercased query. The create row's visibility
|
||||
// (via hasExactMatch) and its label both read from the live query so they
|
||||
// can never disagree for a frame while the fuzzy filter catches up.
|
||||
@@ -244,7 +249,7 @@ export function ChannelBrowserDialog({
|
||||
channels.some(
|
||||
(channel) =>
|
||||
channel.channelType !== "dm" &&
|
||||
channel.name.toLowerCase() === normalizedQuery &&
|
||||
channelNamesMatch(channel.name, normalizedQuery) &&
|
||||
(channelTypeFilter
|
||||
? channel.channelType === channelTypeFilter
|
||||
: true),
|
||||
|
||||
110
desktop/src/features/communities/applyCommunitiesOrder.test.mjs
Normal file
110
desktop/src/features/communities/applyCommunitiesOrder.test.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Unit tests for applyCommunitiesOrder — the pure permutation helper that
|
||||
* drives community-rail drag-to-reorder.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import { applyCommunitiesOrder } from "./useCommunities.tsx";
|
||||
|
||||
const A = {
|
||||
id: "ws-a",
|
||||
name: "Alpha",
|
||||
relayUrl: "wss://a.example.com",
|
||||
addedAt: "2024-01-01",
|
||||
};
|
||||
const B = {
|
||||
id: "ws-b",
|
||||
name: "Bravo",
|
||||
relayUrl: "wss://b.example.com",
|
||||
addedAt: "2024-01-02",
|
||||
};
|
||||
const C = {
|
||||
id: "ws-c",
|
||||
name: "Charlie",
|
||||
relayUrl: "wss://c.example.com",
|
||||
addedAt: "2024-01-03",
|
||||
};
|
||||
|
||||
describe("applyCommunitiesOrder", () => {
|
||||
it("reorders communities to match orderedIds", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-a", "ws-b"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-c", "ws-a", "ws-b"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns same order when orderedIds matches current order", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-a", "ws-b", "ws-c"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("appends communities not mentioned in orderedIds at the end in original relative order", () => {
|
||||
// C was added after drag — not in orderedIds — should tail-append
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles orderedIds that contain stale IDs not present in communities", () => {
|
||||
// "ws-gone" is a stale id — silent skip, no crash
|
||||
const result = applyCommunitiesOrder([A, B], ["ws-b", "ws-gone", "ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a"],
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the full list when orderedIds is empty — original order preserved", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], []);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles a single-element list (no-op reorder)", () => {
|
||||
const result = applyCommunitiesOrder([A], ["ws-a"]);
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-a"],
|
||||
);
|
||||
});
|
||||
|
||||
it("handles an empty communities list", () => {
|
||||
const result = applyCommunitiesOrder([], ["ws-a", "ws-b"]);
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
it("does not mutate the original array", () => {
|
||||
const original = [A, B, C];
|
||||
applyCommunitiesOrder(original, ["ws-c", "ws-a", "ws-b"]);
|
||||
assert.deepEqual(
|
||||
original.map((c) => c.id),
|
||||
["ws-a", "ws-b", "ws-c"],
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves object identity of each community (no clone)", () => {
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-c", "ws-b", "ws-a"]);
|
||||
assert.equal(result[0], C);
|
||||
assert.equal(result[1], B);
|
||||
assert.equal(result[2], A);
|
||||
});
|
||||
|
||||
it("handles duplicate IDs in orderedIds — first occurrence wins", () => {
|
||||
// Defensive: dnd-kit should never produce duplicates, but guard anyway.
|
||||
const result = applyCommunitiesOrder([A, B, C], ["ws-b", "ws-b", "ws-a"]);
|
||||
// ws-b appears once, ws-a once, ws-c appended
|
||||
assert.deepEqual(
|
||||
result.map((c) => c.id),
|
||||
["ws-b", "ws-a", "ws-c"],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,42 @@ export function resolveCommunityUpdateResult(
|
||||
return { kind: "updated", requiresReinit: backendFieldsChanged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Permute `communities` so that its order matches `orderedIds`.
|
||||
*
|
||||
* - Communities whose id appears in `orderedIds` are placed first, in the
|
||||
* order given by `orderedIds`.
|
||||
* - Communities not mentioned in `orderedIds` (e.g. added after the drag
|
||||
* completed) are appended at the end in their original relative order.
|
||||
*
|
||||
* Pure and side-effect-free — extracted so it can be unit-tested without
|
||||
* a DOM or React.
|
||||
*/
|
||||
export function applyCommunitiesOrder(
|
||||
communities: Community[],
|
||||
orderedIds: string[],
|
||||
): Community[] {
|
||||
const byId = new Map(communities.map((c) => [c.id, c]));
|
||||
const seen = new Set<string>();
|
||||
const reordered: Community[] = [];
|
||||
|
||||
for (const id of orderedIds) {
|
||||
const c = byId.get(id);
|
||||
if (c && !seen.has(id)) {
|
||||
reordered.push(c);
|
||||
seen.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
for (const c of communities) {
|
||||
if (!seen.has(c.id)) {
|
||||
reordered.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
return reordered;
|
||||
}
|
||||
|
||||
export type UseCommunitiesReturn = {
|
||||
communities: Community[];
|
||||
activeCommunity: Community | null;
|
||||
@@ -90,6 +126,8 @@ export type UseCommunitiesReturn = {
|
||||
Pick<Community, "name" | "relayUrl" | "token" | "pubkey" | "reposDir">
|
||||
>,
|
||||
) => UpdateCommunityResult;
|
||||
/** Persist a new display order for the rail. IDs not in orderedIds keep their relative position at the end. */
|
||||
reorderCommunities: (orderedIds: string[]) => void;
|
||||
};
|
||||
|
||||
const CommunitiesContext = createContext<UseCommunitiesReturn | null>(null);
|
||||
@@ -242,6 +280,14 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
[activeId],
|
||||
);
|
||||
|
||||
const reorderCommunities = useCallback((orderedIds: string[]) => {
|
||||
setCommunitiesState((prev) => {
|
||||
const next = applyCommunitiesOrder(prev, orderedIds);
|
||||
saveCommunities(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
communities,
|
||||
activeCommunity,
|
||||
@@ -252,5 +298,6 @@ function useCommunitiesInternal(): UseCommunitiesReturn {
|
||||
switchCommunity,
|
||||
reconnectCommunity,
|
||||
updateCommunity,
|
||||
reorderCommunities,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,7 +215,14 @@ export function MachineOnboardingFlow({
|
||||
back: () =>
|
||||
setPage(identityWasImported ? "key-import" : "backup"),
|
||||
next: (runtimeIds) => {
|
||||
setReadyRuntimeIds(Array.from(runtimeIds));
|
||||
const ids = Array.from(runtimeIds);
|
||||
setReadyRuntimeIds(ids);
|
||||
// Harness install can fail (Windows/PATH/network). Don't soft-lock
|
||||
// onboarding — users can finish setup later in Settings → Agents.
|
||||
if (ids.length === 0) {
|
||||
complete(selectedPubkey ?? undefined);
|
||||
return;
|
||||
}
|
||||
setPage("config");
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -687,6 +687,16 @@ function SetupStepContent({
|
||||
Next
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
data-testid="onboarding-setup-skip"
|
||||
onClick={() => actions.next([])}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="h-9 rounded-full bg-foreground/10 px-6 text-sm hover:bg-foreground/15"
|
||||
data-testid="onboarding-back"
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { CheckCheck, Link2, Plus, Settings2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
@@ -33,6 +50,7 @@ type CommunityRailProps = {
|
||||
updates: Partial<Pick<Community, "name" | "relayUrl" | "token">>,
|
||||
) => void;
|
||||
onRemoveCommunity: (id: string) => void;
|
||||
onReorderCommunities: (orderedIds: string[]) => void;
|
||||
};
|
||||
|
||||
const MAX_BADGE = 99;
|
||||
@@ -76,6 +94,9 @@ function CommunityButton({
|
||||
iconUrl,
|
||||
onSwitch,
|
||||
menu,
|
||||
dragListeners,
|
||||
dragAttributes,
|
||||
isDragging,
|
||||
}: {
|
||||
community: Community;
|
||||
isActive: boolean;
|
||||
@@ -83,6 +104,9 @@ function CommunityButton({
|
||||
iconUrl: string | null;
|
||||
onSwitch: () => void;
|
||||
menu: React.ReactNode;
|
||||
dragListeners?: React.HTMLAttributes<HTMLElement>;
|
||||
dragAttributes?: React.HTMLAttributes<HTMLElement>;
|
||||
isDragging?: boolean;
|
||||
}) {
|
||||
const { mentionCount, showBadge, showDot, pending, badgeLabel } =
|
||||
communityRailIndicators(unread);
|
||||
@@ -101,10 +125,15 @@ function CommunityButton({
|
||||
<button
|
||||
aria-current={isActive ? "true" : undefined}
|
||||
aria-label={tooltipLabel}
|
||||
className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
|
||||
className={cn(
|
||||
"relative flex h-9 w-9 items-center justify-center touch-none outline-hidden focus:outline-none focus-visible:outline-none",
|
||||
isDragging && "opacity-30",
|
||||
)}
|
||||
data-testid={`community-rail-button-${community.id}`}
|
||||
onClick={onSwitch}
|
||||
type="button"
|
||||
{...dragAttributes}
|
||||
{...dragListeners}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -154,6 +183,105 @@ function CommunityButton({
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityDragOverlay({
|
||||
community,
|
||||
iconUrl,
|
||||
}: {
|
||||
community: Community;
|
||||
iconUrl: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex h-9 w-9 cursor-grabbing items-center justify-center overflow-hidden rounded-xl bg-primary text-xs font-semibold text-primary-foreground opacity-90 shadow-lg ring-1 ring-sidebar-border"
|
||||
data-buzz-flat
|
||||
>
|
||||
{iconUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
draggable={false}
|
||||
src={iconUrl}
|
||||
/>
|
||||
) : (
|
||||
getInitials(community.name) || "🐝"
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableCommunityButton({
|
||||
community,
|
||||
activeCommunityId,
|
||||
iconsByCommunity,
|
||||
unreadByCommunity,
|
||||
onSwitchCommunity,
|
||||
onMarkAllRead,
|
||||
onSetEditingCommunity,
|
||||
}: {
|
||||
community: Community;
|
||||
activeCommunityId: string | null;
|
||||
iconsByCommunity: Record<string, string | null | undefined>;
|
||||
unreadByCommunity: Record<string, CommunityUnreadState>;
|
||||
onSwitchCommunity: (id: string) => void;
|
||||
onMarkAllRead: (community: Community) => void;
|
||||
onSetEditingCommunity: (community: Community) => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: community.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<CommunityButton
|
||||
community={community}
|
||||
dragAttributes={attributes}
|
||||
dragListeners={listeners}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
isActive={community.id === activeCommunityId}
|
||||
isDragging={isDragging}
|
||||
menu={
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onMarkAllRead(community)}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
void writeTextToClipboard(community.relayUrl);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Copy relay URL
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onSetEditingCommunity(community)}>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Community settings
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
}
|
||||
onSwitch={() => onSwitchCommunity(community.id)}
|
||||
unread={
|
||||
unreadByCommunity[community.id] ?? {
|
||||
hasUnread: false,
|
||||
state: "unknown",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discord/Slack-style vertical rail of communities on the far left of the app.
|
||||
* Shows a mention-count badge for inactive communities (observed via
|
||||
@@ -169,6 +297,7 @@ export function CommunityRail({
|
||||
onAddCommunity,
|
||||
onUpdateCommunity,
|
||||
onRemoveCommunity,
|
||||
onReorderCommunities,
|
||||
}: CommunityRailProps) {
|
||||
const { unreadByCommunity, markCommunityRead } = useCommunityUnread(
|
||||
communities,
|
||||
@@ -179,10 +308,39 @@ export function CommunityRail({
|
||||
const { markAllChannelsRead } = useAppShell();
|
||||
const [editingCommunity, setEditingCommunity] =
|
||||
React.useState<Community | null>(null);
|
||||
const [draggingId, setDraggingId] = React.useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
if (communities.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const communityIds = communities.map((c) => c.id);
|
||||
const draggingCommunity = draggingId
|
||||
? (communities.find((c) => c.id === draggingId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setDraggingId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setDraggingId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIdx = communityIds.indexOf(active.id as string);
|
||||
const newIdx = communityIds.indexOf(over.id as string);
|
||||
if (oldIdx !== -1 && newIdx !== -1) {
|
||||
onReorderCommunities(arrayMove(communityIds, oldIdx, newIdx));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkAllRead = (community: Community) => {
|
||||
if (community.id === activeCommunityId) {
|
||||
markAllChannelsRead();
|
||||
@@ -211,42 +369,37 @@ export function CommunityRail({
|
||||
)}
|
||||
data-testid="community-rail"
|
||||
>
|
||||
{communities.map((community) => (
|
||||
<CommunityButton
|
||||
key={community.id}
|
||||
iconUrl={iconsByCommunity[community.id] ?? null}
|
||||
isActive={community.id === activeCommunityId}
|
||||
menu={
|
||||
<>
|
||||
<ContextMenuItem onClick={() => handleMarkAllRead(community)}>
|
||||
<CheckCheck className="h-4 w-4" />
|
||||
Mark all as read
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
void writeTextToClipboard(community.relayUrl);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-4 w-4" />
|
||||
Copy relay URL
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => setEditingCommunity(community)}>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
Community settings
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
}
|
||||
onSwitch={() => onSwitchCommunity(community.id)}
|
||||
unread={
|
||||
unreadByCommunity[community.id] ?? {
|
||||
hasUnread: false,
|
||||
state: "unknown",
|
||||
}
|
||||
}
|
||||
community={community}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
sensors={sensors}
|
||||
>
|
||||
<SortableContext
|
||||
items={communityIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{communities.map((community) => (
|
||||
<SortableCommunityButton
|
||||
key={community.id}
|
||||
activeCommunityId={activeCommunityId}
|
||||
community={community}
|
||||
iconsByCommunity={iconsByCommunity}
|
||||
unreadByCommunity={unreadByCommunity}
|
||||
onMarkAllRead={handleMarkAllRead}
|
||||
onSetEditingCommunity={setEditingCommunity}
|
||||
onSwitchCommunity={onSwitchCommunity}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay>
|
||||
{draggingCommunity ? (
|
||||
<CommunityDragOverlay
|
||||
community={draggingCommunity}
|
||||
iconUrl={iconsByCommunity[draggingCommunity.id] ?? null}
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
|
||||
@@ -393,6 +393,25 @@ type E2eConfig = {
|
||||
* spec can interleave edits and exercise the mid-save race handling.
|
||||
*/
|
||||
globalConfigSaveDelayMs?: number;
|
||||
/**
|
||||
* Override the `discover_agent_models` mock response. When set, returns
|
||||
* this catalog instead of the default per-harness model list.
|
||||
*/
|
||||
discoverAgentModels?: {
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
description?: string | null;
|
||||
}>;
|
||||
supportsSwitching: boolean;
|
||||
agentDefaultModel?: string | null;
|
||||
selectedModel?: string | null;
|
||||
};
|
||||
/**
|
||||
* When set, `discover_agent_models` throws with this message instead of
|
||||
* returning a catalog.
|
||||
*/
|
||||
discoverAgentModelsError?: string;
|
||||
};
|
||||
relayHttpUrl?: string;
|
||||
relayWsUrl?: string;
|
||||
@@ -10136,6 +10155,25 @@ export function maybeInstallE2eTauriMocks() {
|
||||
supportsSwitching: false,
|
||||
};
|
||||
case "discover_agent_models": {
|
||||
const discoverError = activeConfig?.mock?.discoverAgentModelsError;
|
||||
if (discoverError) {
|
||||
throw new Error(discoverError);
|
||||
}
|
||||
const discoverOverride = activeConfig?.mock?.discoverAgentModels;
|
||||
if (discoverOverride) {
|
||||
return {
|
||||
agentName: "mock-agent",
|
||||
agentVersion: "0.0.0",
|
||||
models: discoverOverride.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
description: model.description ?? null,
|
||||
})),
|
||||
agentDefaultModel: discoverOverride.agentDefaultModel ?? null,
|
||||
selectedModel: discoverOverride.selectedModel ?? null,
|
||||
supportsSwitching: discoverOverride.supportsSwitching,
|
||||
};
|
||||
}
|
||||
const input = (
|
||||
payload as {
|
||||
input?: { agentCommand?: string; provider?: string };
|
||||
|
||||
@@ -216,4 +216,148 @@ test.describe("community rail", () => {
|
||||
expect(toggleBox).not.toBeNull();
|
||||
expect(toggleBox?.x ?? 0).toBeLessThan(120);
|
||||
});
|
||||
|
||||
test("drag-to-reorder updates the stored community order and survives reload", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
// Seed only if not already set so the persisted order survives page.reload().
|
||||
await page.addInitScript(
|
||||
({ list, active }) => {
|
||||
if (!window.localStorage.getItem("buzz-communities")) {
|
||||
window.localStorage.setItem("buzz-communities", JSON.stringify(list));
|
||||
}
|
||||
if (!window.localStorage.getItem("buzz-active-community-id")) {
|
||||
window.localStorage.setItem("buzz-active-community-id", active);
|
||||
}
|
||||
},
|
||||
{ list: [COMMUNITY_A, COMMUNITY_B], active: COMMUNITY_A.id },
|
||||
);
|
||||
await page.goto("/");
|
||||
|
||||
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
||||
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
||||
await expect(buttonA).toBeVisible();
|
||||
await expect(buttonB).toBeVisible();
|
||||
|
||||
// Drag B (lower) up over A (higher) so the order becomes [B, A].
|
||||
const boxA = await buttonA.boundingBox();
|
||||
const boxB = await buttonB.boundingBox();
|
||||
if (!boxA || !boxB) throw new Error("community buttons not laid out");
|
||||
|
||||
const startX = boxB.x + boxB.width / 2;
|
||||
const startY = boxB.y + boxB.height / 2;
|
||||
const targetY = boxA.y + boxA.height / 2;
|
||||
|
||||
// dnd-kit PointerSensor requires a 6px activation distance before it picks
|
||||
// up the drag. Move in small steps so pointermove events fire on every pixel.
|
||||
await page.mouse.move(startX, startY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(startX, startY - 3, { steps: 3 });
|
||||
await page.mouse.move(startX, targetY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
// The community list in localStorage must now be [B, A].
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("buzz-communities");
|
||||
if (!raw) return null;
|
||||
const list = JSON.parse(raw) as Array<{ id: string }>;
|
||||
return list.map((c) => c.id);
|
||||
}),
|
||||
)
|
||||
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
||||
|
||||
// Verify the new order is also reflected in the rendered DOM — B button
|
||||
// must appear above A button.
|
||||
const newBoxA = await buttonA.boundingBox();
|
||||
const newBoxB = await buttonB.boundingBox();
|
||||
if (!newBoxA || !newBoxB)
|
||||
throw new Error("community buttons not laid out after drag");
|
||||
expect(newBoxB.y).toBeLessThan(newBoxA.y);
|
||||
|
||||
// Reload and confirm the order survives restart: addInitScript is
|
||||
// conditional (no-op when data already exists), so the dragged [B, A]
|
||||
// order is what React reads on boot.
|
||||
await page.reload();
|
||||
await expect(page.getByTestId("community-rail")).toBeVisible();
|
||||
|
||||
// Storage must still be [B, A] after reload.
|
||||
const storedOrder = await page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("buzz-communities");
|
||||
if (!raw) return null;
|
||||
const list = JSON.parse(raw) as Array<{ id: string }>;
|
||||
return list.map((c) => c.id);
|
||||
});
|
||||
expect(storedOrder).toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
||||
|
||||
// DOM order must also be [B, A] after reload.
|
||||
const reloadBoxA = await buttonA.boundingBox();
|
||||
const reloadBoxB = await buttonB.boundingBox();
|
||||
if (!reloadBoxA || !reloadBoxB)
|
||||
throw new Error("community buttons not laid out after reload");
|
||||
expect(reloadBoxB.y).toBeLessThan(reloadBoxA.y);
|
||||
});
|
||||
|
||||
test("keyboard reorder: Space to pick up, ArrowUp to move, Space to drop updates stored order", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, undefined, { skipCommunitySeed: true });
|
||||
await seedCommunities(page, [COMMUNITY_A, COMMUNITY_B], COMMUNITY_A.id);
|
||||
await page.goto("/");
|
||||
|
||||
const buttonA = page.getByTestId(`community-rail-button-${COMMUNITY_A.id}`);
|
||||
const buttonB = page.getByTestId(`community-rail-button-${COMMUNITY_B.id}`);
|
||||
await expect(buttonA).toBeVisible();
|
||||
await expect(buttonB).toBeVisible();
|
||||
|
||||
// Focus B (the second/lower item) and use keyboard to move it above A.
|
||||
// Note: page.keyboard.press("Space") fires the button's native click on this
|
||||
// Chromium build even when React's onKeyDown calls preventDefault — a CDP
|
||||
// input-injection quirk. The synthetic dispatch below goes directly through
|
||||
// React's event system where preventDefault correctly suppresses the click,
|
||||
// while still exercising the real KeyboardSensor path (Thufir verified the
|
||||
// test fails when KeyboardSensor is removed).
|
||||
await buttonB.focus();
|
||||
await page.evaluate((testId) => {
|
||||
const el = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!el) throw new Error(`button not found: ${testId}`);
|
||||
el.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: " ",
|
||||
code: "Space",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, `community-rail-button-${COMMUNITY_B.id}`);
|
||||
// ArrowUp moves the active item one slot up.
|
||||
await page.keyboard.press("ArrowUp");
|
||||
// Space drops the item — same synthetic dispatch for consistency.
|
||||
await page.evaluate((testId) => {
|
||||
const el = document.querySelector(`[data-testid="${testId}"]`);
|
||||
if (!el) throw new Error(`button not found: ${testId}`);
|
||||
el.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: " ",
|
||||
code: "Space",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}, `community-rail-button-${COMMUNITY_B.id}`);
|
||||
|
||||
// The community list in localStorage must now be [B, A].
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const raw = window.localStorage.getItem("buzz-communities");
|
||||
if (!raw) return null;
|
||||
const list = JSON.parse(raw) as Array<{ id: string }>;
|
||||
return list.map((c) => c.id);
|
||||
}),
|
||||
)
|
||||
.toEqual([COMMUNITY_B.id, COMMUNITY_A.id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -431,6 +431,76 @@ test("defaults renders only fields supported by the selected harness", async ({
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("defaults hides model when optional harness has empty discovery", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
acpRuntimesCatalog: [
|
||||
runtime("claude", "available", { status: "logged_in" }),
|
||||
],
|
||||
discoverAgentModels: {
|
||||
models: [],
|
||||
supportsSwitching: false,
|
||||
},
|
||||
globalAgentConfig: {
|
||||
env_vars: {},
|
||||
provider: null,
|
||||
model: null,
|
||||
preferred_runtime: null,
|
||||
},
|
||||
},
|
||||
{ skipCommunitySeed: true, skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
await navigateToSetupPage(page);
|
||||
await page.getByTestId("onboarding-setup-next").click();
|
||||
|
||||
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
|
||||
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
|
||||
"Claude Code",
|
||||
);
|
||||
// Confirmed successful empty catalog — omit the Model control; harness
|
||||
// default applies and Finish stays available.
|
||||
await expect(page.getByTestId("global-agent-model")).toHaveCount(0);
|
||||
await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("defaults keeps model control when optional harness discovery fails", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(
|
||||
page,
|
||||
{
|
||||
acpRuntimesCatalog: [
|
||||
runtime("claude", "available", { status: "logged_in" }),
|
||||
],
|
||||
discoverAgentModelsError: "CLI discovery timed out",
|
||||
globalAgentConfig: {
|
||||
env_vars: {},
|
||||
provider: null,
|
||||
model: null,
|
||||
preferred_runtime: null,
|
||||
},
|
||||
},
|
||||
{ skipCommunitySeed: true, skipOnboardingSeed: true },
|
||||
);
|
||||
await page.goto("/");
|
||||
await navigateToSetupPage(page);
|
||||
await page.getByTestId("onboarding-setup-next").click();
|
||||
|
||||
await expect(page.getByTestId("onboarding-page-config")).toBeVisible();
|
||||
await expect(page.getByTestId("global-agent-default-harness")).toHaveText(
|
||||
"Claude Code",
|
||||
);
|
||||
// Failed discovery must not look like successful empty: keep the control
|
||||
// and surface #2246 failure UI (status line bypasses onboarding-essential).
|
||||
await expect(page.getByTestId("global-agent-model")).toBeVisible();
|
||||
await expect(page.getByText(/Could not load live models/i)).toBeVisible();
|
||||
await expect(page.getByTestId("onboarding-finish")).toBeEnabled();
|
||||
});
|
||||
|
||||
test("defaults Back returns to harness setup", async ({ page }) => {
|
||||
await installMockBridge(
|
||||
page,
|
||||
|
||||
@@ -431,6 +431,27 @@ type MockBridgeOptions = {
|
||||
* test can interleave edits and exercise the mid-save race handling.
|
||||
*/
|
||||
globalConfigSaveDelayMs?: number;
|
||||
/**
|
||||
* Override the `discover_agent_models` mock response. When set, the bridge
|
||||
* returns this catalog instead of the default per-harness model list.
|
||||
* Use `{ models: [], supportsSwitching: false }` to exercise empty discovery
|
||||
* (e.g. optional harnesses that omit the Model control).
|
||||
*/
|
||||
discoverAgentModels?: {
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string | null;
|
||||
description?: string | null;
|
||||
}>;
|
||||
supportsSwitching: boolean;
|
||||
agentDefaultModel?: string | null;
|
||||
selectedModel?: string | null;
|
||||
};
|
||||
/**
|
||||
* When set, `discover_agent_models` throws with this message instead of
|
||||
* returning a catalog. Exercises the discovery-failure UI path.
|
||||
*/
|
||||
discoverAgentModelsError?: string;
|
||||
};
|
||||
|
||||
type BridgeOptions = {
|
||||
|
||||
Reference in New Issue
Block a user