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
8 changes: 8 additions & 0 deletions scripts/generate-i18n-contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ const checkOnly = process.argv.includes('--check');
const contractPath = path.join(root, 'src', 'shared', 'i18n', 'contract', 'locales.json');

const outputs = [
{
path: path.join(root, 'src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/GeneratedLocaleContract.kt'),
generate: (contract) => `// Generated by scripts/generate-i18n-contract.mjs. Do not edit.
package com.openbitfun.mobile.app.platform

internal const val DEFAULT_APP_LANGUAGE_TAG = "${contract.surfaceDefaults.android}"
`,
},
{
path: path.join(root, 'src', 'web-ui', 'src', 'infrastructure', 'i18n', 'presets', 'generatedLocaleContract.ts'),
generate: generateWebLocaleContract,
Expand Down
35 changes: 27 additions & 8 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2249,11 +2249,22 @@ pub async fn scan_workspace_info(
.map_err(|e| format!("Failed to scan workspace info: {}", e))
}

async fn ensure_directory_request_path(path: &str) -> Result<(), String> {
async fn ensure_directory_request_path(
path: &str,
remote_connection_id: Option<&str>,
) -> Result<(), String> {
use openbitfun_core::service::remote_ssh::workspace_state::is_remote_path;
use std::path::Path;

if is_remote_path(path).await {
if let Some(id) = remote_connection_id.filter(|id| !id.trim().is_empty()) {
openbitfun_core::service::filesystem::path_operations::resolve_explicit_path_connection(
path,
id.trim(),
)
.await?;
return Ok(());
}
if remote_connection_id != Some("") && is_remote_path(path).await {
return Ok(());
}

Expand Down Expand Up @@ -2308,7 +2319,7 @@ async fn get_file_tree_response(
) -> Result<serde_json::Value, String> {
use std::path::Path;

ensure_directory_request_path(&request.path).await?;
ensure_directory_request_path(&request.path, request.remote_connection_id.as_deref()).await?;

let preferred = request.remote_connection_id.as_deref();
let filesystem_service = &state.filesystem_service;
Expand Down Expand Up @@ -2345,7 +2356,7 @@ async fn get_directory_children_response(
state: &State<'_, AppState>,
request: &GetDirectoryChildrenRequest,
) -> Result<serde_json::Value, String> {
ensure_directory_request_path(&request.path).await?;
ensure_directory_request_path(&request.path, request.remote_connection_id.as_deref()).await?;

let preferred = request.remote_connection_id.as_deref();
let filesystem_service = &state.filesystem_service;
Expand All @@ -2368,7 +2379,7 @@ async fn get_directory_children_paginated_response(
let offset = request.offset.unwrap_or(0);
let limit = request.limit.unwrap_or(100);

ensure_directory_request_path(&request.path).await?;
ensure_directory_request_path(&request.path, request.remote_connection_id.as_deref()).await?;

let preferred = request.remote_connection_id.as_deref();
let filesystem_service = &state.filesystem_service;
Expand All @@ -2383,6 +2394,14 @@ async fn get_directory_children_paginated_response(
request.sort_order.as_deref(),
)?;
let total = nodes.len();
debug!(
"Directory page read: explicit_local={}, remote_scope={}, offset={}, limit={}, total={}",
preferred == Some(""),
preferred.is_some_and(|value| !value.is_empty()),
offset,
limit,
total
);
let has_more = total > offset.saturating_add(limit);
let page_nodes: Vec<_> = nodes.into_iter().skip(offset).take(limit).collect();

Expand Down Expand Up @@ -4680,7 +4699,7 @@ pub async fn search_files(
}
}
} else {
match resolve_desktop_path_target(
match crate::api::path_target::resolve_desktop_workspace_path_target(
&state,
&request.root_path,
request.remote_connection_id.as_deref(),
Expand Down Expand Up @@ -4763,7 +4782,7 @@ pub async fn search_filenames(
include_directories: request.include_directories,
};

let result = match resolve_desktop_path_target(
let result = match crate::api::path_target::resolve_desktop_workspace_path_target(
&state,
&request.root_path,
request.remote_connection_id.as_deref(),
Expand Down Expand Up @@ -4922,7 +4941,7 @@ pub async fn start_search_filenames_stream(
include_directories: request.include_directories,
};

let remote_search_target = match resolve_desktop_path_target(
let remote_search_target = match crate::api::path_target::resolve_desktop_workspace_path_target(
&state,
&request.root_path,
request.remote_connection_id.as_deref(),
Expand Down
64 changes: 54 additions & 10 deletions src/apps/desktop/src/api/path_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,22 @@ use serde::Serialize;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

/// Resolved file routing identity, independent of workspace registration.
#[derive(Debug, Clone)]
pub enum DesktopPathTarget {
pub struct RemotePathConnection {
pub connection_id: String,
}

#[derive(Debug, Clone)]
pub enum DesktopPathTarget<T = RemotePathConnection> {
Local {
requested_path: String,
resolved_path: PathBuf,
is_runtime_artifact: bool,
},
Remote {
requested_path: String,
entry: RemoteWorkspaceEntry,
entry: T,
},
}

Expand Down Expand Up @@ -165,29 +171,67 @@ pub async fn resolve_desktop_path_target(
let explicit_connection_id = preferred_remote_connection_id
.map(str::trim)
.filter(|value| !value.is_empty());
if let Some(id) = explicit_connection_id {
let connection_id = openbitfun_core::service::filesystem::path_operations::resolve_explicit_path_connection(raw_path, id).await?;
return Ok(DesktopPathTarget::Remote {
requested_path: raw_path.to_string(),
entry: RemotePathConnection { connection_id },
});
}
if let Some(entry) =
lookup_remote_entry_for_path(app_state, raw_path, explicit_connection_id).await
{
return Ok(DesktopPathTarget::Remote {
requested_path: raw_path.to_string(),
entry,
entry: RemotePathConnection {
connection_id: entry.connection_id,
},
});
}

if let Some(connection_id) = explicit_connection_id {
return Err(format!(
"Remote workspace connection '{}' is unavailable or does not own path '{}'; local filesystem fallback was not attempted",
connection_id, raw_path
));
}

Ok(DesktopPathTarget::Local {
requested_path: raw_path.to_string(),
resolved_path: PathBuf::from(raw_path),
is_runtime_artifact: false,
})
}

/// Workspace search retains registered-root scope even when device file tools
/// use a saved SSH connection outside any opened workspace.
pub async fn resolve_desktop_workspace_path_target(
app_state: &AppState,
path: &str,
preferred: Option<&str>,
) -> Result<DesktopPathTarget<RemoteWorkspaceEntry>, String> {
match resolve_desktop_path_target(app_state, path, preferred).await? {
DesktopPathTarget::Local {
requested_path,
resolved_path,
is_runtime_artifact,
} => Ok(DesktopPathTarget::Local {
requested_path,
resolved_path,
is_runtime_artifact,
}),
DesktopPathTarget::Remote {
requested_path,
entry,
} => {
let workspace = lookup_remote_entry_for_path(
app_state,
&requested_path,
Some(&entry.connection_id),
)
.await
.ok_or("Remote search requires a registered workspace owning the requested path")?;
Ok(DesktopPathTarget::Remote {
requested_path,
entry: workspace,
})
}
}
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LocalFileMetadata {
Expand Down
31 changes: 20 additions & 11 deletions src/apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,21 @@ The mobile apps control a selected Desktop or CLI runtime. Workspace paths, file

Android/iOS conversation synchronization uses the same revisioned rich session records for initial history, live updates, and recovery. The latest page opens first; earlier encrypted pages prefetch in the background through the same single flight used by explicit history requests. Complete fragment boundaries, stable record revisions, and ancestor deletion markers are preserved during backward reads. Encrypted fragments, the forward cursor, and the older-page boundary commit atomically to the local replica. Returning to the foreground actively checks the forward cursor even when the socket did not report a disconnect.

HarmonyOS also publishes the latest page before scheduling older-page prefetch.
Background and manual history requests share the same serialized reader, and
leaving the session cancels scheduled prefetch.

Workspace tools expose directory browsing, text editing with runtime-enforced content-hash conflict detection, file/folder creation, file renaming/deletion, binary upload/download, and bundled xterm PTY control. The terminal executes on the selected runtime or its saved SSH connection; durable notifications request incremental output from the runtime cursor.

Pending approvals are a separate runtime mailbox, refreshed at initial attachment, reconnect/foreground recovery, and relevant permission control events. Android/iOS answer the stable request identity even when no tool call is attached, and support approving edited JSON input through the same runtime permission owner.

Android, iOS, and HarmonyOS terminal WebViews share `src/shared/terminal/webview` assets. The native adapter only bridges keyboard input, dimensions, and cursor-derived output; it has no Relay token or local process access. Android/iOS downloads stage chunks into a controller-local temporary file and hand its URL or input stream to the platform document exporter. They do not retain the full download in a Kotlin ByteArray or Swift Data. Preview buffers have their own bounded presentation policy.

Android and iOS expose a device-tools button at the bottom of the sidebar.
It selects a runtime location (the controlled device or one of its saved SSH
connections), then opens files or terminals independently of any workspace.
Android, iOS, and HarmonyOS expose a device-tools button at the bottom of the sidebar.
The page selects a runtime location (the controlled device or one of its saved SSH
connections) and provides Files and Terminal tabs independently of any workspace.
Switching tabs retains the selected location and does not create a terminal;
opening a PTY is an explicit action.
Local defaults come from the runtime's `get_system_info.homeDir`; SSH defaults to
`/`. A successful directory change captures that directory and provider for file
transfers. No workspace registration or current-workspace switch is required.
Expand All @@ -119,10 +125,9 @@ connection, plus server-side name and modification-time sorting before directory
pagination. File editing opens a separate full-screen native view; returning
preserves the directory and asks before discarding edits. Its line-number gutter
shares vertical scrolling with unwrapped source text and never changes file
contents. Rename and delete remain available in that editor. The terminal opens
in a separate native view whose PTY viewport follows the remaining safe-area and
keyboard-adjusted space; leaving the view keeps the terminal running until an
explicit close. Native build checks cover integration, while keyboard, scrolling,
contents. Rename and delete remain available in that editor. The terminal tab
fits its PTY viewport into the remaining safe-area and keyboard-adjusted space;
leaving the page keeps the terminal running until an explicit close. Native build checks cover integration, while keyboard, scrolling,
and live runtime behavior still require device or simulator interaction checks.

Creating an ordinary chat sends `Claw` without a workspace override and lets the
Expand All @@ -134,10 +139,14 @@ project carries its connection identity through the create request.

## Connection recovery

Native iOS and Android controllers probe idle session lists while the app is in the
foreground. An open transcript uses its durable session stream for recovery instead
of duplicating the health request. Temporary transport failures keep the displayed
list or transcript; a successful response restores the connected state.
Native iOS and Android controllers probe the selected runtime while in the
foreground, including idle open conversations. Each probe has a ten-second
deadline, and the next starts fifteen seconds after the previous one finishes.
Backgrounding cancels the probe. Durable replay recovers conversation records,
but Relay availability alone does not prove the execution host is online.
Temporary transport failures keep the displayed list, transcript, and draft; a
successful response restores the connected state. Connection feedback stays in
the sidebar instead of inserting a banner above the conversation.

Account sign-in on Android, iOS and HarmonyOS opens the shared authorization page
with separate GitHub and email-code options. Email users need no password and are
Expand Down
8 changes: 8 additions & 0 deletions src/apps/mobile/android/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradle
# Verify running-input, plan gating, offline tools, and attachment retention.
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.openbitfun.mobile.app.MobileParityTest

# Verify terminal WebView focus and native keyboard routing inside the dialog.
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.openbitfun.mobile.app.RuntimeTerminalViewTest

# Run shared JVM tests after core-feature changes.
cd ../shared
JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew jvmTest
Expand All @@ -60,3 +63,8 @@ JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradle
Use the narrowest command covering the changed behavior. Keep credentials,
keystores, signing values, device identifiers, and local SDK paths out of the
repository.

Run instrumented Gradle tests on a dedicated test emulator, not an authenticated
manual-acceptance device. The test runner can uninstall the target application
after the suite and remove its local account state. Use `adb install -r` for
manual acceptance updates to preserve that state.
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ class ChatMessageBubbleTest {
ChatMessageBubble(
row = row,
enabled = true,
onApproveTool = {},
onApproveTool = { _, _ -> },
onRejectTool = { _, _ -> },
onCancelTool = { _, _ -> },
onAnswerTool = { _, _ -> },
Expand Down
Loading
Loading