Skip to content
Draft
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
237 changes: 237 additions & 0 deletions app/src/context_chips/cloud_artifact_pr.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
use std::collections::HashSet;

use crate::ai::artifacts::Artifact;

use super::{prompt_snapshot::PromptSnapshot, ChipValue, ContextChipKind};

pub(crate) fn apply_cloud_artifact_pr_to_prompt_snapshot(
snapshot: &mut PromptSnapshot,
artifacts: &[Artifact],
) -> bool {
if !artifacts
.iter()
.any(|artifact| matches!(artifact, Artifact::PullRequest { .. }))
{
return false;
}

let current_working_directory = snapshot
.chip_value(&ContextChipKind::WorkingDirectory)
.and_then(|value| value.as_text().map(str::to_string));
let current_git_branch = snapshot
.chip_value(&ContextChipKind::ShellGitBranch)
.and_then(|value| value.as_text().map(str::to_string));

let matching_pr_urls = matching_pull_request_urls(
artifacts,
current_working_directory.as_deref(),
current_git_branch.as_deref(),
);
let value = (matching_pr_urls.len() == 1).then(|| ChipValue::Text(matching_pr_urls[0].clone()));

snapshot.set_chip_value(&ContextChipKind::GithubPullRequest, value)
}

fn matching_pull_request_urls(
artifacts: &[Artifact],
current_working_directory: Option<&str>,
current_git_branch: Option<&str>,
) -> Vec<String> {
let mut urls = Vec::new();
let mut seen = HashSet::new();

for artifact in artifacts {
let Artifact::PullRequest {
url, branch, repo, ..
} = artifact
else {
continue;
};

if url.trim().is_empty() {
continue;
}

if !repo_matches_current_working_directory(repo.as_deref(), current_working_directory) {
continue;
}

if !branch_matches_current_git_branch(branch, current_git_branch) {
continue;
}

if seen.insert(url.as_str()) {
urls.push(url.clone());
}
}

urls
}

fn repo_matches_current_working_directory(
repo: Option<&str>,
current_working_directory: Option<&str>,
) -> bool {
let Some(repo) = repo.map(str::trim).filter(|repo| !repo.is_empty()) else {
return false;
};
let Some(current_working_directory) = current_working_directory
.map(str::trim)
.filter(|cwd| !cwd.is_empty())
else {
return false;
};

current_working_directory
.split(['/', '\\'])
.any(|segment| segment == repo)
}

fn branch_matches_current_git_branch(branch: &str, current_git_branch: Option<&str>) -> bool {
let branch = branch.trim();
if branch.is_empty() {
return false;
}

let Some(current_git_branch) = current_git_branch
.map(str::trim)
.filter(|current_git_branch| !current_git_branch.is_empty())
else {
return true;
};

branch == current_git_branch
}

#[cfg(test)]
mod tests {
use super::*;
use crate::context_chips::ChipResult;
use crate::settings::WarpPromptSeparator;

fn pr_artifact(url: &str, branch: &str) -> Artifact {
let (repo, number) = crate::ai::artifacts::parse_github_pr_url(url).unzip();
Artifact::PullRequest {
url: url.to_string(),
branch: branch.to_string(),
repo,
number,
}
}

fn snapshot(cwd: Option<&str>, branch: Option<&str>, pr: Option<&str>) -> PromptSnapshot {
let chips = vec![
ChipResult {
kind: ContextChipKind::WorkingDirectory,
value: cwd.map(|cwd| ChipValue::Text(cwd.to_string())),
on_click_values: vec![],
},
ChipResult {
kind: ContextChipKind::ShellGitBranch,
value: branch.map(|branch| ChipValue::Text(branch.to_string())),
on_click_values: vec![],
},
ChipResult {
kind: ContextChipKind::GithubPullRequest,
value: pr.map(|pr| ChipValue::Text(pr.to_string())),
on_click_values: vec![],
},
];
PromptSnapshot::from_chips(chips, false, WarpPromptSeparator::None)
}

#[test]
fn sets_pr_chip_when_repo_and_branch_match() {
let mut snapshot = snapshot(Some("/workspace/warp/app"), Some("feature"), None);
let changed = apply_cloud_artifact_pr_to_prompt_snapshot(
&mut snapshot,
&[pr_artifact(
"https://github.com/warpdotdev/warp/pull/123",
"feature",
)],
);

assert!(changed);
assert_eq!(
snapshot
.chip_value(&ContextChipKind::GithubPullRequest)
.and_then(|value| value.as_text().map(str::to_string)),
Some("https://github.com/warpdotdev/warp/pull/123".to_string())
);
}

#[test]
fn does_not_set_pr_chip_when_repo_does_not_match_cwd() {
let mut snapshot = snapshot(Some("/workspace/docs"), Some("feature"), None);
let changed = apply_cloud_artifact_pr_to_prompt_snapshot(
&mut snapshot,
&[pr_artifact(
"https://github.com/warpdotdev/warp/pull/123",
"feature",
)],
);

assert!(!changed);
assert_eq!(
snapshot.chip_value(&ContextChipKind::GithubPullRequest),
None
);
}

#[test]
fn does_not_set_pr_chip_when_branch_does_not_match() {
let mut snapshot = snapshot(Some("/workspace/warp"), Some("other"), None);
let changed = apply_cloud_artifact_pr_to_prompt_snapshot(
&mut snapshot,
&[pr_artifact(
"https://github.com/warpdotdev/warp/pull/123",
"feature",
)],
);

assert!(!changed);
assert_eq!(
snapshot.chip_value(&ContextChipKind::GithubPullRequest),
None
);
}

#[test]
fn clears_pr_chip_when_multiple_artifact_prs_match() {
let mut snapshot = snapshot(
Some("/workspace/warp"),
Some("feature"),
Some("https://github.com/warpdotdev/warp/pull/123"),
);
let changed = apply_cloud_artifact_pr_to_prompt_snapshot(
&mut snapshot,
&[
pr_artifact("https://github.com/warpdotdev/warp/pull/123", "feature"),
pr_artifact("https://github.com/warpdotdev/warp/pull/456", "feature"),
],
);

assert!(changed);
assert_eq!(
snapshot.chip_value(&ContextChipKind::GithubPullRequest),
None
);
}

#[test]
fn leaves_snapshot_unchanged_without_pr_artifacts() {
let mut snapshot = snapshot(
Some("/workspace/warp"),
Some("feature"),
Some("https://github.com/warpdotdev/warp/pull/123"),
);
let original = snapshot.chip_value(&ContextChipKind::GithubPullRequest);
let changed = apply_cloud_artifact_pr_to_prompt_snapshot(&mut snapshot, &[]);

assert!(!changed);
assert_eq!(
snapshot.chip_value(&ContextChipKind::GithubPullRequest),
original
);
}
}
1 change: 1 addition & 0 deletions app/src/context_chips/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// TODO: restrict what we make public here.
mod builtins;
pub(crate) mod cloud_artifact_pr;
pub mod context_chip;
pub mod current_prompt;
pub mod directory_fetcher;
Expand Down
21 changes: 21 additions & 0 deletions app/src/context_chips/prompt_snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ impl PromptSnapshot {
})
}

pub(crate) fn set_chip_value(
&mut self,
chip: &ContextChipKind,
value: Option<ChipValue>,
) -> bool {
let Some(chip_result) = self
.chips
.iter_mut()
.find(|chip_result| chip_result.kind == *chip)
else {
return false;
};

if chip_result.value == value {
return false;
}

chip_result.value = value;
true
}

pub(crate) fn chips(&self) -> &Vec<ChipResult> {
&self.chips
}
Expand Down
51 changes: 27 additions & 24 deletions app/src/terminal/shared_session/viewer/terminal_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1341,39 +1341,42 @@ impl TerminalManager {
active_prompt: &ActivePrompt,
ctx: &mut AppContext,
) {
let mut model = model.lock();
match active_prompt {
ActivePrompt::WarpPrompt(serialized_prompt_snapshot) => {
match serde_json::from_str::<PromptSnapshot>(serialized_prompt_snapshot) {
Ok(prompt_snapshot) => {
model.block_list_mut().set_honor_ps1(false);
// Overwrite the static prompt with the new snapshot.
prompt_type.update(ctx, |prompt_type, ctx| {
if let PromptType::Static { snapshot } = prompt_type {
*snapshot = prompt_snapshot;
ctx.notify();
} else {
log::warn!("Received ActivePrompt::WarpPrompt updated but prompt type is not Static");
}
});
}
Err(e) => {
log::error!(
"Failed to deserialize prompt snapshot from shared session server: {e}"
)
{
let mut model = model.lock();
match active_prompt {
ActivePrompt::WarpPrompt(serialized_prompt_snapshot) => {
match serde_json::from_str::<PromptSnapshot>(serialized_prompt_snapshot) {
Ok(prompt_snapshot) => {
model.block_list_mut().set_honor_ps1(false);
// Overwrite the static prompt with the new snapshot.
prompt_type.update(ctx, |prompt_type, ctx| {
if let PromptType::Static { snapshot } = prompt_type {
*snapshot = prompt_snapshot;
ctx.notify();
} else {
log::warn!("Received ActivePrompt::WarpPrompt updated but prompt type is not Static");
}
});
}
Err(e) => {
log::error!(
"Failed to deserialize prompt snapshot from shared session server: {e}"
)
}
}
}
}
ActivePrompt::PS1 => {
// The viewer already receives bytes from the pty for the PS1 prompt, so we only need to choose to render it.
model.block_list_mut().set_honor_ps1(true);
ActivePrompt::PS1 => {
// The viewer already receives bytes from the pty for the PS1 prompt, so we only need to choose to render it.
model.block_list_mut().set_honor_ps1(true);
}
}
}
let Some(view) = weak_view_handle.upgrade(ctx) else {
return;
};
// This is needed to re-render the input if we changed prompt types.
view.update(ctx, |view, ctx| {
view.refresh_cloud_artifact_pr_prompt_chip(ctx);
view.input().update(ctx, |input, ctx| {
input.notify_and_notify_children(ctx);
})
Expand Down
35 changes: 35 additions & 0 deletions app/src/terminal/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,7 @@ use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{CloudObject, GenericStringObjectFormat, JsonObjectType};
#[cfg(feature = "local_fs")]
use crate::code::editor_management::CodeSource;
use crate::context_chips::cloud_artifact_pr::apply_cloud_artifact_pr_to_prompt_snapshot;
use crate::context_chips::prompt::Prompt;
use crate::context_chips::prompt_type::PromptType;
use crate::context_chips::ContextChipKind;
Expand Down Expand Up @@ -3659,6 +3660,9 @@ impl TerminalView {
| AgentConversationsModelEvent::ConversationUpdated { .. }
| AgentConversationsModelEvent::ConversationArtifactsUpdated { .. }
);
if should_refresh_details_panel {
me.refresh_cloud_artifact_pr_prompt_chip(ctx);
}
// Only refresh panel if it's currently open (avoids unnecessary work)
if should_refresh_details_panel
&& me.is_conversation_details_panel_open
Expand Down Expand Up @@ -10476,6 +10480,37 @@ impl TerminalView {
});
}

pub(crate) fn refresh_cloud_artifact_pr_prompt_chip(&mut self, ctx: &mut ViewContext<Self>) {
let Some(task_id) = self.ambient_agent_task_id_for_details_panel(ctx) else {
return;
};
let Some(task) = AgentConversationsModel::handle(ctx).update(ctx, |model, ctx| {
model.get_or_async_fetch_task_data(&task_id, ctx)
}) else {
return;
};
if task.artifacts.is_empty() {
return;
}

let did_update = self.current_prompt.update(ctx, |prompt_type, ctx| {
let PromptType::Static { snapshot } = prompt_type else {
return false;
};
let did_update = apply_cloud_artifact_pr_to_prompt_snapshot(snapshot, &task.artifacts);
if did_update {
ctx.notify();
}
did_update
});

if did_update {
self.input.update(ctx, |input, ctx| {
input.notify_and_notify_children(ctx);
});
}
}

pub fn current_state(&self) -> TerminalViewStateChange {
self.current_state
}
Expand Down