diff --git a/crates/buzz-cli/src/commands/agents.rs b/crates/buzz-cli/src/commands/agents.rs
index 58564a45c2f..fcdee150609 100644
--- a/crates/buzz-cli/src/commands/agents.rs
+++ b/crates/buzz-cli/src/commands/agents.rs
@@ -93,7 +93,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
} => {
validate_hex64(&target_pubkey)?;
let signer_hex = client.keys().public_key().to_hex();
- let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?;
+ let auth =
+ resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?;
let builder = build_archive_identity_request(
&target_pubkey,
&content,
@@ -124,7 +125,8 @@ pub async fn dispatch(command: AgentsCmd, client: &BuzzClient) -> Result<(), Cli
} => {
validate_hex64(&target_pubkey)?;
let signer_hex = client.keys().public_key().to_hex();
- let auth = resolve_auth(client, &target_pubkey, &signer_hex).await?;
+ let auth =
+ resolve_auth(client, &target_pubkey, &signer_hex, &mut std::io::stderr()).await?;
let builder = build_unarchive_identity_request(
&target_pubkey,
&content,
@@ -160,19 +162,175 @@ fn require_owner(client: &BuzzClient) -> Result {
PublicKey::parse(&hex).map_err(|e| CliError::Auth(format!("invalid owner attestation: {e}")))
}
+/// Typed reason why NIP-OA owner-auth could not be extracted from a kind:0.
+///
+/// Produced by [`classify_owner_auth_tag`] and formatted into a JSON warning
+/// by [`resolve_auth`]. One variant per distinguishable failure cause so the
+/// diagnostic is always accurate and never duplicates validation logic.
+#[derive(Debug, PartialEq)]
+enum AuthFailure {
+ /// kind:0 has no `tags` array or the array is empty of `auth`-labelled entries.
+ NoAuthTag,
+ /// kind:0 has more than one `auth`-labelled tag; count included.
+ AmbiguousAuthTag(usize),
+ /// Sole `auth` tag has wrong element count; actual count included.
+ WrongArity(usize),
+ /// Sole `auth` tag contains a non-string element.
+ NonStringElement,
+ /// Sole `auth` tag owner field is not a valid 64-hex pubkey; value included.
+ InvalidOwnerHex(String),
+ /// Sole `auth` tag sig field is not a valid 128-hex signature.
+ InvalidSigHex,
+ /// Tag is structurally valid but names a different owner; actual owner included.
+ OwnerMismatch(String),
+}
+
+impl AuthFailure {
+ /// Human-readable description suitable for the `"warning"` JSON field.
+ fn message(&self) -> String {
+ match self {
+ AuthFailure::NoAuthTag => "target kind:0 has no \"auth\" tag".to_owned(),
+ AuthFailure::AmbiguousAuthTag(n) => format!(
+ "target kind:0 has {n} \"auth\" tags (expected exactly 1) — ambiguous ownership"
+ ),
+ AuthFailure::WrongArity(n) => format!(
+ "sole \"auth\" tag has {n} element(s) (expected 4: label, owner, conditions, sig)"
+ ),
+ AuthFailure::NonStringElement => {
+ "sole \"auth\" tag contains a non-string element".to_owned()
+ }
+ AuthFailure::InvalidOwnerHex(v) => {
+ format!("sole \"auth\" tag owner field is not a valid 64-hex pubkey: {v}")
+ }
+ AuthFailure::InvalidSigHex => {
+ "sole \"auth\" tag sig field is not a valid 128-hex signature".to_owned()
+ }
+ AuthFailure::OwnerMismatch(actual) => {
+ format!("sole \"auth\" tag names owner {actual} which does not match your key")
+ }
+ }
+ }
+}
+
+/// Single classifier: either extract the auth tag or return the typed reason
+/// for failure. [`extract_owner_auth_tag`] is a thin `.ok()` wrapper kept for
+/// the existing tests that assert on `Option`.
+fn classify_owner_auth_tag(
+ tags: &[serde_json::Value],
+ signer_hex: &str,
+) -> Result<[String; 4], AuthFailure> {
+ let auth_tags: Vec<&serde_json::Value> = tags
+ .iter()
+ .filter(|tag| {
+ tag.as_array()
+ .and_then(|elems| elems.first())
+ .and_then(|v| v.as_str())
+ == Some("auth")
+ })
+ .collect();
+ match auth_tags.len() {
+ 0 => return Err(AuthFailure::NoAuthTag),
+ n if n > 1 => return Err(AuthFailure::AmbiguousAuthTag(n)),
+ _ => {}
+ }
+
+ // Exactly one auth tag.
+ let elems = auth_tags[0]
+ .as_array()
+ .ok_or(AuthFailure::NonStringElement)?;
+ if elems.len() != 4 {
+ return Err(AuthFailure::WrongArity(elems.len()));
+ }
+ let label = elems[0].as_str().ok_or(AuthFailure::NonStringElement)?;
+ let owner = elems[1].as_str().ok_or(AuthFailure::NonStringElement)?;
+ let conditions = elems[2].as_str().ok_or(AuthFailure::NonStringElement)?;
+ let sig = elems[3].as_str().ok_or(AuthFailure::NonStringElement)?;
+ if owner.len() != 64 || !owner.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Err(AuthFailure::InvalidOwnerHex(owner.to_owned()));
+ }
+ if sig.len() != 128 || !sig.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Err(AuthFailure::InvalidSigHex);
+ }
+ if !owner.eq_ignore_ascii_case(signer_hex) {
+ return Err(AuthFailure::OwnerMismatch(owner.to_owned()));
+ }
+ Ok([
+ label.to_owned(),
+ owner.to_owned(),
+ conditions.to_owned(),
+ sig.to_owned(),
+ ])
+}
+
+/// Pure sync core of auth resolution: given a fetched kind:0 profile (or
+/// `None` when no event was found), either return the extracted auth tag or
+/// emit one `{"warning":"..."}` JSON line to `warn_sink` and return `None`.
+///
+/// Separated from [`resolve_auth`] so unit tests can call this directly with
+/// a `Vec` sink and assert on exactly what hits the wire — without needing
+/// a live `BuzzClient` or async runtime.
+///
+/// Three warning branches, one success path:
+/// 1. `profile == None` → no kind:0 found for target.
+/// 2. `profile.get("tags")` absent or non-array → no tags array.
+/// 3. [`classify_owner_auth_tag`] returns `Err` → typed failure reason.
+/// 4. `classify_owner_auth_tag` returns `Ok` → `Some(tag)`, no warning.
+fn resolve_auth_from_profile(
+ profile: Option<&serde_json::Value>,
+ target_hex: &str,
+ signer_hex: &str,
+ warn_sink: &mut dyn std::io::Write,
+) -> Option<[String; 4]> {
+ let event = match profile {
+ Some(e) => e,
+ None => {
+ let msg = format!(
+ "no kind:0 profile found for target {target_hex}; \
+ proceeding without owner attestation — this succeeds only if your key is a relay admin"
+ );
+ let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg}));
+ return None;
+ }
+ };
+ let tags = match event.get("tags").and_then(|v| v.as_array()) {
+ Some(t) => t,
+ None => {
+ let msg = format!(
+ "target {target_hex} kind:0 has no tags array; \
+ proceeding without owner attestation — this succeeds only if your key is a relay admin"
+ );
+ let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg}));
+ return None;
+ }
+ };
+ match classify_owner_auth_tag(tags, signer_hex) {
+ Ok(tag) => Some(tag),
+ Err(failure) => {
+ let msg = format!(
+ "{}; proceeding without owner attestation — \
+ this succeeds only if your key is a relay admin",
+ failure.message()
+ );
+ let _ = writeln!(warn_sink, "{}", serde_json::json!({"warning": msg}));
+ None
+ }
+ }
+}
+
/// Resolve the optional NIP-OA `auth` tag for archive/unarchive requests.
///
/// Mirrors the desktop's `maybe_owner_auth_tag`:
-/// - `target == signer`: self path — no auth needed → `Ok(None)`.
-/// - Otherwise: fetch target's kind:0, look for an `auth` tag whose owner
-/// (index 1) matches the signer. Return it when present; `Ok(None)` when
-/// absent or structurally malformed. Query/network failures surface as
-/// `Err` — silent degradation to bare would make the relay reject the
-/// request with a misleading error.
+/// - `target == signer`: self path — no auth needed → `Ok(None)`, silent.
+/// - Otherwise: fetch target's kind:0, delegate to [`resolve_auth_from_profile`]
+/// which either returns the extracted tag or emits one `{"warning":"..."}` JSON
+/// line to `warn_sink` and returns `None` — the bare request is still sent so
+/// relay admins can succeed without owner attestation. Query/network failures
+/// surface as `Err`.
async fn resolve_auth(
client: &BuzzClient,
target_hex: &str,
signer_hex: &str,
+ warn_sink: &mut dyn std::io::Write,
) -> Result, CliError> {
if target_hex.eq_ignore_ascii_case(signer_hex) {
return Ok(None);
@@ -184,15 +342,13 @@ async fn resolve_auth(
.map_err(|e| CliError::Other(format!("failed to fetch target kind:0: {e}")))?;
let events: Vec = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("invalid kind:0 query response: {e}")))?;
- let event = match events.into_iter().next() {
- Some(e) => e,
- None => return Ok(None),
- };
- let tags = match event.get("tags").and_then(|v| v.as_array()) {
- Some(t) => t,
- None => return Ok(None),
- };
- Ok(extract_owner_auth_tag(tags, signer_hex))
+ let profile = events.into_iter().next();
+ Ok(resolve_auth_from_profile(
+ profile.as_ref(),
+ target_hex,
+ signer_hex,
+ warn_sink,
+ ))
}
/// Pure extraction helper: require exactly one kind:0 tag whose first
@@ -201,46 +357,11 @@ async fn resolve_auth(
/// then structurally validate that sole tag as
/// `["auth", owner, conditions, sig]` matching `signer_hex`.
///
-/// Malformed tags (wrong arity, non-string elements, non-hex fields) are
-/// silently skipped — the contract is "bare" (None), not error.
+/// Thin wrapper around [`classify_owner_auth_tag`] that collapses the typed
+/// failure reason to `None`. Malformed tags → `None`; valid tag → `Some`.
+#[cfg(test)]
fn extract_owner_auth_tag(tags: &[serde_json::Value], signer_hex: &str) -> Option<[String; 4]> {
- let auth_tags: Vec<&serde_json::Value> = tags
- .iter()
- .filter(|tag| {
- tag.as_array()
- .and_then(|elems| elems.first())
- .and_then(|v| v.as_str())
- == Some("auth")
- })
- .collect();
- if auth_tags.len() != 1 {
- return None;
- }
-
- let elems = auth_tags[0].as_array()?;
- if elems.len() != 4 {
- return None;
- }
- let label = elems[0].as_str()?;
- let owner = elems[1].as_str()?;
- if !owner.eq_ignore_ascii_case(signer_hex) {
- return None;
- }
- let conditions = elems[2].as_str()?;
- let sig = elems[3].as_str()?;
- if owner.len() != 64
- || !owner.chars().all(|c| c.is_ascii_hexdigit())
- || sig.len() != 128
- || !sig.chars().all(|c| c.is_ascii_hexdigit())
- {
- return None;
- }
- Some([
- label.to_owned(),
- owner.to_owned(),
- conditions.to_owned(),
- sig.to_owned(),
- ])
+ classify_owner_auth_tag(tags, signer_hex).ok()
}
/// Validate the NIP-11 relay-info `self` field is a 64-hex pubkey and
@@ -521,6 +642,242 @@ mod tests {
assert!(extract_owner_auth_tag(&tags, &signer).is_none());
}
+ // --- (c) auth-failure classifier: classify_owner_auth_tag ---
+ //
+ // Tests the typed failure taxonomy. Each case asserts the exact
+ // AuthFailure variant so a wrong classification causes a compile-time or
+ // assertion failure — not just a message-substring miss.
+
+ #[test]
+ fn classify_no_auth_tag_returns_no_auth_tag() {
+ // Case 3 (zero auth tags): tags array has entries but none labelled "auth".
+ let signer = hex64('a');
+ let tags = vec![json!(["p", hex64('b')]), json!(["e", hex64('c')])];
+ assert_eq!(
+ classify_owner_auth_tag(&tags, &signer),
+ Err(AuthFailure::NoAuthTag)
+ );
+ }
+
+ #[test]
+ fn classify_empty_tags_returns_no_auth_tag() {
+ assert_eq!(
+ classify_owner_auth_tag(&[], &hex64('a')),
+ Err(AuthFailure::NoAuthTag)
+ );
+ }
+
+ #[test]
+ fn classify_duplicate_auth_tags_returns_ambiguous() {
+ let signer = hex64('a');
+ let sig = hex128('b');
+ let tags = vec![
+ json!(["auth", signer, "conditions", sig]),
+ json!(["auth", signer, "conditions", sig]),
+ ];
+ assert_eq!(
+ classify_owner_auth_tag(&tags, &signer),
+ Err(AuthFailure::AmbiguousAuthTag(2))
+ );
+ }
+
+ #[test]
+ fn classify_wrong_arity_returns_wrong_arity() {
+ let signer = hex64('a');
+ let tags = vec![json!(["auth", signer, "conditions"])];
+ assert_eq!(
+ classify_owner_auth_tag(&tags, &signer),
+ Err(AuthFailure::WrongArity(3))
+ );
+ }
+
+ #[test]
+ fn classify_non_string_element_returns_non_string() {
+ let signer = hex64('a');
+ let tags = vec![json!(["auth", signer, 42, hex128('b')])];
+ assert_eq!(
+ classify_owner_auth_tag(&tags, &signer),
+ Err(AuthFailure::NonStringElement)
+ );
+ }
+
+ #[test]
+ fn classify_invalid_owner_hex_returns_invalid_owner_hex() {
+ let bad_owner = "z".repeat(64);
+ let tags = vec![json!(["auth", bad_owner, "", hex128('a')])];
+ assert_eq!(
+ classify_owner_auth_tag(&tags, &bad_owner),
+ Err(AuthFailure::InvalidOwnerHex(bad_owner))
+ );
+ }
+
+ #[test]
+ fn classify_invalid_sig_hex_returns_invalid_sig_hex() {
+ let signer = hex64('a');
+ let bad_sig = "z".repeat(128);
+ let tags = vec![json!(["auth", signer, "", bad_sig])];
+ assert_eq!(
+ classify_owner_auth_tag(&tags, &signer),
+ Err(AuthFailure::InvalidSigHex)
+ );
+ }
+
+ #[test]
+ fn classify_owner_mismatch_returns_owner_mismatch_with_actual_owner() {
+ // Case 4: structurally valid tag but owner ≠ signer. The failure must
+ // carry the actual owner so resolve_auth can print it in the warning.
+ let actual_owner = hex64('a');
+ let signer = hex64('b');
+ let sig = hex128('c');
+ let tags = vec![json!(["auth", actual_owner, "conditions", sig])];
+ assert_eq!(
+ classify_owner_auth_tag(&tags, &signer),
+ Err(AuthFailure::OwnerMismatch(actual_owner.clone()))
+ );
+ // Message must include the actual owner for actionability.
+ let msg = AuthFailure::OwnerMismatch(actual_owner.clone()).message();
+ assert!(
+ msg.contains(&actual_owner),
+ "OwnerMismatch message must include actual owner, got: {msg}"
+ );
+ }
+
+ // --- (c2) resolve_auth_from_profile emission boundary ---
+ //
+ // Observable-boundary tests: each test calls the production function
+ // `resolve_auth_from_profile` directly with a `Vec` sink and asserts
+ // on exactly what the production code writes. Deleting any `writeln!`
+ // call in that function makes at least one of these tests fail.
+ //
+ // `resolve_auth` is async and requires a live `BuzzClient`; the sync
+ // decomposition lets us test the warning logic without a relay connection.
+
+ fn assert_one_json_warning(sink: &[u8], expected_fragment: &str) {
+ let text = std::str::from_utf8(sink).expect("sink is valid UTF-8");
+ let lines: Vec<&str> = text.lines().collect();
+ assert_eq!(
+ lines.len(),
+ 1,
+ "expected exactly one warning line, got: {text:?}"
+ );
+ let parsed: serde_json::Value =
+ serde_json::from_str(lines[0]).expect("warning line must be parseable JSON");
+ let warning = parsed["warning"]
+ .as_str()
+ .expect("warning line must have a string 'warning' field");
+ assert!(
+ warning.contains(expected_fragment),
+ "warning must contain {expected_fragment:?}, got: {warning}"
+ );
+ }
+
+ fn assert_no_warning(sink: &[u8]) {
+ let text = std::str::from_utf8(sink).expect("sink is valid UTF-8");
+ assert!(text.is_empty(), "expected no warning output, got: {text:?}");
+ }
+
+ // Branch 1: profile == None → no kind:0 found.
+ #[test]
+ fn resolve_auth_from_profile_no_kind0_emits_json_warning() {
+ let target = hex64('t');
+ let signer = hex64('s');
+ let mut sink: Vec = Vec::new();
+ let result = resolve_auth_from_profile(None, &target, &signer, &mut sink);
+ assert!(result.is_none(), "bare path: must return None");
+ assert_one_json_warning(&sink, "no kind:0 profile found");
+ }
+
+ // Branch 2: profile present but no tags array.
+ #[test]
+ fn resolve_auth_from_profile_no_tags_array_emits_json_warning() {
+ let target = hex64('t');
+ let signer = hex64('s');
+ let profile = json!({"kind": 0, "content": "{}"});
+ let mut sink: Vec = Vec::new();
+ let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink);
+ assert!(result.is_none(), "bare path: must return None");
+ assert_one_json_warning(&sink, "no tags array");
+ }
+
+ // Branch 3a: tags present but no auth tag (NoAuthTag).
+ #[test]
+ fn resolve_auth_from_profile_no_auth_tag_emits_json_warning() {
+ let target = hex64('t');
+ let signer = hex64('s');
+ let profile = json!({"tags": [["p", hex64('b')]]});
+ let mut sink: Vec = Vec::new();
+ let result = resolve_auth_from_profile(Some(&profile), &target, &signer, &mut sink);
+ assert!(result.is_none(), "bare path: must return None");
+ assert_one_json_warning(&sink, "no \"auth\" tag");
+ }
+
+ // Branch 3b: duplicate auth tags (AmbiguousAuthTag).
+ #[test]
+ fn resolve_auth_from_profile_ambiguous_auth_tag_emits_json_warning() {
+ let signer = hex64('s');
+ let sig = hex128('b');
+ let profile = json!({"tags": [
+ ["auth", signer, "conditions", sig],
+ ["auth", signer, "conditions", sig],
+ ]});
+ let mut sink: Vec = Vec::new();
+ let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink);
+ assert!(result.is_none(), "bare path: must return None");
+ assert_one_json_warning(&sink, "ambiguous");
+ }
+
+ // Branch 3c: sole auth tag malformed (WrongArity).
+ #[test]
+ fn resolve_auth_from_profile_malformed_tag_emits_json_warning() {
+ let signer = hex64('s');
+ // arity 3 — missing sig field
+ let profile = json!({"tags": [["auth", signer, "conditions"]]});
+ let mut sink: Vec = Vec::new();
+ let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink);
+ assert!(result.is_none(), "bare path: must return None");
+ assert_one_json_warning(&sink, "element");
+ }
+
+ // Branch 3d: owner mismatch — warning must include the actual owner pubkey.
+ #[test]
+ fn resolve_auth_from_profile_owner_mismatch_emits_json_warning_with_actual_owner() {
+ let actual_owner = hex64('a');
+ let signer = hex64('b');
+ let sig = hex128('c');
+ let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]});
+ let mut sink: Vec = Vec::new();
+ let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink);
+ assert!(result.is_none(), "bare path: must return None");
+ assert_one_json_warning(&sink, &actual_owner);
+ }
+
+ // Success path: valid auth tag → Some returned, sink stays empty.
+ #[test]
+ fn resolve_auth_from_profile_valid_auth_tag_returns_some_emits_nothing() {
+ let signer = hex64('a');
+ let sig = hex128('b');
+ let profile = json!({"tags": [["auth", signer, "conditions", sig]]});
+ let mut sink: Vec = Vec::new();
+ let result = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink);
+ assert!(result.is_some(), "must return the extracted tag");
+ assert_no_warning(&sink);
+ }
+
+ // Warning output must be valid JSON (serde_json serializes safely).
+ #[test]
+ fn resolve_auth_from_profile_warning_is_valid_json() {
+ let actual_owner = hex64('a');
+ let signer = hex64('b');
+ let sig = hex128('c');
+ let profile = json!({"tags": [["auth", actual_owner, "conditions", sig]]});
+ let mut sink: Vec = Vec::new();
+ let _ = resolve_auth_from_profile(Some(&profile), &hex64('t'), &signer, &mut sink);
+ let text = std::str::from_utf8(&sink).unwrap();
+ let parsed: serde_json::Value =
+ serde_json::from_str(text.trim()).expect("warning output must be valid JSON");
+ assert!(parsed["warning"].is_string());
+ }
+
// --- (d) NIP-11 self normalization: normalize_relay_self_hex ---
#[test]
diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index c1ea0e061b9..d1477f4bc6b 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -105,6 +105,7 @@ export default defineConfig({
"**/inbox-edit.spec.ts",
"**/send-channel-binding.spec.ts",
"**/project-commit-detail.spec.ts",
+ "**/project-file-tree.spec.ts",
"**/project-inbox.spec.ts",
"**/project-issue-comments.spec.ts",
"**/project-pr-review.spec.ts",
diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs
index 322834630a3..17440a8057e 100644
--- a/desktop/src-tauri/src/commands/mod.rs
+++ b/desktop/src-tauri/src/commands/mod.rs
@@ -47,6 +47,7 @@ mod project_git;
mod project_git_branches;
mod project_git_diff;
mod project_git_exec;
+mod project_git_files;
mod project_git_merge_error;
mod project_git_push;
mod project_git_workflow;
diff --git a/desktop/src-tauri/src/commands/project_git.rs b/desktop/src-tauri/src/commands/project_git.rs
index 201f3a05079..a9eccf85525 100644
--- a/desktop/src-tauri/src/commands/project_git.rs
+++ b/desktop/src-tauri/src/commands/project_git.rs
@@ -2,11 +2,11 @@ use super::project_git_exec::{
build_git_auth_config, clean_branch, clean_target_ref, run_git, validate_workspace_clone_url,
GitAuthConfig,
};
+use super::project_git_files::{parse_ls_tree, parse_worktree_files, ParsedProjectRepoFiles};
use super::project_git_push::push_project_local_repository_blocking;
use super::project_repo_paths::{canonical_repos_roots, find_local_repo_dir};
use crate::app_state::AppState;
use serde::Serialize;
-use std::time::UNIX_EPOCH;
use tauri::State;
#[derive(Clone, Serialize)]
pub struct ProjectRepoCommitInfo {
@@ -38,6 +38,8 @@ pub struct ProjectRepoSnapshotInfo {
pub latest_commit: Option,
pub commits: Vec,
pub files: Vec,
+ /// Complete file count before the snapshot payload is capped.
+ pub total_file_count: usize,
pub contributors: Vec,
}
#[derive(Serialize)]
@@ -134,30 +136,6 @@ fn has_untracked_files(output: &str) -> bool {
output.lines().any(|line| line.starts_with("??"))
}
-fn read_preview_content(
- repo_dir: &std::path::Path,
- path: &str,
- size: Option,
-) -> Option {
- const MAX_PREVIEW_BYTES: u64 = 64 * 1024;
- if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) {
- return None;
- }
-
- let full_path = repo_dir.join(path);
- let normalized = full_path.canonicalize().ok()?;
- let repo_root = repo_dir.canonicalize().ok()?;
- if !normalized.starts_with(repo_root) {
- return None;
- }
-
- let bytes = std::fs::read(normalized).ok()?;
- if bytes.contains(&0) {
- return None;
- }
- String::from_utf8(bytes).ok()
-}
-
fn parse_commits(output: &str) -> Vec {
output
.lines()
@@ -235,46 +213,6 @@ fn parse_latest_commit_by_path(
result
}
-fn path_modified_at(path: &std::path::Path) -> Option {
- let modified = std::fs::metadata(path).ok()?.modified().ok()?;
- modified
- .duration_since(UNIX_EPOCH)
- .ok()
- .and_then(|duration| i64::try_from(duration.as_secs()).ok())
-}
-
-fn parse_worktree_files(
- repo_dir: &std::path::Path,
- output: &str,
- latest_commit_by_path: &std::collections::HashMap,
-) -> Vec {
- output
- .split('\0')
- .filter(|path| !path.trim().is_empty())
- .filter_map(|path| {
- let full_path = repo_dir.join(path);
- let metadata = std::fs::metadata(&full_path).ok()?;
- if !metadata.is_file() {
- return None;
- }
- let size = Some(metadata.len());
- let latest_commit = latest_commit_by_path.get(path).cloned();
- Some(ProjectRepoFileInfo {
- path: path.to_string(),
- kind: "blob".to_string(),
- size,
- preview_content: read_preview_content(repo_dir, path, size),
- last_changed_at: latest_commit
- .as_ref()
- .map(|commit| commit.timestamp)
- .or_else(|| path_modified_at(&full_path)),
- latest_commit,
- })
- })
- .take(250)
- .collect()
-}
-
fn normalize_branch_name(branch: &str) -> &str {
branch
.trim()
@@ -309,40 +247,6 @@ fn branch_activity_range(
Some(format!("origin/{base_branch}..HEAD"))
}
-fn parse_ls_tree(
- repo_dir: &std::path::Path,
- output: &str,
- latest_commit_by_path: &std::collections::HashMap,
-) -> Vec {
- output
- .lines()
- .filter_map(|line| {
- let (meta, path) = line.split_once('\t')?;
- let mut parts = meta.split_whitespace();
- let _mode = parts.next()?;
- let kind = parts.next()?.to_string();
- let _object = parts.next()?;
- let size = parts.next().and_then(|value| value.parse::().ok());
- let preview_content = if kind == "blob" {
- read_preview_content(repo_dir, path, size)
- } else {
- None
- };
- Some(ProjectRepoFileInfo {
- path: path.to_string(),
- kind,
- size,
- preview_content,
- last_changed_at: latest_commit_by_path
- .get(path)
- .map(|commit| commit.timestamp),
- latest_commit: latest_commit_by_path.get(path).cloned(),
- })
- })
- .take(250)
- .collect()
-}
-
fn snapshot_from_repo(
repo_dir: &std::path::Path,
auth: &GitAuthConfig,
@@ -383,7 +287,7 @@ fn snapshot_from_repo(
(Vec::new(), Vec::new())
};
- let files = if latest_commit.is_some() {
+ let parsed_files = if latest_commit.is_some() {
let latest_commit_by_path = run_git(
&[
"log",
@@ -402,13 +306,14 @@ fn snapshot_from_repo(
.map(|output| parse_ls_tree(repo_dir, &output, &latest_commit_by_path))
.unwrap_or_default()
} else {
- Vec::new()
+ ParsedProjectRepoFiles::default()
};
ProjectRepoSnapshotInfo {
latest_commit,
commits,
- files,
+ files: parsed_files.files,
+ total_file_count: parsed_files.total_file_count,
contributors,
}
}
@@ -466,7 +371,7 @@ fn snapshot_from_worktree(
(Vec::new(), Vec::new(), std::collections::HashMap::new())
};
- let files = run_git(
+ let parsed_files = run_git(
&[
"ls-files",
"--cached",
@@ -483,7 +388,8 @@ fn snapshot_from_worktree(
ProjectRepoSnapshotInfo {
latest_commit,
commits,
- files,
+ files: parsed_files.files,
+ total_file_count: parsed_files.total_file_count,
contributors,
}
}
diff --git a/desktop/src-tauri/src/commands/project_git_files.rs b/desktop/src-tauri/src/commands/project_git_files.rs
new file mode 100644
index 00000000000..a1bc47a0ea2
--- /dev/null
+++ b/desktop/src-tauri/src/commands/project_git_files.rs
@@ -0,0 +1,152 @@
+use super::project_git::{ProjectRepoCommitInfo, ProjectRepoFileInfo};
+use std::{collections::HashMap, path::Path, time::UNIX_EPOCH};
+
+const MAX_REPOSITORY_FILE_PAYLOAD: usize = 250;
+
+#[derive(Default)]
+pub(super) struct ParsedProjectRepoFiles {
+ pub(super) files: Vec,
+ pub(super) total_file_count: usize,
+}
+
+fn read_preview_content(repo_dir: &Path, path: &str, size: Option) -> Option {
+ const MAX_PREVIEW_BYTES: u64 = 64 * 1024;
+ if size.is_some_and(|value| value > MAX_PREVIEW_BYTES) {
+ return None;
+ }
+
+ let normalized = repo_dir.join(path).canonicalize().ok()?;
+ let repo_root = repo_dir.canonicalize().ok()?;
+ if !normalized.starts_with(repo_root) {
+ return None;
+ }
+
+ let bytes = std::fs::read(normalized).ok()?;
+ if bytes.contains(&0) {
+ return None;
+ }
+ String::from_utf8(bytes).ok()
+}
+
+fn path_modified_at(path: &Path) -> Option {
+ let modified = std::fs::metadata(path).ok()?.modified().ok()?;
+ modified
+ .duration_since(UNIX_EPOCH)
+ .ok()
+ .and_then(|duration| i64::try_from(duration.as_secs()).ok())
+}
+
+pub(super) fn parse_worktree_files(
+ repo_dir: &Path,
+ output: &str,
+ latest_commit_by_path: &HashMap,
+) -> ParsedProjectRepoFiles {
+ let mut parsed = ParsedProjectRepoFiles::default();
+
+ for path in output.split('\0').filter(|path| !path.trim().is_empty()) {
+ let full_path = repo_dir.join(path);
+ let Ok(metadata) = std::fs::metadata(&full_path) else {
+ continue;
+ };
+ if !metadata.is_file() {
+ continue;
+ }
+
+ parsed.total_file_count += 1;
+ if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD {
+ continue;
+ }
+
+ let size = Some(metadata.len());
+ let latest_commit = latest_commit_by_path.get(path).cloned();
+ parsed.files.push(ProjectRepoFileInfo {
+ path: path.to_string(),
+ kind: "blob".to_string(),
+ size,
+ preview_content: read_preview_content(repo_dir, path, size),
+ last_changed_at: latest_commit
+ .as_ref()
+ .map(|commit| commit.timestamp)
+ .or_else(|| path_modified_at(&full_path)),
+ latest_commit,
+ });
+ }
+
+ parsed
+}
+
+pub(super) fn parse_ls_tree(
+ repo_dir: &Path,
+ output: &str,
+ latest_commit_by_path: &HashMap,
+) -> ParsedProjectRepoFiles {
+ let mut parsed = ParsedProjectRepoFiles::default();
+
+ for line in output.lines() {
+ let Some((meta, path)) = line.split_once('\t') else {
+ continue;
+ };
+ let mut parts = meta.split_whitespace();
+ let (Some(_mode), Some(kind), Some(_object)) = (parts.next(), parts.next(), parts.next())
+ else {
+ continue;
+ };
+ let size = parts.next().and_then(|value| value.parse::().ok());
+
+ parsed.total_file_count += 1;
+ if parsed.files.len() >= MAX_REPOSITORY_FILE_PAYLOAD {
+ continue;
+ }
+
+ let preview_content = (kind == "blob")
+ .then(|| read_preview_content(repo_dir, path, size))
+ .flatten();
+ parsed.files.push(ProjectRepoFileInfo {
+ path: path.to_string(),
+ kind: kind.to_string(),
+ size,
+ preview_content,
+ last_changed_at: latest_commit_by_path
+ .get(path)
+ .map(|commit| commit.timestamp),
+ latest_commit: latest_commit_by_path.get(path).cloned(),
+ });
+ }
+
+ parsed
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn ls_tree_reports_the_full_count_when_the_payload_is_capped() {
+ let output = (0..251)
+ .map(|index| format!("100644 blob {} 1\tfile-{index:03}.txt", "0".repeat(40)))
+ .collect::>()
+ .join("\n");
+
+ let parsed = parse_ls_tree(Path::new("/missing-repository"), &output, &HashMap::new());
+
+ assert_eq!(parsed.files.len(), 250);
+ assert_eq!(parsed.total_file_count, 251);
+ }
+
+ #[test]
+ fn worktree_reports_the_full_count_when_the_payload_is_capped() {
+ let repo = tempfile::tempdir().expect("create temporary repository");
+ let mut paths = Vec::new();
+ for index in 0..251 {
+ let path = format!("file-{index:03}.txt");
+ std::fs::write(repo.path().join(&path), "x").expect("write fixture file");
+ paths.push(path);
+ }
+ let output = format!("{}\0", paths.join("\0"));
+
+ let parsed = parse_worktree_files(repo.path(), &output, &HashMap::new());
+
+ assert_eq!(parsed.files.len(), 250);
+ assert_eq!(parsed.total_file_count, 251);
+ }
+}
diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md
index b929dbb6131..b578326eba3 100644
--- a/desktop/src/features/agents/AGENTS.md
+++ b/desktop/src/features/agents/AGENTS.md
@@ -113,13 +113,16 @@ with a TypeScript lookup table or an id comparison in a component.
Once the Advanced toggle is visible, its expanded state is exclusively
user-controlled: provider, harness, and required-env changes must never
open it automatically in defaults, create, or edit flows. In Create mode,
- the defaults summary follows preferred-harness changes saved while the
- dialog is open, and its configured state includes required credentials as
- well as provider/model values. If no available harness can resolve, Create
- starts in Customize and lets unavailable catalog entries be selected only
- to expose their setup guidance; submission remains blocked.
- Advanced-only required credentials mark the collapsed Advanced toggle
- without opening it in Global Defaults and Edit, and block incomplete saves.
+ `Run on` belongs in Advanced directly after **Who can send instructions**;
+ keep it out of the basic create fields. The defaults summary follows
+ preferred-harness changes saved while the dialog is open, and its configured
+ state includes required credentials as well as provider/model values. If no
+ available harness can resolve, Create starts in Customize and lets unavailable
+ catalog entries be selected only to expose their setup guidance; submission
+ remains blocked.
+ Advanced-only required credentials and incomplete remote **Run on** setup
+ mark the collapsed Advanced toggle without opening it, and block incomplete
+ saves.
Runtime-file credentials satisfy Global Defaults just as they do Create and
Edit. In Edit,
selecting Custom command keeps its required command field beside the harness
diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
index 12702f45ac4..409ae6a8214 100644
--- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
+++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx
@@ -109,7 +109,6 @@ type AgentDefinitionDialogProps = {
) => Promise;
/** Publishes saved changes when the edited agent is shared in the catalog. */
publishCatalogUpdatesOnSave?: boolean;
- /** Rendered below the form fields in create mode only ("Where to run"). */
createRunSection?: React.ReactNode;
/** Extra create-mode submit gate (e.g. incomplete provider config). */
createSubmitBlocked?: boolean;
@@ -962,9 +961,6 @@ export function AgentDefinitionDialog({
onSaved={selectSavedHarness}
open={isAddHarnessOpen}
/>
-
- {isCreateMode ? createRunSection : null}
-
Advanced
- {localModeGate.missingEnvKeys.some((key) =>
+ {(isCreateMode && createSubmitBlocked) ||
+ localModeGate.missingEnvKeys.some((key) =>
advancedRequiredEnvKeys.includes(key),
) ? (
+ {afterRespondTo}
+
(null);
+ const runOnOptions = React.useMemo(
+ () => [
+ { label: "This computer", value: "local" },
+ ...backendProviders.map((provider) => ({
+ label: provider.id,
+ value: provider.id,
+ })),
+ ],
+ [backendProviders],
+ );
const isProviderMode = draft.runOn !== "local";
const selectedBackendProvider = React.useMemo(
() =>
@@ -51,7 +62,7 @@ export function WhereToRunSection({
? (selectedBackendProvider?.binaryPath ?? null)
: null;
React.useEffect(() => {
- if (!selectedBinaryPath) {
+ if (!selectedBinaryPath || draft.probedProvider) {
setProbeError(null);
return;
}
@@ -70,7 +81,7 @@ export function WhereToRunSection({
return () => {
cancelled = true;
};
- }, [selectedBinaryPath]);
+ }, [selectedBinaryPath, draft.probedProvider]);
if (backendProviders.length === 0) return null;
@@ -80,25 +91,19 @@ export function WhereToRunSection({
Run on
-
+ onValueChange={(runOn) =>
onDraftChange({
...emptyWhereToRunDraft,
- runOn: event.target.value,
+ runOn,
})
}
+ options={runOnOptions}
+ placeholder="Choose where to run"
value={draft.runOn}
- >
- This computer
- {backendProviders.map((provider) => (
-
- {provider.id}
-
- ))}
-
+ />
{isProviderMode && selectedBackendProvider ? (
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 9e5152edfe4..7ea63f23396 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -22,7 +22,7 @@ import {
getDmHuddleMemberPubkeys,
hasOtherDmParticipant,
} from "@/features/channels/lib/dmHuddleMembers";
-import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext";
+import { buildVideoReviewPresentationByMessageId } from "@/features/messages/lib/videoReviewContext";
import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar";
@@ -42,7 +42,7 @@ import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar
import { ChannelComposerActivityAccessory } from "@/features/channels/ui/ChannelComposerActivityAccessory";
import {
containsWelcomePersonaMention,
- WelcomeComposerBanner,
+ WelcomeComposerGuidanceLayer,
WELCOME_COMPOSER_BANNER_DISMISS_DURATION_SECONDS,
WELCOME_COMPOSER_BANNER_HIDE_BUFFER_MS,
WELCOME_COMPOSER_BANNER_SUCCESS_SETTLE_MS,
@@ -464,7 +464,7 @@ export const ChannelPane = React.memo(function ChannelPane({
const activeVideoReviewCommentSender = activeChannel?.archivedAt
? undefined
: onSendVideoReviewComment;
- const threadVideoReviewContextsByMessageId = React.useMemo(() => {
+ const threadVideoReviewPresentation = React.useMemo(() => {
const messagesById = new Map(
messages.map((message) => [message.id, message]),
);
@@ -475,7 +475,7 @@ export const ChannelPane = React.memo(function ChannelPane({
messagesById.set(message.id, message);
}
- return buildVideoReviewContextsByMessageId({
+ return buildVideoReviewPresentationByMessageId({
channelId: activeChannel?.id ?? null,
channelName: activeChannel?.name,
channelType: activeChannel?.channelType ?? null,
@@ -737,18 +737,18 @@ export const ChannelPane = React.memo(function ChannelPane({
hasComposerBottomActivity && "composer-dock--with-activity",
)}
>
+ {isActiveWelcomeChannel && !timeoutState.active ? (
+
+ {welcomeKickoffStage}
+
+ ) : null}
{timeoutState.active ? (
- ) : isActiveWelcomeChannel ? (
-
- {welcomeKickoffStage}
-
-
) : null}
);
}
+
+type WelcomeComposerGuidanceLayerProps = WelcomeComposerBannerProps & {
+ children: React.ReactNode;
+};
+
+export function WelcomeComposerGuidanceLayer({
+ children,
+ settingUp,
+ state,
+}: WelcomeComposerGuidanceLayerProps) {
+ return (
+
+ );
+}
diff --git a/desktop/src/features/messages/lib/videoReviewContext.test.mjs b/desktop/src/features/messages/lib/videoReviewContext.test.mjs
index 8ecb5f57981..6c75883da98 100644
--- a/desktop/src/features/messages/lib/videoReviewContext.test.mjs
+++ b/desktop/src/features/messages/lib/videoReviewContext.test.mjs
@@ -4,6 +4,7 @@ import test from "node:test";
import {
buildVideoReviewCommentsByRootId,
buildVideoReviewCommentsForRoot,
+ buildVideoReviewCommentRootIdsByMessageId,
buildVideoReviewContextForMessage,
buildVideoReviewContextsByMessageId,
hasVideoAttachment,
@@ -159,6 +160,57 @@ test("buildVideoReviewCommentsForRoot returns descendants for one root", () => {
);
});
+test("buildVideoReviewCommentRootIdsByMessageId targets the nearest video ancestor", () => {
+ const root = message({ id: "root", body: "Review request" });
+ const firstVideo = message({
+ id: "first-video",
+ body: "",
+ parentId: root.id,
+ rootId: root.id,
+ });
+ const firstComment = message({
+ id: "first-comment",
+ body: "[00:01] tighten this",
+ parentId: firstVideo.id,
+ rootId: root.id,
+ });
+ const nestedVideo = message({
+ id: "nested-video",
+ body: "",
+ parentId: firstComment.id,
+ rootId: root.id,
+ });
+ const nestedComment = message({
+ id: "nested-comment",
+ body: "[00:02] check this frame",
+ parentId: nestedVideo.id,
+ rootId: root.id,
+ });
+ const plainReply = message({
+ id: "plain-reply",
+ body: "No video ancestor",
+ parentId: root.id,
+ rootId: root.id,
+ });
+
+ const rootIds = buildVideoReviewCommentRootIdsByMessageId([
+ root,
+ firstVideo,
+ firstComment,
+ nestedVideo,
+ nestedComment,
+ plainReply,
+ ]);
+
+ assert.deepEqual(
+ [...rootIds.entries()],
+ [
+ [firstComment.id, firstVideo.id],
+ [nestedComment.id, nestedVideo.id],
+ ],
+ );
+});
+
test("buildVideoReviewContextForMessage posts against the source video", async () => {
const video = message({
id: "video",
diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts
index f605952f5a7..78401214a20 100644
--- a/desktop/src/features/messages/lib/videoReviewContext.ts
+++ b/desktop/src/features/messages/lib/videoReviewContext.ts
@@ -93,6 +93,33 @@ export function buildVideoReviewCommentsForRoot(
return comments;
}
+export function buildVideoReviewCommentRootIdsByMessageId(
+ messages: TimelineMessage[],
+): ReadonlyMap {
+ const messageById = new Map(messages.map((message) => [message.id, message]));
+ const videoMessageIds = new Set(
+ messages.filter(hasVideoAttachment).map((message) => message.id),
+ );
+ const rootIdsByMessageId = new Map();
+
+ for (const message of messages) {
+ if (videoMessageIds.has(message.id)) continue;
+
+ let ancestorId = message.parentId ?? null;
+ const visited = new Set();
+ while (ancestorId && !visited.has(ancestorId)) {
+ if (videoMessageIds.has(ancestorId)) {
+ rootIdsByMessageId.set(message.id, ancestorId);
+ break;
+ }
+ visited.add(ancestorId);
+ ancestorId = messageById.get(ancestorId)?.parentId ?? null;
+ }
+ }
+
+ return rootIdsByMessageId;
+}
+
export function buildVideoReviewContextForMessage({
channelId,
channelName,
@@ -193,3 +220,18 @@ export function buildVideoReviewContextsByMessageId({
return contexts;
}
+
+export function buildVideoReviewPresentationByMessageId(
+ args: Parameters[0],
+) {
+ return {
+ commentRootIdsByMessageId: buildVideoReviewCommentRootIdsByMessageId(
+ args.messages,
+ ),
+ contextsByMessageId: buildVideoReviewContextsByMessageId(args),
+ };
+}
+
+export type VideoReviewPresentation = ReturnType<
+ typeof buildVideoReviewPresentationByMessageId
+>;
diff --git a/desktop/src/features/messages/ui/ComposerDockBackdrop.tsx b/desktop/src/features/messages/ui/ComposerDockBackdrop.tsx
index 840be49beae..0e237d5145d 100644
--- a/desktop/src/features/messages/ui/ComposerDockBackdrop.tsx
+++ b/desktop/src/features/messages/ui/ComposerDockBackdrop.tsx
@@ -1,5 +1,30 @@
import { cn } from "@/shared/lib/cn";
+type ComposerDockGlassBackdropProps = {
+ className?: string;
+ testId?: string;
+};
+
+/**
+ * Applies the composer dock's shared blur without adding color or layout.
+ * Reuse it for composer-adjacent surfaces that need the same glass treatment.
+ */
+export function ComposerDockGlassBackdrop({
+ className,
+ testId,
+}: ComposerDockGlassBackdropProps) {
+ return (
+
+ );
+}
+
type ComposerDockBackdropProps = {
gutterClassName: string;
};
@@ -21,7 +46,7 @@ export function ComposerDockBackdrop({
)}
data-testid="composer-dock-backdrop"
>
-
+
);
- default:
- {
- const waveMessage = parseWaveMessageContent(message.body);
- if (waveMessage) {
- return (
-
- );
- }
+ default: {
+ const waveMessage = parseWaveMessageContent(message.body);
+ if (waveMessage) {
+ return (
+
+ );
}
- return (
+ const reviewRootEventId = videoReviewCommentRootId;
+ const reviewTimecode = reviewRootEventId
+ ? parseVideoReviewTimecode(message.body)
+ : null;
+ const markdown = (
);
+ if (!reviewRootEventId || !reviewTimecode || !openVideoReviewAt) {
+ return markdown;
+ }
+
+ return (
+
+
{
+ event.stopPropagation();
+ openVideoReviewAt(reviewRootEventId, reviewTimecode.seconds);
+ }}
+ />
+ {markdown}
+
+ );
+ }
}
};
@@ -893,6 +919,7 @@ export const MessageRow = React.memo(
prev.playEntrance === next.playEntrance &&
prev.profiles === next.profiles &&
prev.searchQuery === next.searchQuery &&
+ prev.videoReviewCommentRootId === next.videoReviewCommentRootId &&
prev.videoReviewContext === next.videoReviewContext,
);
diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
index fb01783bf46..2c82268462f 100644
--- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx
+++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx
@@ -18,11 +18,13 @@ import {
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
import type { TimelineMessage } from "@/features/messages/types";
+import type { VideoReviewPresentation } from "@/features/messages/lib/videoReviewContext";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { Channel } from "@/shared/api/types";
import type { ThreadPanelLayoutProps } from "@/features/channels/lib/threadPanelLayout";
import { useEscapeKey } from "@/shared/hooks/useEscapeKey";
import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile";
+import { VideoReviewNavigationProvider } from "@/shared/ui/VideoReviewNavigation";
import { cn } from "@/shared/lib/cn";
import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel";
import { AuxiliaryPanelBody } from "@/shared/layout/AuxiliaryPanel";
@@ -38,7 +40,6 @@ import {
} from "@/features/messages/lib/messageThreadPanelLayout";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
-import type { VideoReviewContext } from "@/shared/ui/VideoPlayer";
import { ComposerActivityAccessory } from "./ComposerActivityAccessory";
import { ComposerDockBackdrop } from "./ComposerDockBackdrop";
import { MessageComposer } from "./MessageComposer";
@@ -111,7 +112,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & {
threadUnreadCount?: number;
threadReplyUnreadCounts?: ReadonlyMap;
threadTypingPubkeys: string[];
- videoReviewContextsByMessageId?: ReadonlyMap;
+ videoReviewPresentation?: VideoReviewPresentation;
activityAccessoryContent?: React.ReactNode;
activityAccessoryVisible: boolean;
widthPx: number;
@@ -225,7 +226,7 @@ export function MessageThreadPanel({
scrollTargetId,
scrollTargetHighlights = true,
threadHead,
- videoReviewContextsByMessageId,
+ videoReviewPresentation,
threadReplies,
threadRepliesPending = false,
threadUnreadCount,
@@ -617,7 +618,10 @@ export function MessageThreadPanel({
}
profiles={profiles}
showDepthGuides={shouldShowThreadBranchGuides}
- videoReviewContext={videoReviewContextsByMessageId?.get(
+ videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
+ threadHead.id,
+ )}
+ videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
threadHead.id,
)}
/>
@@ -776,7 +780,10 @@ export function MessageThreadPanel({
onToggleReaction={onToggleReaction}
profiles={profiles}
showDepthGuides={shouldShowThreadBranchGuides}
- videoReviewContext={videoReviewContextsByMessageId?.get(
+ videoReviewCommentRootId={videoReviewPresentation?.commentRootIdsByMessageId.get(
+ entry.message.id,
+ )}
+ videoReviewContext={videoReviewPresentation?.contextsByMessageId.get(
entry.message.id,
)}
/>
@@ -955,24 +962,26 @@ export function MessageThreadPanel({
);
return (
- {threadHeaderContent}
- )
- }
- isSinglePanelView={isSinglePanelView}
- layout={layout}
- onClose={onClose}
- testId="message-thread-panel"
- transparentChrome={transparentChrome}
- widthPx={widthPx}
- >
- {threadScrollRegion}
-
+
+ {threadHeaderContent}
+ )
+ }
+ isSinglePanelView={isSinglePanelView}
+ layout={layout}
+ onClose={onClose}
+ testId="message-thread-panel"
+ transparentChrome={transparentChrome}
+ widthPx={widthPx}
+ >
+ {threadScrollRegion}
+
+
);
}
diff --git a/desktop/src/features/projects/lib/projectFileTreeSummary.test.mjs b/desktop/src/features/projects/lib/projectFileTreeSummary.test.mjs
new file mode 100644
index 00000000000..0d0033e9fd6
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectFileTreeSummary.test.mjs
@@ -0,0 +1,26 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { projectFileTreeSummary } from "./projectFileTreeSummary.ts";
+
+test("reports a complete repository tree normally", () => {
+ assert.deepEqual(projectFileTreeSummary(12, 12), {
+ countLabel: "12 files",
+ truncationNotice: null,
+ });
+});
+
+test("reports the loaded and total counts when the repository tree is truncated", () => {
+ assert.deepEqual(projectFileTreeSummary(250, 661), {
+ countLabel: "250 of 661 files",
+ truncationNotice:
+ "Showing the first 250 of 661 files. Some files and folders are not included.",
+ });
+});
+
+test("never reports fewer total files than were loaded", () => {
+ assert.deepEqual(projectFileTreeSummary(3, 0), {
+ countLabel: "3 files",
+ truncationNotice: null,
+ });
+});
diff --git a/desktop/src/features/projects/lib/projectFileTreeSummary.ts b/desktop/src/features/projects/lib/projectFileTreeSummary.ts
new file mode 100644
index 00000000000..af9d0eb915a
--- /dev/null
+++ b/desktop/src/features/projects/lib/projectFileTreeSummary.ts
@@ -0,0 +1,23 @@
+export type ProjectFileTreeSummary = {
+ countLabel: string;
+ truncationNotice: string | null;
+};
+
+/** Builds honest file-count copy for complete and capped repository snapshots. */
+export function projectFileTreeSummary(
+ loadedFileCount: number,
+ reportedTotalFileCount: number,
+): ProjectFileTreeSummary {
+ const totalFileCount = Math.max(loadedFileCount, reportedTotalFileCount);
+ if (loadedFileCount >= totalFileCount) {
+ return {
+ countLabel: `${loadedFileCount} file${loadedFileCount === 1 ? "" : "s"}`,
+ truncationNotice: null,
+ };
+ }
+
+ return {
+ countLabel: `${loadedFileCount} of ${totalFileCount} files`,
+ truncationNotice: `Showing the first ${loadedFileCount} of ${totalFileCount} files. Some files and folders are not included.`,
+ };
+}
diff --git a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
index c4becd90576..471a3938f10 100644
--- a/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
+++ b/desktop/src/features/projects/ui/ProjectRepositoryPanel.tsx
@@ -26,6 +26,7 @@ import type {
ProjectRepoFile,
ProjectRepoSnapshot,
} from "@/features/projects/hooks";
+import { projectFileTreeSummary } from "@/features/projects/lib/projectFileTreeSummary";
import { relativeTime } from "@/features/projects/lib/projectsViewHelpers";
import { useUserSearchQuery } from "@/features/profile/hooks";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
@@ -45,10 +46,6 @@ import {
RepositoryBranchDropdown,
} from "./ProjectRepositorySource";
-function pluralize(count: number, singular: string) {
- return `${count} ${singular}${count === 1 ? "" : "s"}`;
-}
-
export function formatLastChangedAt(timestamp: number | null) {
if (!timestamp) return "—";
return new Date(timestamp * 1_000).toLocaleString(undefined, {
@@ -639,6 +636,10 @@ export function RepositoryFilesPanel({
);
const visibleEntries = entries.slice(0, 200);
const latestCommit = snapshot?.latestCommit ?? null;
+ const fileTreeSummary = projectFileTreeSummary(
+ files.length,
+ snapshot?.totalFileCount ?? files.length,
+ );
const knownLatestCommitProfile = React.useMemo(
() => profileForCommitAuthor(latestCommit, profiles),
[latestCommit, profiles],
@@ -829,7 +830,7 @@ export function RepositoryFilesPanel({
{latestCommit.shortHash}
- · {pluralize(files.length, "file")}
+ · {fileTreeSummary.countLabel}
@@ -844,7 +845,7 @@ export function RepositoryFilesPanel({
) : (
- Repository files · {files.length} tracked files
+ Repository files · {fileTreeSummary.countLabel}
)}
@@ -910,6 +911,11 @@ export function RepositoryFilesPanel({
the list.
) : null}
+ {fileTreeSummary.truncationNotice ? (
+
+ {fileTreeSummary.truncationNotice}
+
+ ) : null}
);
}
diff --git a/desktop/src/shared/api/projectGit.ts b/desktop/src/shared/api/projectGit.ts
index 7293db19385..96a1bc93b89 100644
--- a/desktop/src/shared/api/projectGit.ts
+++ b/desktop/src/shared/api/projectGit.ts
@@ -52,6 +52,7 @@ type RawProjectRepoSnapshot = {
latest_commit: RawProjectRepoCommit | null;
commits?: RawProjectRepoCommit[];
files: RawProjectRepoFile[];
+ total_file_count?: number;
contributors?: RawProjectRepoContributor[];
};
@@ -137,6 +138,10 @@ function fromRawProjectRepoSnapshot(
? fromRawProjectRepoCommit(file.latest_commit)
: null,
})),
+ totalFileCount: Math.max(
+ snapshot.files.length,
+ snapshot.total_file_count ?? snapshot.files.length,
+ ),
contributors: (snapshot.contributors ?? []).map((contributor) => ({
name: contributor.name,
email: contributor.email,
diff --git a/desktop/src/shared/api/projectGitTypes.ts b/desktop/src/shared/api/projectGitTypes.ts
index 46854eb33e7..5da3cadc71e 100644
--- a/desktop/src/shared/api/projectGitTypes.ts
+++ b/desktop/src/shared/api/projectGitTypes.ts
@@ -27,6 +27,8 @@ export type ProjectRepoSnapshot = {
latestCommit: ProjectRepoCommit | null;
commits: ProjectRepoCommit[];
files: ProjectRepoFile[];
+ /** Complete file count before the native snapshot payload is capped. */
+ totalFileCount: number;
contributors: ProjectRepoContributor[];
};
diff --git a/desktop/src/shared/ui/VideoPlayer.tsx b/desktop/src/shared/ui/VideoPlayer.tsx
index 908bd8e3f01..74703dc5e8c 100644
--- a/desktop/src/shared/ui/VideoPlayer.tsx
+++ b/desktop/src/shared/ui/VideoPlayer.tsx
@@ -28,6 +28,13 @@ import { UserAvatar } from "@/shared/ui/UserAvatar";
import { Spinner } from "./spinner";
import { useNaturalVideoAspectRatio } from "./videoAspectRatio";
import { useVideoContextMenu } from "./useVideoContextMenu";
+import { useRegisterVideoReview } from "./VideoReviewNavigation";
+import { VideoReviewPosterPreview } from "./VideoReviewPosterPreview";
+import { parseVideoReviewTimecode } from "./videoReviewTimecode";
+import {
+ VideoReviewTimecodeButton,
+ VIDEO_REVIEW_TIMECODE_ACCENT_CLASS,
+} from "./VideoReviewTimecodeButton";
import {
getInlinePlaybackPosition,
getReviewPlaybackPosition,
@@ -109,16 +116,10 @@ type TimecodedComment = {
text: string;
};
-const TIMECODE_RE =
- /^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
const QUICK_REACTIONS = ["😂", "😍", "😮", "🙌", "👍", "👎"];
const DEFAULT_PLAYBACK_SPEED = 1;
const INLINE_SPEED_CONTROL_MIN_WIDTH = 220;
const PLAYBACK_SPEEDS = [2, 1.75, 1.5, 1.25, 1, 0.75, 0.5, 0.25];
-const TIMECODE_ACCENT_CLASS =
- "bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.15)] text-[hsl(var(--buzz-video-review-accent-foreground,var(--buzz-video-review-accent,var(--primary))))]";
-const TIMECODE_ACCENT_HOVER_CLASS =
- "hover:bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.3)]";
/**
* Frosted-glass backing layer for floating media controls. The parent must
@@ -188,40 +189,11 @@ function isPlaybackSpeedOption(speed: number): boolean {
return PLAYBACK_SPEEDS.some((option) => option === speed);
}
-function parseTimecode(value: string): number | null {
- const parts = value.split(":").map((part) => Number(part));
- if (parts.some((part) => !Number.isFinite(part) || part < 0)) {
- return null;
- }
-
- if (parts.length === 2) {
- return parts[0] * 60 + parts[1];
- }
-
- if (parts.length === 3) {
- return parts[0] * 3600 + parts[1] * 60 + parts[2];
- }
-
- return null;
-}
-
function parseTimecodedComment(comment: VideoReviewComment): TimecodedComment {
- const match = comment.body.match(TIMECODE_RE);
- if (!match) {
- return {
- comment,
- seconds: null,
- timecode: null,
- text: comment.body.trim(),
- };
- }
-
- return {
- comment,
- seconds: parseTimecode(match[1]),
- timecode: match[1],
- text: comment.body.slice(match[0].length).trim(),
- };
+ const parsed = parseVideoReviewTimecode(comment.body);
+ return parsed
+ ? { comment, ...parsed }
+ : { comment, seconds: null, text: comment.body.trim(), timecode: null };
}
function sortTimecodedComments(
@@ -928,20 +900,32 @@ export function VideoPlayer({
video.muted = value <= 0;
}, []);
+ const openReviewAt = React.useCallback(
+ (seconds: number) => {
+ const video = videoRef.current;
+ video?.pause();
+ const safeSeconds = Number.isFinite(seconds) ? Math.max(seconds, 0) : 0;
+ const nextSeconds =
+ duration > 0 ? Math.min(safeSeconds, duration) : safeSeconds;
+ setPendingSeekSeconds(nextSeconds);
+ setReviewCurrentTime(nextSeconds);
+ setReviewOpen(true);
+ },
+ [duration, setReviewCurrentTime, setReviewOpen],
+ );
+ useRegisterVideoReview(reviewContext, persistedReviewKey, openReviewAt);
+
const handleOpenReview = React.useCallback(
(event?: React.SyntheticEvent) => {
event?.stopPropagation();
const video = videoRef.current;
- video?.pause();
const startTime =
video && Number.isFinite(video.currentTime)
? video.currentTime
: currentTime;
- setPendingSeekSeconds(startTime);
- setReviewCurrentTime(startTime);
- setReviewOpen(true);
+ openReviewAt(startTime);
},
- [currentTime, setReviewCurrentTime, setReviewOpen],
+ [currentTime, openReviewAt],
);
const handleReviewOpenChange = React.useCallback(
@@ -989,7 +973,12 @@ export function VideoPlayer({
maxHeight: 256,
width: inlineSurfaceWidth,
};
- const showControls = started && !hasError;
+ const hideInlineControls = !started || isPlaying;
+ const inlineControlsRevealClass = cn(
+ "transition-opacity duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none",
+ hideInlineControls &&
+ "opacity-0 group-focus-within/inline-controls:opacity-100 group-hover/video:opacity-100",
+ );
return (
<>
@@ -1015,7 +1004,7 @@ export function VideoPlayer({
poster={poster}
preload="metadata"
src={src}
- onClick={showControls ? handleTogglePlay : undefined}
+ onClick={started ? handleTogglePlay : undefined}
onDurationChange={(event) =>
handleMediaDuration(event.currentTarget.duration)
}
@@ -1072,16 +1061,29 @@ export function VideoPlayer({
}}
onWaiting={() => setIsBuffering(true)}
/>
- {!started && !hasError ? (
+ {!hasError && !isBuffering ? (
-
+
-
+ {isPlaying ? (
+
+ ) : (
+
+ )}
) : null}
@@ -1109,33 +1111,22 @@ export function VideoPlayer({
) : null}
- {/* Slide (not fade) the pill out: animating opacity on an ancestor
- of a backdrop-filter flattens the glass into a plain fill
- mid-transition, which reads as a flicker. The video container's
- overflow-hidden clips the slid-out pill. */}
- {showControls ? (
+ {!hasError ? (
-
-
-
- {isPlaying ? (
-
- ) : (
-
- )}
-
+
+
(null);
+ const [hasVisibleFrame, setHasVisibleFrame] = React.useState(false);
const [videoAreaSize, setVideoAreaSize] = React.useState<{
height: number;
width: number;
@@ -1364,6 +1356,7 @@ function VideoReviewDialog({
React.useEffect(() => {
if (!open) {
setIsComposerMounted(false);
+ setHasVisibleFrame(false);
return;
}
// Two frames: one for the dialog to paint, one for the browser to
@@ -1715,7 +1708,7 @@ function VideoReviewDialog({
className="h-full w-full min-h-0 object-contain"
playsInline
poster={poster}
- preload="metadata"
+ preload="auto"
src={src}
onClick={togglePlay}
onDurationChange={(event) =>
@@ -1740,6 +1733,7 @@ function VideoReviewDialog({
syncCurrentTime(pendingSeekSeconds);
}
}}
+ onLoadedData={() => setHasVisibleFrame(true)}
onPause={(event) => {
syncCurrentTime(event.currentTarget.currentTime);
setIsPlaying(false);
@@ -1748,7 +1742,15 @@ function VideoReviewDialog({
syncCurrentTime(event.currentTarget.currentTime);
setIsPlaying(true);
}}
- onSeeked={reviewSeek.handleSeeked}
+ onSeeked={(event) => {
+ reviewSeek.handleSeeked();
+ if (
+ event.currentTarget.readyState >=
+ HTMLMediaElement.HAVE_CURRENT_DATA
+ ) {
+ setHasVisibleFrame(true);
+ }
+ }}
onTimeUpdate={(event) => {
syncCurrentTime(event.currentTarget.currentTime);
}}
@@ -1757,6 +1759,10 @@ function VideoReviewDialog({
setMuted(event.currentTarget.muted);
}}
/>
+
@@ -1903,12 +1909,12 @@ function VideoReviewDialog({
{showCommentsPanel ? (
-
+
@@ -1960,7 +1966,7 @@ function VideoReviewDialog({
className={cn(
"rounded-md px-2 py-1 font-mono text-xs font-semibold transition-colors",
!replyTarget && postAtCurrentFrame
- ? TIMECODE_ACCENT_CLASS
+ ? VIDEO_REVIEW_TIMECODE_ACCENT_CLASS
: "bg-muted text-muted-foreground/70",
)}
data-testid="video-review-composer-timecode"
@@ -2124,19 +2130,10 @@ function VideoReviewCommentBody({
const text = item.text || item.comment.body;
const timecodeButton =
item.seconds !== null && item.timecode ? (
-
onSeek(item.seconds ?? 0)}
- >
- {item.timecode}
-
+ />
) : null;
return (
diff --git a/desktop/src/shared/ui/VideoReviewNavigation.tsx b/desktop/src/shared/ui/VideoReviewNavigation.tsx
new file mode 100644
index 00000000000..76c8bda0290
--- /dev/null
+++ b/desktop/src/shared/ui/VideoReviewNavigation.tsx
@@ -0,0 +1,101 @@
+import * as React from "react";
+
+type OpenVideoReview = (seconds: number) => void;
+
+type VideoReviewNavigationValue = {
+ open: (rootEventId: string, seconds: number) => void;
+ register: (
+ rootEventId: string,
+ attachmentKey: string,
+ handler: OpenVideoReview,
+ ) => () => void;
+};
+
+const VideoReviewNavigationContext =
+ React.createContext
(null);
+
+export function VideoReviewNavigationProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ // Review comments are scoped to their message, not an individual attachment.
+ // Keep attachment registration order so a multi-video message always opens
+ // its first video instead of whichever player happened to update last.
+ const handlersRef = React.useRef(
+ new Map>>(),
+ );
+ const value = React.useMemo(
+ () => ({
+ open(rootEventId, seconds) {
+ handlersRef.current
+ .get(rootEventId)
+ ?.values()
+ .next()
+ .value?.values()
+ .next()
+ .value?.(seconds);
+ },
+ register(rootEventId, attachmentKey, handler) {
+ let rootHandlers = handlersRef.current.get(rootEventId);
+ if (!rootHandlers) {
+ rootHandlers = new Map();
+ handlersRef.current.set(rootEventId, rootHandlers);
+ }
+ let attachmentHandlers = rootHandlers.get(attachmentKey);
+ if (!attachmentHandlers) {
+ attachmentHandlers = new Set();
+ rootHandlers.set(attachmentKey, attachmentHandlers);
+ }
+ attachmentHandlers.add(handler);
+ return () => {
+ const registeredHandlers = handlersRef.current.get(rootEventId);
+ const registeredAttachmentHandlers =
+ registeredHandlers?.get(attachmentKey);
+ registeredAttachmentHandlers?.delete(handler);
+ if (registeredAttachmentHandlers?.size === 0) {
+ registeredHandlers?.delete(attachmentKey);
+ }
+ if (registeredHandlers?.size === 0) {
+ handlersRef.current.delete(rootEventId);
+ }
+ };
+ },
+ }),
+ [],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useOpenVideoReviewAt():
+ | VideoReviewNavigationValue["open"]
+ | null {
+ return React.useContext(VideoReviewNavigationContext)?.open ?? null;
+}
+
+export function useRegisterVideoReview(
+ reviewContext: { rootEventId?: string } | undefined,
+ attachmentKey: string,
+ handler: OpenVideoReview,
+): void {
+ const navigation = React.useContext(VideoReviewNavigationContext);
+ const rootEventId = reviewContext?.rootEventId;
+ const handlerRef = React.useRef(handler);
+ React.useLayoutEffect(() => {
+ handlerRef.current = handler;
+ }, [handler]);
+ const registeredHandler = React.useCallback(
+ (seconds: number) => handlerRef.current(seconds),
+ [],
+ );
+
+ React.useEffect(() => {
+ if (!navigation || !rootEventId) return;
+ return navigation.register(rootEventId, attachmentKey, registeredHandler);
+ }, [attachmentKey, navigation, registeredHandler, rootEventId]);
+}
diff --git a/desktop/src/shared/ui/VideoReviewPosterPreview.tsx b/desktop/src/shared/ui/VideoReviewPosterPreview.tsx
new file mode 100644
index 00000000000..c2c18befc4e
--- /dev/null
+++ b/desktop/src/shared/ui/VideoReviewPosterPreview.tsx
@@ -0,0 +1,22 @@
+export function VideoReviewPosterPreview({
+ poster,
+ visible,
+}: {
+ poster?: string;
+ visible: boolean;
+}) {
+ if (!poster || !visible) return null;
+
+ return (
+
+ );
+}
diff --git a/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx b/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx
new file mode 100644
index 00000000000..53904f886f1
--- /dev/null
+++ b/desktop/src/shared/ui/VideoReviewTimecodeButton.tsx
@@ -0,0 +1,42 @@
+import type * as React from "react";
+
+import { cn } from "@/shared/lib/cn";
+
+const TIMECODE_ACCENT_CLASS =
+ "bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.15)] text-[hsl(var(--buzz-video-review-accent-foreground,var(--buzz-video-review-accent,var(--primary))))]";
+const TIMECODE_ACCENT_HOVER_CLASS =
+ "hover:bg-[hsl(var(--buzz-video-review-accent,var(--primary))/0.3)]";
+const MESSAGE_TIMECODE_ACCENT_CLASS =
+ "bg-primary/15 text-primary hover:bg-primary/30";
+
+export function VideoReviewTimecodeButton({
+ className,
+ onClick,
+ surface = "review",
+ timecode,
+}: {
+ className?: string;
+ onClick: React.MouseEventHandler;
+ surface?: "message" | "review";
+ timecode: string;
+}) {
+ return (
+
+ {timecode}
+
+ );
+}
+
+export const VIDEO_REVIEW_TIMECODE_ACCENT_CLASS = TIMECODE_ACCENT_CLASS;
diff --git a/desktop/src/shared/ui/videoReviewTimecode.test.mjs b/desktop/src/shared/ui/videoReviewTimecode.test.mjs
new file mode 100644
index 00000000000..40c11ebde42
--- /dev/null
+++ b/desktop/src/shared/ui/videoReviewTimecode.test.mjs
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { parseVideoReviewTimecode } from "./videoReviewTimecode.ts";
+
+test("parseVideoReviewTimecode extracts supported leading timecodes", () => {
+ assert.deepEqual(parseVideoReviewTimecode("[00:10.7] Tighten **this** cut"), {
+ seconds: 10.7,
+ text: "Tighten **this** cut",
+ timecode: "00:10.7",
+ });
+ assert.deepEqual(parseVideoReviewTimecode("[1:02:03] Long-form note"), {
+ seconds: 3723,
+ text: "Long-form note",
+ timecode: "1:02:03",
+ });
+});
+
+test("parseVideoReviewTimecode ignores ordinary bracketed markdown", () => {
+ assert.equal(parseVideoReviewTimecode("[docs](https://example.com)"), null);
+ assert.equal(parseVideoReviewTimecode("Comment at [00:10]"), null);
+});
diff --git a/desktop/src/shared/ui/videoReviewTimecode.ts b/desktop/src/shared/ui/videoReviewTimecode.ts
new file mode 100644
index 00000000000..6b097323c12
--- /dev/null
+++ b/desktop/src/shared/ui/videoReviewTimecode.ts
@@ -0,0 +1,41 @@
+export type VideoReviewTimecode = {
+ seconds: number;
+ text: string;
+ timecode: string;
+};
+
+const TIMECODE_RE =
+ /^\s*\[((?:(?:\d{1,2}:)?\d{1,2}:)?\d{2}(?:\.\d{1,3})?)\]\s*/;
+
+function parseTimecode(value: string): number | null {
+ const parts = value.split(":").map((part) => Number(part));
+ if (parts.some((part) => !Number.isFinite(part) || part < 0)) {
+ return null;
+ }
+
+ if (parts.length === 2) {
+ return parts[0] * 60 + parts[1];
+ }
+
+ if (parts.length === 3) {
+ return parts[0] * 3600 + parts[1] * 60 + parts[2];
+ }
+
+ return null;
+}
+
+export function parseVideoReviewTimecode(
+ content: string,
+): VideoReviewTimecode | null {
+ const match = content.match(TIMECODE_RE);
+ if (!match) return null;
+
+ const seconds = parseTimecode(match[1]);
+ if (seconds === null) return null;
+
+ return {
+ seconds,
+ text: content.slice(match[0].length).trim(),
+ timecode: match[1],
+ };
+}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index abf74078dac..daba1cfbb96 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -180,6 +180,8 @@ type E2eConfig = {
pocketVoiceImportResult?: "success" | "cancel" | "invalid";
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
+ /** Full repository file count reported alongside the capped snapshot payload. */
+ projectRepoTotalFileCount?: number;
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
builderlabAuth?: {
email?: string;
@@ -11265,6 +11267,7 @@ export function maybeInstallE2eTauriMocks() {
last_commit_at: Math.floor(Date.now() / 1000) - 7_200,
},
],
+ total_file_count: activeConfig?.mock?.projectRepoTotalFileCount ?? 4,
files: [
{
path: "desktop/src/features/projects/ui/ProjectDetailScreen.tsx",
diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts
index 0f2e8ce6177..c5416336773 100644
--- a/desktop/tests/e2e/onboarding.spec.ts
+++ b/desktop/tests/e2e/onboarding.spec.ts
@@ -202,8 +202,23 @@ async function expectWelcomeComposerBannerLayout(page: Page) {
const bannerBox = await banner.boundingBox();
const personaMentionBox = await personaMention.boundingBox();
const composerBox = await composer.boundingBox();
+ const dockBackdropBox = await page
+ .getByTestId("composer-dock-backdrop")
+ .locator("div")
+ .boundingBox();
+ const guidanceLayer = page.getByTestId("welcome-composer-guidance-layer");
+ const guidanceBackdrop = page.getByTestId(
+ "welcome-composer-guidance-backdrop",
+ );
+ const guidanceBackdropBox = await guidanceBackdrop.boundingBox();
- if (!bannerBox || !personaMentionBox || !composerBox) {
+ if (
+ !bannerBox ||
+ !personaMentionBox ||
+ !composerBox ||
+ !dockBackdropBox ||
+ !guidanceBackdropBox
+ ) {
throw new Error("Could not measure welcome composer banner layout");
}
@@ -212,11 +227,40 @@ async function expectWelcomeComposerBannerLayout(page: Page) {
).toBe(0);
expect(bannerBox.y).toBeLessThan(composerBox.y);
expect(bannerBox.y + bannerBox.height).toBeGreaterThan(composerBox.y);
+ expect(Math.abs(dockBackdropBox.y - composerBox.y)).toBeLessThanOrEqual(1);
+ expect(guidanceBackdropBox.y).toBeLessThanOrEqual(bannerBox.y);
+ expect(
+ Math.abs(
+ guidanceBackdropBox.y + guidanceBackdropBox.height - composerBox.y,
+ ),
+ ).toBeLessThanOrEqual(1);
+ const [guidanceZIndex, backdropZIndex] = await Promise.all([
+ guidanceLayer.evaluate((element) =>
+ Number(window.getComputedStyle(element).zIndex),
+ ),
+ page
+ .getByTestId("composer-dock-backdrop")
+ .evaluate((element) => Number(window.getComputedStyle(element).zIndex)),
+ ]);
+ expect(guidanceZIndex).toBeLessThan(backdropZIndex);
+ expect(
+ await page
+ .getByTestId("channel-composer-overlay")
+ .getByTestId("welcome-composer-guidance-layer")
+ .count(),
+ ).toBe(1);
+ expect(
+ await page
+ .getByTestId("composer-dock-backdrop")
+ .getByTestId("welcome-composer-guidance-layer")
+ .count(),
+ ).toBe(0);
const radii = await banner.evaluate((element) => {
const styles = window.getComputedStyle(element);
return {
backdropFilter: styles.backdropFilter,
+ backgroundColor: styles.backgroundColor,
bottomLeft: styles.borderBottomLeftRadius,
bottomRight: styles.borderBottomRightRadius,
filter: styles.filter,
@@ -227,11 +271,24 @@ async function expectWelcomeComposerBannerLayout(page: Page) {
willChange: styles.willChange,
};
});
+ const composerBackgroundColor = await composer.evaluate(
+ (element) => window.getComputedStyle(element).backgroundColor,
+ );
+ const dockBackdropFilter = await page
+ .getByTestId("composer-dock-backdrop")
+ .locator("div")
+ .evaluate((element) => window.getComputedStyle(element).backdropFilter);
+ const guidanceBackdropFilter = await guidanceBackdrop.evaluate(
+ (element) => window.getComputedStyle(element).backdropFilter,
+ );
expect(radii.topLeft).toBe(radii.topRight);
expect(radii.bottomLeft).toBe("0px");
expect(radii.bottomRight).toBe("0px");
expect(radii.backdropFilter).toBe("none");
+ expect(radii.backgroundColor).not.toBe(composerBackgroundColor);
+ expect(dockBackdropFilter).not.toBe("none");
+ expect(guidanceBackdropFilter).toBe(dockBackdropFilter);
expect(radii.filter).toBe("none");
expect(radii.transform).toBe("none");
expect(radii.willChange).toBe("auto");
@@ -385,6 +442,11 @@ async function expectWelcomeComposerBannerCompletesAfterPersonaMention(
) {
const banner = page.getByTestId("welcome-composer-guide-banner");
const channelIntro = page.getByTestId("message-channel-intro");
+ const composer = page.getByTestId("message-composer");
+ const initialComposerBox = await composer.boundingBox();
+ if (!initialComposerBox) {
+ throw new Error("Could not measure the Welcome composer");
+ }
await page.getByTestId("message-input").fill("Thanks @Fizz");
await page.getByTestId("send-message").click();
@@ -403,6 +465,17 @@ async function expectWelcomeComposerBannerCompletesAfterPersonaMention(
await expect(banner).toContainText("Nice work.");
await expect(banner).not.toContainText("Try mentioning");
await expect(channelIntro).toBeVisible();
+ const completeComposerBox = await composer.boundingBox();
+ expect(completeComposerBox).not.toBeNull();
+ expect(
+ Math.abs((completeComposerBox?.y ?? 0) - initialComposerBox.y),
+ ).toBeLessThanOrEqual(1);
+ await expect(banner).toHaveCount(0, { timeout: 6_000 });
+ const hiddenComposerBox = await composer.boundingBox();
+ expect(hiddenComposerBox).not.toBeNull();
+ expect(
+ Math.abs((hiddenComposerBox?.y ?? 0) - initialComposerBox.y),
+ ).toBeLessThanOrEqual(1);
}
async function getMockChannels(page: Page) {
diff --git a/desktop/tests/e2e/project-file-tree.spec.ts b/desktop/tests/e2e/project-file-tree.spec.ts
new file mode 100644
index 00000000000..8612444f735
--- /dev/null
+++ b/desktop/tests/e2e/project-file-tree.spec.ts
@@ -0,0 +1,45 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+async function enableProjectsFeature(page: import("@playwright/test").Page) {
+ await page.addInitScript(() => {
+ window.localStorage.setItem(
+ "buzz-feature-overrides-v1",
+ JSON.stringify({ projects: true }),
+ );
+ });
+}
+
+async function openBuzzProject(page: import("@playwright/test").Page) {
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.getByTestId("open-projects-view").click();
+ await page.getByRole("button", { name: "Repositories", exact: true }).click();
+ const projectEntry = page
+ .locator(
+ '[data-testid="repository-card-buzz"], [data-testid="repository-row-buzz"]',
+ )
+ .first();
+ await expect(projectEntry).toBeVisible({ timeout: 10_000 });
+ await projectEntry.click();
+}
+
+test("repository files disclose when the backend tree is truncated", async ({
+ page,
+}) => {
+ await enableProjectsFeature(page);
+ await installMockBridge(page, { projectRepoTotalFileCount: 661 });
+ await openBuzzProject(page);
+
+ await page.getByRole("tab", { name: "Files", exact: true }).click();
+
+ await expect(
+ page.getByText("· 4 of 661 files", { exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ "Showing the first 4 of 661 files. Some files and folders are not included.",
+ { exact: true },
+ ),
+ ).toBeVisible();
+});
diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts
index 488485292e5..763d54b8726 100644
--- a/desktop/tests/e2e/video-attachment.spec.ts
+++ b/desktop/tests/e2e/video-attachment.spec.ts
@@ -624,12 +624,20 @@ test("video upload previews use poster frames and inline videos open review mode
// must not remount the review dialog or wipe an in-progress comment draft.
await commentBox.click();
await commentBox.fill("Second pass note");
+ const commentEditor = await commentBox.elementHandle();
+ if (!commentEditor) {
+ throw new Error("Expected the review comment editor to be mounted.");
+ }
await emitMockMessage(page, "general", "Unrelated chatter mid-review");
- await expect(
- page
- .getByTestId("message-row")
- .filter({ hasText: "Unrelated chatter mid-review" }),
- ).toHaveCount(1);
+ await page.evaluate(
+ () =>
+ new Promise((resolve) => {
+ requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
+ }),
+ );
+ expect(await commentEditor.evaluate((element) => element.isConnected)).toBe(
+ true,
+ );
await expect(commentBox).toHaveText("Second pass note");
await expect(commentBox).toBeFocused();
await expect(page.getByTestId("video-review-composer-timecode")).toHaveText(
@@ -746,6 +754,14 @@ test("video upload previews use poster frames and inline videos open review mode
.click({ position: { x: 4, y: 4 } });
await expect(page.getByTestId("video-review-dialog")).toHaveCount(0);
+ // Re-open the channel so the thread summary is sourced from the persisted
+ // mock history instead of depending on whether the background timeline row
+ // stayed mounted while the modal handled live comment updates.
+ await page.getByTestId("channel-random").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("random");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+
const videoSummaryRow = page.locator(
`[data-thread-head-id="${videoMessageId}"]`,
);
@@ -771,6 +787,103 @@ test("video upload previews use poster frames and inline videos open review mode
).toContainText("Color pass looks right");
});
+test("inline video hover reveals a timeline without a second play control", async ({
+ page,
+}) => {
+ await installVideoReviewHarness(page);
+
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ await emitMockMessage(page, "general", ``, {
+ extraTags: [
+ [
+ "imeta",
+ `url ${VIDEO_URL}`,
+ "m video/mp4",
+ `x ${VIDEO_SHA}`,
+ "size 987654",
+ "dim 160x80",
+ "duration 12.5",
+ `image ${POSTER_DATA_URL}`,
+ "filename launch-demo.mp4",
+ ],
+ ],
+ });
+
+ const player = page.getByTestId("video-player").last();
+ const video = player.locator("video");
+ const surface = video.locator("..");
+ const centerPlayback = player.getByTestId("video-inline-center-playback");
+ const centerIcon = player.getByTestId("video-inline-center-icon");
+ const controls = player.getByTestId("video-inline-controls");
+
+ await expect(centerPlayback).toHaveAttribute("aria-label", "Play video");
+ await expect(
+ controls.getByRole("button", { name: /^(?:Play|Pause) video$/ }),
+ ).toHaveCount(0);
+ await expect(
+ player.getByRole("button", { name: "Open video review" }),
+ ).toBeVisible();
+ await expect(player.getByTestId("video-inline-duration")).toHaveText("00:12");
+
+ await video.evaluate((element) => {
+ element.currentTime = 6.25;
+ element.dispatchEvent(new Event("timeupdate"));
+ });
+ await expect
+ .poll(() =>
+ player
+ .getByTestId("video-inline-progress-fill")
+ .evaluate((element) => element.style.width),
+ )
+ .toBe("50%");
+
+ const restingControlsBox = await controls.boundingBox();
+ const restingIconTransform = await centerIcon.evaluate(
+ (element) => window.getComputedStyle(element).transform,
+ );
+ expect(restingControlsBox).not.toBeNull();
+ await expect(controls).toHaveCSS("opacity", "0");
+ await surface.hover();
+ await expect(controls).toHaveCSS("opacity", "1");
+ const hoveredControlsBox = await controls.boundingBox();
+ expect(hoveredControlsBox).not.toBeNull();
+ expect(
+ Math.abs((hoveredControlsBox?.y ?? 0) - (restingControlsBox?.y ?? 0)),
+ ).toBeLessThan(0.5);
+ await expect
+ .poll(() =>
+ centerIcon.evaluate(
+ (element) => window.getComputedStyle(element).transform,
+ ),
+ )
+ .toBe(restingIconTransform);
+
+ await centerPlayback.click();
+ await expect
+ .poll(() => video.evaluate((element) => element.paused))
+ .toBe(false);
+ await expect(centerPlayback).toHaveAttribute("aria-label", "Pause video");
+ await page.mouse.move(0, 0);
+ await expect(centerPlayback).toHaveCSS("opacity", "0");
+ await surface.hover();
+ await expect(centerPlayback).toHaveCSS("opacity", "1");
+ await expect(centerPlayback).toHaveCSS("transition-property", "opacity");
+ await expect(centerPlayback).toHaveCSS("transition-duration", "0.15s");
+
+ await page.emulateMedia({ reducedMotion: "reduce" });
+ await expect(centerPlayback).toHaveCSS("transition-property", "none");
+ await expect(controls).toHaveCSS("transition-property", "none");
+
+ await centerPlayback.click();
+ await expect
+ .poll(() => video.evaluate((element) => element.paused))
+ .toBe(true);
+});
+
test("video replies in threads open the review comments view", async ({
page,
}) => {
@@ -791,6 +904,19 @@ test("video replies in threads open the review comments view", async ({
"general",
``,
{
+ extraTags: [
+ [
+ "imeta",
+ `url ${VIDEO_URL}`,
+ "m video/mp4",
+ `x ${VIDEO_SHA}`,
+ "size 987654",
+ "dim 160x80",
+ "duration 12.5",
+ `image ${POSTER_DATA_URL}`,
+ "filename launch-demo.mp4",
+ ],
+ ],
parentEventId: root.id,
},
)) as { id: string };
@@ -808,9 +934,73 @@ test("video replies in threads open the review comments view", async ({
name: "Open video review",
});
await expect(reviewButton).toBeVisible();
- await reviewButton.click();
+ const nestedVideoSummary = threadReplies.locator(
+ `[data-thread-head-id="${videoReply.id}"]`,
+ );
+ await expect(nestedVideoSummary).toBeVisible();
+ await expect(threadReplies.locator("video")).toHaveAttribute(
+ "preload",
+ "metadata",
+ );
+ await nestedVideoSummary.click();
+ const outsideTimecode = threadReplies.getByRole("button", {
+ name: "Jump to 00:01",
+ });
+ await expect(outsideTimecode).toBeVisible();
+ await expect(outsideTimecode).toHaveAttribute(
+ "data-testid",
+ "video-review-comment-timecode",
+ );
+ const outsideTimecodeStyles = await outsideTimecode.evaluate((element) => {
+ const styles = window.getComputedStyle(element);
+ return {
+ backgroundColor: styles.backgroundColor,
+ borderRadius: styles.borderRadius,
+ fontFamily: styles.fontFamily,
+ height: styles.height,
+ paddingLeft: styles.paddingLeft,
+ paddingRight: styles.paddingRight,
+ };
+ });
+ expect(outsideTimecodeStyles.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
+ await outsideTimecode.click();
const reviewDialog = page.getByTestId("video-review-dialog");
+ const reviewVideo = reviewDialog.locator("video");
+ const posterPreview = reviewDialog.getByTestId("video-review-poster-preview");
+ await expect(posterPreview).toBeVisible();
+ await expect(posterPreview).toHaveAttribute("src", POSTER_DATA_URL);
+ await expect(
+ reviewDialog.getByTestId("video-review-comments-panel"),
+ ).toHaveCSS("background-color", "oklch(0.145 0 0)");
+ const modalTimecode = reviewDialog
+ .getByRole("button", { name: "Jump to 00:01" })
+ .first();
+ await expect(modalTimecode).toBeVisible();
+ const modalTimecodeStyles = await modalTimecode.evaluate((element) => {
+ const styles = window.getComputedStyle(element);
+ return {
+ borderRadius: styles.borderRadius,
+ fontFamily: styles.fontFamily,
+ height: styles.height,
+ paddingLeft: styles.paddingLeft,
+ paddingRight: styles.paddingRight,
+ };
+ });
+ expect(outsideTimecodeStyles).toMatchObject(modalTimecodeStyles);
+ await expect(reviewVideo).toHaveAttribute("preload", "auto");
+ await expect
+ .poll(() =>
+ reviewVideo.evaluate((video) => (video as HTMLVideoElement).paused),
+ )
+ .toBe(true);
+ await expect
+ .poll(() =>
+ reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
+ )
+ .toBe(1);
+ await reviewVideo.dispatchEvent("loadeddata");
+ await expect(posterPreview).toHaveCount(0);
await expect(
reviewDialog.getByTestId("video-review-comments-panel"),
).toBeVisible();
@@ -820,6 +1010,76 @@ test("video replies in threads open the review comments view", async ({
);
});
+test("message timecodes deterministically open the first attached video", async ({
+ page,
+}) => {
+ await installVideoReviewHarness(page);
+
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await expect(page.getByTestId("chat-title")).toHaveText("general");
+ await waitForMockLiveSubscription(page, "general");
+
+ const videoMessage = (await emitMockMessage(
+ page,
+ "general",
+ `\n\n`,
+ {
+ extraTags: [
+ [
+ "imeta",
+ `url ${VIDEO_URL}`,
+ "m video/mp4",
+ `x ${VIDEO_SHA}`,
+ "dim 160x80",
+ "duration 12.5",
+ "filename first-cut.mp4",
+ ],
+ [
+ "imeta",
+ `url ${PORTRAIT_VIDEO_URL}`,
+ "m video/mp4",
+ `x ${PORTRAIT_VIDEO_SHA}`,
+ "dim 80x160",
+ "duration 12.5",
+ "filename second-cut.mp4",
+ ],
+ ],
+ },
+ )) as { id: string };
+ await emitMockMessage(page, "general", "[00:01] Check this frame.", {
+ parentEventId: videoMessage.id,
+ });
+
+ const threadSummary = page.locator(
+ `[data-thread-head-id="${videoMessage.id}"]`,
+ );
+ await expect(threadSummary).toBeVisible();
+ await threadSummary.click();
+
+ const threadPanel = page.getByTestId("message-thread-panel");
+ const threadHead = threadPanel.getByTestId("message-thread-head");
+ const inlineVideos = threadHead.locator("video");
+ await expect(inlineVideos).toHaveCount(2);
+ const firstVideoSrc = await inlineVideos.nth(0).getAttribute("src");
+ const secondVideoSrc = await inlineVideos.nth(1).getAttribute("src");
+ expect(firstVideoSrc).toBeTruthy();
+ expect(firstVideoSrc).not.toBe(secondVideoSrc);
+
+ await threadPanel
+ .getByTestId("message-thread-replies")
+ .getByRole("button", { name: "Jump to 00:01" })
+ .click();
+
+ const reviewVideo = page.getByTestId("video-review-dialog").locator("video");
+ await expect(reviewVideo).toHaveAttribute("src", firstVideoSrc ?? "");
+ await expect
+ .poll(() =>
+ reviewVideo.evaluate((video) => (video as HTMLVideoElement).currentTime),
+ )
+ .toBe(1);
+});
+
test("narrow inline videos hide playback speed control", async ({ page }) => {
await installVideoReviewHarness(page);
diff --git a/desktop/tests/e2e/where-to-run-config.spec.ts b/desktop/tests/e2e/where-to-run-config.spec.ts
index 869e9b2138c..1aa2f5c1260 100644
--- a/desktop/tests/e2e/where-to-run-config.spec.ts
+++ b/desktop/tests/e2e/where-to-run-config.spec.ts
@@ -9,9 +9,12 @@
*
* Covers:
* - typing into a defaultless provider field sticks, and the provider is
- * probed exactly once for the selection (not once per keystroke)
+ * probed exactly once for the selection (not once per keystroke or
+ * Advanced disclosure toggle)
* - the config form is gated on probe resolution (no half-rendered form),
* and defaults prefill exactly once when a slow probe lands
+ * - collapsing Advanced during an incomplete remote setup keeps the submit
+ * blocker visible through the Required badge
* - switching provider → local → provider re-probes and resets cleanly
*
* The stale-closure merge on probe resolution (defaults beneath in-flight
@@ -63,7 +66,28 @@ async function probeInvocations(page: Page): Promise {
);
}
-/** Open the create-agent dialog and select the mocked provider in "Run on". */
+async function selectRunOnOption(
+ page: Page,
+ dialog: import("@playwright/test").Locator,
+ optionName: string,
+) {
+ const trigger = dialog.locator("#agent-run-on");
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+ await trigger.press("Enter");
+
+ const option = page.getByRole("menuitemradio", {
+ exact: true,
+ name: optionName,
+ });
+ await expect(option).toBeVisible();
+ // The shared PersonaDropdownField supports keyboard selection. Using it here
+ // avoids racing the menu's open animation when this test changes locations
+ // repeatedly.
+ await option.press("Enter");
+ await expect(trigger).toHaveAttribute("aria-expanded", "false");
+}
+
+/** Open Advanced in the create-agent dialog and select the mocked provider. */
async function openCreateDialogOnProvider(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
@@ -71,7 +95,22 @@ async function openCreateDialogOnProvider(page: Page) {
await page.getByRole("menuitem", { name: "Create agent" }).click();
const dialog = page.getByTestId("persona-dialog");
await expect(dialog).toBeVisible({ timeout: 10_000 });
- await dialog.locator("#agent-run-on").selectOption(PROVIDER.id);
+ const advanced = dialog.getByRole("button", {
+ name: "Advanced",
+ exact: true,
+ });
+ await expect(advanced).toHaveAttribute("aria-expanded", "false");
+ await expect(dialog.locator("#agent-run-on")).toHaveCount(0);
+ await advanced.click();
+ await expect(advanced).toHaveAttribute("aria-expanded", "true");
+ const respondTo = dialog.getByTestId("agent-respond-to");
+ const runOn = dialog.locator("#agent-run-on");
+ await expect(respondTo).toBeVisible();
+ await expect(runOn).toBeVisible();
+ expect(await respondTo.evaluate((element) => element.offsetTop)).toBeLessThan(
+ await runOn.evaluate((element) => element.offsetTop),
+ );
+ await selectRunOnOption(page, dialog, PROVIDER.id);
return dialog;
}
@@ -95,7 +134,21 @@ test("typing into a defaultless provider field sticks and probes only once", asy
await contextField.pressSequentially("prod-us-west", { delay: 20 });
await expect(contextField).toHaveValue("prod-us-west");
- // One selection, one probe — keystrokes must not refire it.
+ // One selection, one probe — keystrokes and Advanced disclosure toggles
+ // must not refire executable provider discovery after it has completed.
+ expect(await probeInvocations(page)).toBe(1);
+ const advanced = dialog.getByRole("button", {
+ name: "Advanced",
+ exact: true,
+ });
+ await advanced.click();
+ await expect(advanced).toHaveAttribute("aria-expanded", "false");
+ await expect(dialog.locator("#agent-run-on")).toHaveCount(0);
+ await advanced.click();
+ await expect(advanced).toHaveAttribute("aria-expanded", "true");
+ await expect(dialog.locator("#provider-cfg-context")).toHaveValue(
+ "prod-us-west",
+ );
expect(await probeInvocations(page)).toBe(1);
});
@@ -128,6 +181,31 @@ test("config fields render only after a slow probe resolves, with defaults", asy
expect(await probeInvocations(page)).toBe(1);
});
+test("collapsed Advanced marks incomplete remote setup as required", async ({
+ page,
+}) => {
+ await installMockBridge(page, {
+ backendProviders: [PROVIDER],
+ backendProviderProbeResult: PROBE_RESULT,
+ backendProviderProbeDelayMs: 10_000,
+ });
+ const dialog = await openCreateDialogOnProvider(page);
+ const advanced = dialog.getByRole("button", {
+ name: "Advanced",
+ exact: true,
+ });
+ const submit = dialog.getByTestId("persona-dialog-submit");
+
+ await expect(submit).toBeDisabled();
+ await advanced.click();
+ await expect(advanced).toHaveAttribute("aria-expanded", "false");
+ await expect(dialog.locator("#agent-run-on")).toHaveCount(0);
+ await expect(
+ dialog.getByTestId("persona-advanced-required-badge"),
+ ).toHaveText("Required");
+ await expect(submit).toBeDisabled();
+});
+
test("provider → local → provider re-probes and resets the config", async ({
page,
}) => {
@@ -141,10 +219,10 @@ test("provider → local → provider re-probes and resets the config", async ({
await expect(contextField).toBeVisible({ timeout: 10_000 });
await contextField.fill("stale-value");
- await dialog.locator("#agent-run-on").selectOption("local");
+ await selectRunOnOption(page, dialog, "This computer");
await expect(contextField).toHaveCount(0);
- await dialog.locator("#agent-run-on").selectOption(PROVIDER.id);
+ await selectRunOnOption(page, dialog, PROVIDER.id);
await expect(dialog.locator("#provider-cfg-context")).toBeVisible({
timeout: 10_000,
});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 02342544102..54c676e44bd 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -156,6 +156,8 @@ type MockBridgeOptions = {
pocketVoiceImportResult?: "success" | "cancel" | "invalid";
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
+ /** Full repository file count reported alongside the capped snapshot payload. */
+ projectRepoTotalFileCount?: number;
/** Relay NIP-11 identity used to sign authoritative repository state. */
relaySelf?: string | null;
/** Native-like huddle state seeded from authoritative role-bearing membership. */