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
11 changes: 7 additions & 4 deletions src/apps/cli/tests/terminal_process_contracts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const EXEC_STREAM_SIZE: PtySize = PtySize {
};
const STARTUP_INPUT: &[u8] = b"exercise active turn resize Q7Z9";
const STARTUP_INPUT_SENTINEL: &str = "Q7Z9";
const MULTILINE_INPUT_SENTINEL: &str = "M7Q4";
const RECOVERY_INPUT: &[u8] = b"READY_AFTER_CANCEL K4W8";
const RECOVERY_INPUT_SENTINEL: &str = "K4W8";

Expand All @@ -48,18 +49,20 @@ fn interactive_startup_survives_resize_multiline_input_and_emits_cleanup() {

process.resize(RESIZED_SIZE);

// Ratatui emits only changed cells, so the sentinel must not share characters
// with the startup placeholder at the same screen positions.
#[cfg(unix)]
process.write(b"\x1b[200~alpha\r\nbeta\x1b[201~");
process.write(b"\x1b[200~M7Q4\r\nbeta\x1b[201~");
#[cfg(windows)]
{
let mut rapid_input = b"alpha".to_vec();
let mut rapid_input = MULTILINE_INPUT_SENTINEL.as_bytes().to_vec();
rapid_input.extend(std::iter::repeat_n(b'a', 251));
rapid_input.extend_from_slice(b"\rbeta");
process.write(&rapid_input);
}

process.expect_output(
"alpha",
MULTILINE_INPUT_SENTINEL,
Duration::from_secs(15),
"interactive startup did not render multiline input",
);
Expand All @@ -80,7 +83,7 @@ fn interactive_startup_survives_resize_multiline_input_and_emits_cleanup() {
"unexpected process status {status}:\n{output}"
);
assert!(
output.contains("alpha"),
output.contains(MULTILINE_INPUT_SENTINEL),
"paste text was not rendered:\n{output}"
);
assert!(
Expand Down
147 changes: 119 additions & 28 deletions src/crates/interfaces/acp/src/client/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use bitfun_core::infrastructure::PathManager;
use bitfun_core::service::config::ConfigService;
use bitfun_core::service::remote_ssh::workspace_state::get_remote_workspace_manager;
use bitfun_core::util::errors::{BitFunError, BitFunResult};
use dashmap::mapref::entry::Entry;
use dashmap::DashMap;
use futures::io::{AsyncRead as FuturesAsyncRead, AsyncWrite as FuturesAsyncWrite};
use log::{debug, info, warn};
Expand Down Expand Up @@ -513,33 +514,59 @@ impl AcpClientService {
workspace_path: Option<&str>,
remote_connection_id: Option<&str>,
) -> BitFunResult<()> {
if let Some(existing) = self.clients.get(connection_id).map(|entry| entry.clone()) {
let status = *existing.status.read().await;
if matches!(status, AcpClientStatus::Running) {
return Ok(());
}
if matches!(status, AcpClientStatus::Starting) {
return wait_for_client_connection(existing, connection_id).await;
let (connection, remote_connection_id) = loop {
if let Some(existing) = self.clients.get(connection_id).map(|entry| entry.clone()) {
let status = *existing.status.read().await;
match status {
AcpClientStatus::Running => return Ok(()),
AcpClientStatus::Starting => {
return wait_for_client_connection(existing, connection_id).await;
}
AcpClientStatus::Configured
| AcpClientStatus::Stopped
| AcpClientStatus::Failed => {
self.clients
.remove_if(connection_id, |_, current| Arc::ptr_eq(current, &existing));
}
}
}
}

let StartClientConfig {
remote_connection_id,
config,
} = self
.resolve_start_client_config(client_id, workspace_path, remote_connection_id)
.await?;
let StartClientConfig {
remote_connection_id,
config,
} = self
.resolve_start_client_config(client_id, workspace_path, remote_connection_id)
.await?;
let candidate = Arc::new(AcpClientConnection::new(
connection_id.to_string(),
client_id.to_string(),
config,
));

let connection = Arc::new(AcpClientConnection::new(
connection_id.to_string(),
client_id.to_string(),
config,
));
self.clients
.insert(connection_id.to_string(), connection.clone());
*connection.status.write().await = AcpClientStatus::Starting;
match claim_client_start(&self.clients, connection_id, candidate) {
ClientStartClaim::Owned(connection) => {
break (connection, remote_connection_id);
}
ClientStartClaim::Existing(existing) => {
let status = *existing.status.read().await;
match status {
AcpClientStatus::Running => return Ok(()),
AcpClientStatus::Starting => {
return wait_for_client_connection(existing, connection_id).await;
}
AcpClientStatus::Configured
| AcpClientStatus::Stopped
| AcpClientStatus::Failed => {
self.clients.remove_if(connection_id, |_, current| {
Arc::ptr_eq(current, &existing)
});
}
}
}
}
};

let (transport, child) = match remote_connection_id {
let transport_result = match remote_connection_id {
Some(ref remote_connection_id) => {
self.open_transport_for_connection(
client_id,
Expand All @@ -560,10 +587,17 @@ impl AcpClientService {
)
.await
}
}
.inspect_err(|_| {
self.clients.remove(connection_id);
})?;
};
let (transport, child) = match transport_result {
Ok(result) => result,
Err(error) => {
*connection.status.write().await = AcpClientStatus::Failed;
self.clients.remove_if(connection_id, |_, current| {
Arc::ptr_eq(current, &connection)
});
return Err(error);
}
};
*connection.child.lock().await = child;
let service = self.clone();
let connection_for_task = connection.clone();
Expand Down Expand Up @@ -1801,7 +1835,7 @@ impl AcpClientConnection {
id,
client_id,
config,
status: RwLock::new(AcpClientStatus::Configured),
status: RwLock::new(AcpClientStatus::Starting),
connection: RwLock::new(None),
agent_capabilities: RwLock::new(None),
sessions: DashMap::new(),
Expand All @@ -1818,6 +1852,25 @@ impl AcpClientConnection {
}
}

enum ClientStartClaim {
Owned(Arc<AcpClientConnection>),
Existing(Arc<AcpClientConnection>),
}

fn claim_client_start(
clients: &DashMap<String, Arc<AcpClientConnection>>,
connection_id: &str,
candidate: Arc<AcpClientConnection>,
) -> ClientStartClaim {
match clients.entry(connection_id.to_string()) {
Entry::Vacant(entry) => {
entry.insert(candidate.clone());
ClientStartClaim::Owned(candidate)
}
Entry::Occupied(entry) => ClientStartClaim::Existing(entry.get().clone()),
}
}

async fn wait_for_client_connection(
client: Arc<AcpClientConnection>,
connection_id: &str,
Expand Down Expand Up @@ -2431,6 +2484,44 @@ fn select_permission_option_id(options: &[PermissionOption], approve: bool) -> S
mod tests {
use super::*;

fn test_client_connection(id: &str) -> Arc<AcpClientConnection> {
Arc::new(AcpClientConnection::new(
id.to_string(),
"opencode".to_string(),
AcpClientConfig {
name: Some("OpenCode".to_string()),
command: "opencode".to_string(),
args: Vec::new(),
env: HashMap::new(),
enabled: true,
readonly: false,
permission_mode: AcpClientPermissionMode::Ask,
},
))
}

#[test]
fn claims_only_one_client_start_for_a_connection() {
let clients = DashMap::new();
let first = test_client_connection("opencode::session::s1");
let second = test_client_connection("opencode::session::s1");

let ClientStartClaim::Owned(owned) =
claim_client_start(&clients, "opencode::session::s1", first.clone())
else {
panic!("first claimant should own startup");
};
let ClientStartClaim::Existing(existing) =
claim_client_start(&clients, "opencode::session::s1", second)
else {
panic!("second claimant should reuse startup");
};

assert!(Arc::ptr_eq(&owned, &first));
assert!(Arc::ptr_eq(&existing, &first));
assert_eq!(clients.len(), 1);
}

#[test]
fn selects_actual_permission_option_id_for_approval() {
let options = vec![
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -634,13 +634,10 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
workspaceId: workspace.id,
activateWorkspace: setActiveWorkspace,
});
} catch (error) {
notificationService.error(
error instanceof Error ? error.message : t('nav.workspaces.createSessionFailed'),
{ duration: 4000 }
);
} catch {
// createAcpChatSession records the failure through the ACP notification lifecycle.
}
}, [setActiveWorkspace, t, workspace]);
}, [setActiveWorkspace, workspace]);

const handleCreateInitSession = useCallback(async () => {
setMenuOpen(false);
Expand Down
53 changes: 0 additions & 53 deletions src/web-ui/src/app/layout/AppLayout.scss
Original file line number Diff line number Diff line change
Expand Up @@ -122,42 +122,6 @@ html, body {
background: var(--color-bg-scene);
}

.bitfun-app-acp-session-loading {
position: absolute;
left: 50%;
bottom: $size-gap-5;
z-index: 10020;
display: inline-flex;
align-items: center;
gap: $size-gap-2;
max-width: min(360px, calc(100vw - 32px));
min-height: 36px;
padding: 0 $size-gap-3;
border: 1px solid var(--border-subtle);
border-radius: $size-radius-base;
background: color-mix(in srgb, var(--color-bg-elevated) 92%, transparent);
box-shadow: 0 10px 30px rgba(var(--color-static-black-rgb), 0.28);
color: var(--color-text-primary);
font-size: var(--font-size-sm);
line-height: 1.35;
transform: translateX(-50%);
pointer-events: none;
animation: bitfun-acp-session-loading-in $motion-fast $easing-decelerate forwards;

span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

&__spinner {
flex-shrink: 0;
color: var(--color-accent-500);
animation: bitfun-acp-session-spinner 0.9s linear infinite;
}
}

.bitfun-window-mode-hint {
position: fixed;
top: calc(env(safe-area-inset-top, 0px) + #{$size-gap-3});
Expand Down Expand Up @@ -199,17 +163,6 @@ html, body {
}
}

@keyframes bitfun-acp-session-loading-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(6px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

@keyframes bitfun-window-mode-hint-in {
from {
opacity: 0;
Expand All @@ -221,12 +174,6 @@ html, body {
}
}

@keyframes bitfun-acp-session-spinner {
to {
transform: rotate(360deg);
}
}

// ==================== Scrollbar styles ====================
// Moved to global scrollbar.css for shared management.

Expand Down
Loading
Loading