Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,6 @@ identity.key

# Claude Code worktrees
.claude/worktrees/

# mesh-llm build cache
.cache/
29 changes: 27 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,8 +319,19 @@ Right-click shows "Mark as read".
{{02-context-menu}}
```

Re-runs for the same PR overwrite previous images. Cleanup:
`git push origin --delete agent-screenshots/<username>`.
Re-runs overwrite the image blobs on the `agent-screenshots/<username>`
branch, but the script **appends a new PR comment** — it does not edit or
delete the previous one. After reposting, delete the superseded comment so
only the current set remains, otherwise reviewers still see the stale images:

```bash
# List screenshot comments to find the stale one's id
gh pr view <pr> --repo block/sprout --json comments \
--jq '.comments[] | select(.body | test("pr-<pr>--")) | {id, url}'
gh api -X DELETE repos/block/sprout/issues/comments/<stale-comment-id>
```

Branch cleanup when fully done: `git push origin --delete agent-screenshots/<username>`.

### Writing E2E Screenshot Specs

Expand Down Expand Up @@ -357,6 +368,20 @@ await menuItem.evaluate((el) =>
**Cropping:** Use `clip` — full-window (1280x720) screenshots are unreadable
for sidebar features. Sidebar = 256px; context menus ~450px.

**Distinct states — verify before posting:** when one view renders many
elements at once (e.g. all team cards in a single grid), an unscoped
full-page `page.screenshot()` captures the *same* pixels for every shot, so
multiple PNGs come out byte-identical. Scope each shot to its subject with
`locator.screenshot()` (full-page `clip` only when an overlay like an open
dropdown must be included). Then gate on hash distinctness before posting:

```bash
shasum -a 256 test-results/<dir>/*.png # every hash must be unique
```

Identical hashes mean two shots captured the same state — fix the spec, do
not post. This catches the most common screenshot regression.

**`general` has pre-seeded messages** making `hasUnread` always true. Use
`engineering` for "muted + no unread" visual states.

Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default defineConfig({
"**/channel-mute-screenshots.spec.ts",
"**/channel-star-screenshots.spec.ts",
"**/channel-controls-screenshots.spec.ts",
"**/team-management-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/mentions.spec.ts",
"**/relay-reconnect.spec.ts",
Expand Down
1 change: 1 addition & 0 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const overrides = new Map([
["src-tauri/src/nostr_convert.rs", 1116],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
["src-tauri/src/managed_agents/teams.rs", 1020],
]);

await runFileSizeCheck({
Expand Down
16 changes: 8 additions & 8 deletions desktop/src-tauri/src/commands/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -462,13 +462,13 @@ pub async fn create_managed_agent(
requested_persona_id.as_deref().and_then(|pid| {
let personas = load_personas(&app).ok()?;
let persona = personas.iter().find(|p| p.id == pid)?;
let pack_id = persona.source_pack.as_deref()?;
let slug = persona.source_pack_persona_slug.as_deref()?;
let team_id = persona.source_team.as_deref()?;
let slug = persona.source_team_persona_slug.as_deref()?;
let base = managed_agents_base_dir(&app).ok()?;
let pack_path = base.join("packs").join(pack_id);
let team_path = base.join("teams").join(team_id);
// Use the validated slug stored during import — no need to
// re-resolve the pack. The slug is [a-zA-Z0-9_-]+ by construction.
Some((pack_path, slug.to_owned()))
Some((team_path, slug.to_owned()))
});

// Resolve the avatar URL once at creation and persist it on the record.
Expand Down Expand Up @@ -540,11 +540,11 @@ pub async fn create_managed_agent(
backend: input.backend.clone(),
backend_agent_id: None,
provider_binary_path,
// Pack-backed personas: record path + internal slug so the runtime
// can resolve pack config at startup. Must be the slug (e.g., "lep"),
// Team-backed personas: record path + internal slug so the runtime
// can resolve team config at startup. Must be the slug (e.g., "lep"),
// NOT the display_name — ACP's resolve_persona_by_name() matches slugs.
persona_pack_path: pack_metadata.as_ref().map(|(path, _)| path.clone()),
persona_name_in_pack: pack_metadata.as_ref().map(|(_, name)| name.clone()),
persona_team_dir: pack_metadata.as_ref().map(|(path, _)| path.clone()),
persona_name_in_team: pack_metadata.as_ref().map(|(_, name)| name.clone()),
env_vars: input.env_vars.clone(),
created_at: now_iso(),
updated_at: now_iso(),
Expand Down
56 changes: 7 additions & 49 deletions desktop/src-tauri/src/commands/personas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,11 @@ use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
encode_persona_json, import_persona_pack, list_installed_packs, load_managed_agents,
load_personas, load_teams, parse_json_persona, parse_md_persona, parse_png_persona,
parse_zip_personas, save_managed_agents, save_personas, try_regenerate_nest,
uninstall_persona_pack as do_uninstall_persona_pack, validate_persona_activation_change,
validate_persona_deletion, CreatePersonaRequest, PackSummary, ParsePersonaFilesResult,
PersonaRecord, UpdatePersonaRequest,
encode_persona_json, load_managed_agents, load_personas, load_teams, parse_json_persona,
parse_md_persona, parse_png_persona, parse_zip_personas, save_managed_agents,
save_personas, try_regenerate_nest, validate_persona_activation_change,
validate_persona_deletion, CreatePersonaRequest, ParsePersonaFilesResult, PersonaRecord,
UpdatePersonaRequest,
},
util::now_iso,
};
Expand Down Expand Up @@ -79,8 +78,8 @@ pub fn create_persona(
name_pool,
is_builtin: false,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
source_team: None,
source_team_persona_slug: None,
env_vars: input.env_vars,
created_at: now.clone(),
updated_at: now,
Expand Down Expand Up @@ -374,44 +373,3 @@ pub async fn export_persona_to_json(
let filename = format!("{slug}.persona.json");
save_json_with_dialog(&app, &filename, &json_bytes).await
}

// ── Pack management commands ──────────────────────────────────────────────────

#[tauri::command]
pub fn install_persona_pack(
app: AppHandle,
state: State<'_, AppState>,
path: String,
) -> Result<Vec<PersonaRecord>, String> {
let _lock = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let source = std::path::PathBuf::from(&path);
if !source.is_dir() {
return Err(format!("pack path is not a directory: {path}"));
}
let result = import_persona_pack(&app, &source)?;
try_regenerate_nest(&app);
Ok(result)
}

#[tauri::command]
pub fn uninstall_persona_pack(
app: AppHandle,
state: State<'_, AppState>,
pack_id: String,
) -> Result<(), String> {
let _lock = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
do_uninstall_persona_pack(&app, &pack_id)?;
try_regenerate_nest(&app);
Ok(())
}

#[tauri::command]
pub fn list_persona_packs(app: AppHandle) -> Result<Vec<PackSummary>, String> {
list_installed_packs(&app)
}
64 changes: 53 additions & 11 deletions desktop/src-tauri/src/commands/teams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ use super::export_util::save_json_with_dialog;
use crate::{
app_state::AppState,
managed_agents::{
encode_team_json, ensure_persona_ids_are_active, load_personas, load_teams,
parse_team_json, save_teams, validate_team_deletion, CreateTeamRequest, ParsedTeamPreview,
TeamRecord, UpdateTeamRequest,
delete_team_with_cascade, encode_team_json, ensure_persona_ids_are_active,
import_team_from_directory as do_import_team, load_personas, load_teams, parse_team_json,
save_teams, sync_team_from_dir as do_sync_team, try_regenerate_nest, CreateTeamRequest,
ParsedTeamPreview, SyncResult, TeamRecord, UpdateTeamRequest,
},
util::now_iso,
};
Expand Down Expand Up @@ -59,6 +60,10 @@ pub fn create_team(
description,
persona_ids: input.persona_ids,
is_builtin: false,
source_dir: None,
is_symlink: false,
symlink_target: None,
version: None,
created_at: now.clone(),
updated_at: now,
};
Expand Down Expand Up @@ -104,14 +109,51 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re
.managed_agents_store_lock
.lock()
.map_err(|error| error.to_string())?;
let mut teams = load_teams(&app)?;
let team = teams
.iter()
.find(|record| record.id == id)
.ok_or_else(|| format!("team {id} not found"))?;
validate_team_deletion(team)?;
teams.retain(|record| record.id != id);
save_teams(&app, &teams)
delete_team_with_cascade(&app, &id)?;
try_regenerate_nest(&app);
Ok(())
}

#[tauri::command]
pub fn install_team_from_directory(
app: AppHandle,
state: State<'_, AppState>,
path: String,
symlink: Option<bool>,
) -> Result<TeamRecord, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let source = std::path::PathBuf::from(&path);
if !source.is_dir() {
return Err(format!("team path is not a directory: {path}"));
}
let result = do_import_team(&app, &source, symlink.unwrap_or(false))?;
try_regenerate_nest(&app);
Ok(result)
}

#[tauri::command]
pub fn sync_team_directory(
app: AppHandle,
state: State<'_, AppState>,
team_id: String,
) -> Result<SyncResult, String> {
let _store_guard = state
.managed_agents_store_lock
.lock()
.map_err(|e| e.to_string())?;
let result = do_sync_team(&app, &team_id)?;
try_regenerate_nest(&app);
Ok(result)
}

#[tauri::command]
pub async fn pick_team_directory(app: AppHandle) -> Result<Option<String>, String> {
use tauri_plugin_dialog::DialogExt;
let path = app.dialog().file().blocking_pick_folder();
Ok(path.map(|p| p.to_string()))
}

// ---------------------------------------------------------------------------
Expand Down
13 changes: 7 additions & 6 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -520,12 +520,13 @@ pub fn run() {
// this worktree's data directory. Must run before
// restore_managed_agents_on_launch (which reads managed-agents.json).
migration::sync_shared_agent_data(&app_handle);
migration::reconcile_persona_pack_paths(&app_handle);
migration::migrate_packs_to_teams(&app_handle);
migration::reconcile_persona_team_dirs(&app_handle);
migration::reconcile_provider_mcp_commands(&app_handle);
migration::migrate_persona_provider_to_runtime(&app_handle);

if let Err(e) = managed_agents::sync_pack_personas(&app_handle) {
eprintln!("sprout-desktop: sync-pack-personas: {e}");
if let Err(e) = managed_agents::sync_team_personas(&app_handle) {
eprintln!("sprout-desktop: sync-team-personas: {e}");
}

// Resolve persisted identity key (env var → file → generate+save).
Expand Down Expand Up @@ -734,13 +735,13 @@ pub fn run() {
create_team,
update_team,
delete_team,
install_team_from_directory,
sync_team_directory,
pick_team_directory,
export_team_to_json,
parse_team_file,
parse_persona_files,
export_persona_to_json,
install_persona_pack,
uninstall_persona_pack,
list_persona_packs,
get_channel_workflows,
get_workflow,
create_workflow,
Expand Down
8 changes: 4 additions & 4 deletions desktop/src-tauri/src/managed_agents/nest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -947,8 +947,8 @@ mod tests {
name_pool: vec![],
is_builtin: false,
is_active: true,
source_pack: None,
source_pack_persona_slug: None,
source_team: None,
source_team_persona_slug: None,
env_vars: std::collections::BTreeMap::new(),
created_at: String::new(),
updated_at: String::new(),
Expand Down Expand Up @@ -980,8 +980,8 @@ mod tests {
backend: BackendKind::default(),
backend_agent_id: None,
provider_binary_path: None,
persona_pack_path: None,
persona_name_in_pack: None,
persona_team_dir: None,
persona_name_in_team: None,
created_at: String::new(),
updated_at: String::new(),
last_started_at: None,
Expand Down
Loading