diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5ca9eb8c..e2cc541b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -70,7 +70,11 @@ jobs:
"$WEBKIT_PKG" \
"$APPINDICATOR_PKG" \
librsvg2-dev \
- patchelf
+ patchelf \
+ libleptonica-dev \
+ libtesseract-dev \
+ tesseract-ocr \
+ tesseract-ocr-eng
- uses: dtolnay/rust-toolchain@stable
diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml
index c6a159b5..ec071b8b 100644
--- a/.github/workflows/desktop-package.yml
+++ b/.github/workflows/desktop-package.yml
@@ -147,7 +147,11 @@ jobs:
librsvg2-dev \
patchelf \
fakeroot \
- rpm
+ rpm \
+ libleptonica-dev \
+ libtesseract-dev \
+ tesseract-ocr \
+ tesseract-ocr-eng
- name: Setup pnpm
uses: pnpm/action-setup@v4
diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml
index 06c93088..5d17c9ef 100644
--- a/src/apps/desktop/Cargo.toml
+++ b/src/apps/desktop/Cargo.toml
@@ -59,10 +59,19 @@ fontdue = "0.9"
core-foundation = "0.9"
core-graphics = "0.23"
dispatch = "0.2"
+objc2 = "0.6"
+objc2-foundation = "0.3"
+objc2-app-kit = "0.3"
+objc2-vision = { version = "0.3.2", features = ["VNRecognizeTextRequest", "VNRequest", "VNObservation", "VNRequestHandler", "VNUtils", "VNTypes", "objc2-core-foundation"] }
[target.'cfg(windows)'.dependencies]
win32job = { workspace = true }
windows = { version = "0.61.3", features = [
+ "Foundation",
+ "Globalization",
+ "Graphics_Imaging",
+ "Media_Ocr",
+ "Storage_Streams",
"Win32_Foundation",
"Win32_System_Com",
"Win32_UI_Accessibility",
@@ -72,3 +81,4 @@ windows-core = "0.61.2"
[target.'cfg(target_os = "linux")'.dependencies]
atspi = "0.29"
+leptess = "0.14.0"
diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs
index a756ce22..1ab92667 100644
--- a/src/apps/desktop/src/api/config_api.rs
+++ b/src/apps/desktop/src/api/config_api.rs
@@ -272,27 +272,15 @@ pub async fn get_mode_configs(state: State<'_, AppState>) -> Result,
}
@@ -131,12 +150,7 @@ fn draw_pointer_fallback_cross(img: &mut RgbImage, cx: i32, cy: i32) {
}
}
-// ── Computer-use coordinate grid (100 px step): lines + anti-aliased axis labels (Inter OFL) ──
-
-const COORD_GRID_DEFAULT_STEP: u32 = 100;
-const COORD_GRID_MAJOR_STEP: u32 = 500;
-/// Logical scale knob; mapped to TTF pixel size for `fontdue` (`scale * 3.5`).
-const COORD_LABEL_SCALE: i32 = 11;
+// ── SoM / overlay text: Inter (OFL) via fontdue ──
/// Inter (OFL); variable font from google/fonts OFL tree.
const COORD_AXIS_FONT_TTF: &[u8] = include_bytes!("../../assets/fonts/Inter-Regular.ttf");
@@ -146,15 +160,10 @@ static COORD_AXIS_FONT: OnceLock = OnceLock::new();
fn coord_axis_font() -> &'static Font {
COORD_AXIS_FONT.get_or_init(|| {
Font::from_bytes(COORD_AXIS_FONT_TTF, FontSettings::default())
- .expect("Inter TTF embedded for computer-use axis labels")
+ .expect("Inter TTF embedded for computer-use SoM/overlay labels")
})
}
-#[inline]
-fn coord_label_px() -> f32 {
- COORD_LABEL_SCALE as f32 * 3.5
-}
-
/// Alpha-blend grayscale coverage onto `img` (baseline-anchored glyph).
fn coord_blit_glyph(
img: &mut RgbImage,
@@ -228,243 +237,175 @@ fn coord_draw_text_h(img: &mut RgbImage, mut baseline_x: i32, baseline_y: i32, t
}
}
-/// Vertically center a horizontal digit string on tick `py`.
-fn coord_draw_u32_h_centered(img: &mut RgbImage, lx: i32, py: i32, n: u32, fg: Rgb, px: f32) {
- let s = n.to_string();
+// ── Set-of-Mark (SoM) label rendering ──
+
+/// Badge font size for SoM labels (smaller than axis labels).
+const SOM_LABEL_PX: f32 = 28.0;
+/// Badge background color (bright magenta -- high contrast on most UIs).
+const SOM_BG: Rgb = Rgb([230, 40, 120]);
+/// Badge text color.
+const SOM_FG: Rgb = Rgb([255, 255, 255]);
+/// Padding around the label text inside the badge.
+const SOM_PAD_X: i32 = 4;
+const SOM_PAD_Y: i32 = 2;
+
+/// Draw SoM numbered labels on the frame at each element's mapped image position.
+/// `elements`: SoM elements with global coordinates.
+/// `margin_l`, `margin_t`: content area offset in the frame.
+/// `map_fn`: maps global (f64,f64) -> Option<(i32,i32)> in content-area pixel space.
+fn draw_som_labels(
+ frame: &mut RgbImage,
+ elements: &[SomElement],
+ margin_l: u32,
+ margin_t: u32,
+ map_fn: F,
+) where
+ F: Fn(f64, f64) -> Option<(i32, i32)>,
+{
let font = coord_axis_font();
- let (m_rep, _) = font.rasterize('8', px);
- let text_h = m_rep.height as i32;
- let baseline_y = py - (m_rep.ymin + text_h / 2);
- coord_draw_text_h(img, lx, baseline_y, &s, fg, px);
-}
-
-#[inline]
-fn coord_plot(img: &mut RgbImage, x: i32, y: i32, c: Rgb) {
- let w = img.width() as i32;
- let h = img.height() as i32;
- if x >= 0 && x < w && y >= 0 && y < h {
- img.put_pixel(x as u32, y as u32, c);
- }
-}
-
-fn coord_digit_block_width(digit_count: usize, px: f32) -> i32 {
- if digit_count == 0 {
- return 0;
- }
- let s: String = std::iter::repeat('8').take(digit_count).collect();
- coord_measure_str_width(&s, px)
-}
+ let (fw, fh) = frame.dimensions();
-/// Height of a vertical digit stack (top-to-bottom) for `nd` decimal digits.
-fn coord_vertical_digit_stack_height(nd: usize, px: f32) -> i32 {
- if nd == 0 {
- return 0;
- }
- let font = coord_axis_font();
- let gap = (px * 0.22).ceil().max(1.0) as i32;
- let mut tot = 0i32;
- for _ in 0..nd {
- let (m, _) = font.rasterize('8', px);
- tot += m.height as i32 + gap;
- }
- tot - gap
-}
+ for elem in elements {
+ let Some((cx, cy)) = map_fn(elem.global_center_x, elem.global_center_y) else {
+ continue;
+ };
-/// Draw decimal `n` with digits stacked **top-to-bottom** (high-order digit at top).
-/// Column is centered on `center_x` (tick position); narrow horizontal footprint for dense x-axis ticks.
-fn coord_draw_u32_vertical_stack(
- img: &mut RgbImage,
- center_x: i32,
- top_y: i32,
- n: u32,
- fg: Rgb,
- px: f32,
-) {
- let s = n.to_string();
- let font = coord_axis_font();
- let gap = (px * 0.22).ceil().max(1.0) as i32;
- let mut ty = top_y;
- for c in s.chars() {
- let (m, bmp) = font.rasterize(c, px);
- let top_left_x = center_x - m.width as i32 / 2;
- let top_left_y = ty;
- let baseline_x = top_left_x - m.xmin as i32;
- let baseline_y = top_left_y - m.ymin as i32;
- coord_blit_glyph_bold(img, baseline_x, baseline_y, &m, &bmp, fg);
- ty += m.height as i32 + gap;
- }
-}
+ // Map from content-area space to frame space
+ let img_x = cx + margin_l as i32;
+ let img_y = cy + margin_t as i32;
+
+ // Measure label text width
+ let label_text = elem.label.to_string();
+ let text_w = coord_measure_str_width(&label_text, SOM_LABEL_PX);
+ let (m_rep, _) = font.rasterize('8', SOM_LABEL_PX);
+ let text_h = m_rep.height as i32;
+
+ let badge_w = text_w + SOM_PAD_X * 2 + 2; // +2 for bold offset
+ let badge_h = text_h + SOM_PAD_Y * 2;
+
+ // Position badge at top-left of element's bounds (mapped to image),
+ // but fall back to center if bounds mapping fails
+ let (badge_x, badge_y) = {
+ let bx = elem.bounds_left;
+ let by = elem.bounds_top;
+ if let Some((bix, biy)) = map_fn(bx, by) {
+ (bix + margin_l as i32, biy + margin_t as i32)
+ } else {
+ // Fall back to center
+ (img_x - badge_w / 2, img_y - badge_h / 2)
+ }
+ };
-fn content_grid_step(min_side: u32) -> u32 {
- if min_side < 240 {
- 25u32
- } else if min_side < 480 {
- 50u32
- } else {
- COORD_GRID_DEFAULT_STEP
- }
-}
+ // Clamp to frame bounds
+ let bx0 = badge_x.max(0).min(fw as i32 - badge_w);
+ let by0 = badge_y.max(0).min(fh as i32 - badge_h);
+
+ // Draw badge background rectangle
+ for dy in 0..badge_h {
+ for dx in 0..badge_w {
+ let px = bx0 + dx;
+ let py = by0 + dy;
+ if px >= 0 && px < fw as i32 && py >= 0 && py < fh as i32 {
+ frame.put_pixel(px as u32, py as u32, SOM_BG);
+ }
+ }
+ }
-/// Symmetric white margins (left = right, top = bottom) for ruler labels outside the capture.
-/// `ruler_origin_*` is the **full-capture native** pixel index of the content’s top-left (0,0 for full screen; crop `x0,y0` for point crops) so label digit width fits large coordinates.
-fn computer_use_margins(
- cw: u32,
- ch: u32,
- ruler_origin_x: u32,
- ruler_origin_y: u32,
-) -> (u32, u32) {
- if cw < 2 || ch < 2 {
- return (0, 0);
+ // Draw label text centered in badge
+ let text_x = bx0 + SOM_PAD_X;
+ let baseline_y = by0 + SOM_PAD_Y + text_h - (m_rep.ymin.max(0) as i32);
+ coord_draw_text_h(frame, text_x, baseline_y, &label_text, SOM_FG, SOM_LABEL_PX);
}
- let px = coord_label_px();
- let tick_len = 14i32;
- let pad = 12i32;
- let max_val_x = ruler_origin_x.saturating_add(cw.saturating_sub(1));
- let max_val_y = ruler_origin_y.saturating_add(ch.saturating_sub(1));
- let nd_x = (max_val_x.max(1).ilog10() as usize + 1).max(4);
- let nd_y = (max_val_y.max(1).ilog10() as usize + 1).max(4);
- let nd = nd_x.max(nd_y);
- let ml = (coord_digit_block_width(nd, px) + tick_len + pad).max(0) as u32;
- // Top/bottom: x-axis labels are vertical stacks — need height for `nd_x` digits.
- let x_stack_h = coord_vertical_digit_stack_height(nd_x, px);
- let mt = (x_stack_h + tick_len + pad).max(0) as u32;
- (ml, mt)
}
-/// White border, grid lines on the capture only, numeric labels in the margin.
-/// `ruler_origin_x/y`: **full-capture native** index of content pixel (0,0) — for a point crop, pass the crop’s `x0,y0` so tick labels match the same **whole-screen bitmap** space as a full-screen shot (not 0..crop_width only).
+/// Returns the capture bitmap unchanged (no grid, rulers, or margins). Pointer and SoM overlays are applied later.
fn compose_computer_use_frame(
content: RgbImage,
- ruler_origin_x: u32,
- ruler_origin_y: u32,
+ _ruler_origin_x: u32,
+ _ruler_origin_y: u32,
) -> (RgbImage, u32, u32) {
- let cw = content.width();
- let ch = content.height();
- if cw < 2 || ch < 2 {
- return (content, 0, 0);
- }
- let grid_step = content_grid_step(cw.min(ch));
- let (ml, mt) = computer_use_margins(cw, ch, ruler_origin_x, ruler_origin_y);
- let mr = ml;
- let mb = mt;
- let tw = ml + cw + mr;
- let th = mt + ch + mb;
- let label_px = coord_label_px();
- let tick_len = 14i32;
- let pad = 12i32;
-
- let mut out = RgbImage::new(tw, th);
- for p in out.pixels_mut() {
- *p = Rgb([255u8, 255, 255]);
- }
- for yy in 0..ch {
- for xx in 0..cw {
- out.put_pixel(ml + xx, mt + yy, *content.get_pixel(xx, yy));
- }
- }
-
- let grid = Rgb([52, 52, 68]);
- let grid_major = Rgb([95, 95, 118]);
- let tick = Rgb([180, 130, 40]);
- // Coordinate numerals in white margins — saturated red for visibility.
- let label = Rgb([200, 32, 40]);
-
- let cl = ml as i32;
- let ct = mt as i32;
- let cr = (ml + cw - 1) as i32;
- let cb = (mt + ch - 1) as i32;
- let wi = tw as i32;
- let hi = th as i32;
-
- let mut gx = grid_step as i32;
- while gx < cw as i32 {
- let major = (gx as u32) % COORD_GRID_MAJOR_STEP == 0;
- let thick = if major { 2 } else { 1 };
- let c = if major { grid_major } else { grid };
- for t in 0..thick {
- let px = cl + gx + t;
- if px >= cl && px <= cr {
- for py in ct..=cb {
- coord_plot(&mut out, px, py, c);
- }
- }
- }
- gx += grid_step as i32;
- }
+ (content, 0, 0)
+}
- let mut gy = grid_step as i32;
- while gy < ch as i32 {
- let major = (gy as u32) % COORD_GRID_MAJOR_STEP == 0;
- let thick = if major { 2 } else { 1 };
- let c = if major { grid_major } else { grid };
- for t in 0..thick {
- let py = ct + gy + t;
- if py >= ct && py <= cb {
- for px in cl..=cr {
- coord_plot(&mut out, px, py, c);
- }
- }
- }
- gy += grid_step as i32;
+fn implicit_confirmation_should_apply(click_needs: bool, params: &ComputerUseScreenshotParams) -> bool {
+ // Applies on **every** bare `screenshot` while confirmation is required — including the
+ // first capture in a session (`last_shot_refinement` may still be `None`), so click/Enter
+ // guards get a ~500×500 around the mouse (or `text_caret` when requested) instead of full screen.
+ //
+ // **Always** apply when `click_needs` (even during quadrant/point-crop drill): previously we
+ // skipped implicit crop while `navigation_focus` was Quadrant/PointCrop, which produced large
+ // confirmation JPEGs; confirmation shots must stay ~500×500 around the pointer/caret.
+ if !click_needs {
+ return false;
+ }
+ if params.crop_center.is_some()
+ || params.navigate_quadrant.is_some()
+ || params.reset_navigation
+ {
+ return false;
}
+ true
+}
- let top_label_y = pad.max(2);
- for gxc in (0..cw as i32).step_by(grid_step as usize) {
- let tick_x = cl + gxc;
- for k in 0..tick_len.min(ct.max(1)) {
- coord_plot(&mut out, tick_x, ct - 1 - k, tick);
+fn global_to_native_full_pixel_center(
+ gx: f64,
+ gy: f64,
+ native_w: u32,
+ native_h: u32,
+ d: &DisplayInfo,
+) -> (u32, u32) {
+ #[cfg(target_os = "macos")]
+ {
+ let geo = MacPointerGeo::from_display(native_w, native_h, d);
+ let lx = gx - geo.disp_ox;
+ let ly = gy - geo.disp_oy;
+ if lx < 0.0 || lx >= geo.disp_w || ly < 0.0 || ly >= geo.disp_h {
+ return clamp_center_to_native(native_w / 2, native_h / 2, native_w, native_h);
}
- let val = ruler_origin_x.saturating_add(gxc.max(0) as u32);
- let col_w = coord_measure_str_width("8", label_px).max(1);
- let cx = tick_x.clamp(col_w / 2 + 2, wi - col_w / 2 - 2);
- coord_draw_u32_vertical_stack(&mut out, cx, top_label_y, val, label, label_px);
+ let full_ix = ((lx / geo.disp_w) * geo.full_px_w as f64).floor() as u32;
+ let full_iy = ((ly / geo.disp_h) * geo.full_px_h as f64).floor() as u32;
+ clamp_center_to_native(full_ix, full_iy, native_w, native_h)
}
-
- let bot_label_y = cb + tick_len + 4;
- for gxc in (0..cw as i32).step_by(grid_step as usize) {
- let tick_x = cl + gxc;
- for k in 0..tick_len {
- let y = cb + 1 + k;
- if y < hi {
- coord_plot(&mut out, tick_x, y, tick);
- }
+ #[cfg(not(target_os = "macos"))]
+ {
+ let disp_w = d.width as f64;
+ let disp_h = d.height as f64;
+ if disp_w <= 0.0 || disp_h <= 0.0 || native_w == 0 || native_h == 0 {
+ return (0, 0);
}
- let val = ruler_origin_x.saturating_add(gxc.max(0) as u32);
- let col_w = coord_measure_str_width("8", label_px).max(1);
- let cx = tick_x.clamp(col_w / 2 + 2, wi - col_w / 2 - 2);
- coord_draw_u32_vertical_stack(&mut out, cx, bot_label_y, val, label, label_px);
- }
-
- let left_numbers_x = pad.max(2);
- for gyc in (0..ch as i32).step_by(grid_step as usize) {
- let py = ct + gyc;
- for k in 0..tick_len.min(cl.max(1)) {
- coord_plot(&mut out, cl - 1 - k, py, tick);
+ let lx = gx - d.x as f64;
+ let ly = gy - d.y as f64;
+ if lx < 0.0 || lx >= disp_w || ly < 0.0 || ly >= disp_h {
+ return clamp_center_to_native(native_w / 2, native_h / 2, native_w, native_h);
}
- let val = ruler_origin_y.saturating_add(gyc.max(0) as u32);
- let s = val.to_string();
- let dw = coord_measure_str_width(&s, label_px);
- let lx = left_numbers_x.min(cl - dw - 2).max(2);
- coord_draw_u32_h_centered(&mut out, lx, py, val, label, label_px);
+ let full_ix = ((lx / disp_w) * native_w as f64).floor() as u32;
+ let full_iy = ((ly / disp_h) * native_h as f64).floor() as u32;
+ clamp_center_to_native(full_ix, full_iy, native_w, native_h)
}
+}
- let right_text_x = cr + tick_len + 4;
- for gyc in (0..ch as i32).step_by(grid_step as usize) {
- let py = ct + gyc;
- for k in 0..tick_len {
- let x = cr + 1 + k;
- if x < wi {
- coord_plot(&mut out, x, py, tick);
- }
+#[cfg(target_os = "macos")]
+fn implicit_global_center_for_confirmation(
+ center: ComputerUseImplicitScreenshotCenter,
+ mx: f64,
+ my: f64,
+) -> (f64, f64) {
+ match center {
+ ComputerUseImplicitScreenshotCenter::Mouse => (mx, my),
+ ComputerUseImplicitScreenshotCenter::TextCaret => {
+ crate::computer_use::macos_ax_ui::global_point_for_text_caret_screenshot(mx, my)
}
- let val = ruler_origin_y.saturating_add(gyc.max(0) as u32);
- let s = val.to_string();
- let dw = coord_measure_str_width(&s, label_px);
- let lx = right_text_x.min(wi - dw - 2).max(2);
- coord_draw_u32_h_centered(&mut out, lx, py, val, label, label_px);
}
+}
- (out, ml, mt)
+#[cfg(not(target_os = "macos"))]
+fn implicit_global_center_for_confirmation(
+ center: ComputerUseImplicitScreenshotCenter,
+ mx: f64,
+ my: f64,
+) -> (f64, f64) {
+ let _ = center;
+ (mx, my)
}
/// JPEG quality for computer-use screenshots. Native display resolution is preserved (no downscale)
@@ -686,13 +627,13 @@ impl MacPointerGeo {
#[derive(Clone, Copy, Debug)]
struct PointerMap {
- /// Composed JPEG size (includes white margin).
+ /// Screenshot JPEG width/height (same as capture when there is no frame padding).
image_w: u32,
image_h: u32,
- /// Top-left of capture inside the JPEG.
+ /// Top-left of capture inside the JPEG (0 when there is no padding).
content_origin_x: u32,
content_origin_y: u32,
- /// Native capture pixel size (the screen bitmap, no margin).
+ /// Native capture pixel size (the cropped/visible bitmap).
content_w: u32,
content_h: u32,
native_w: u32,
@@ -742,7 +683,7 @@ impl PointerMap {
Ok((center_full_x, center_full_y))
}
- /// Normalized 0..=1000 maps to the **capture** (same as pre-margin bitmap; independent of ruler padding).
+ /// Normalized 0..=1000 maps to the **capture** bitmap.
fn map_normalized_to_global_f64(&self, x: i32, y: i32) -> BitFunResult<(f64, f64)> {
if self.native_w == 0 || self.native_h == 0 {
return Err(BitFunError::tool(
@@ -781,15 +722,67 @@ enum ComputerUseNavFocus {
},
}
-pub struct DesktopComputerUseHost {
- last_pointer_map: Mutex
- - {t('remoteConnect.disclaimerItemBeta')}
+ - {t('remoteConnect.disclaimerItemGeneralRisk')}
- {t('remoteConnect.disclaimerItemSecurity')}
- {t('remoteConnect.disclaimerItemEncryption')}
- {t('remoteConnect.disclaimerItemOpenSource')}
diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.scss b/src/web-ui/src/component-library/components/Markdown/Markdown.scss
index c489078c..10822294 100644
--- a/src/web-ui/src/component-library/components/Markdown/Markdown.scss
+++ b/src/web-ui/src/component-library/components/Markdown/Markdown.scss
@@ -1,6 +1,8 @@
/* Markdown renderer styles */
.markdown-renderer {
--markdown-font-mono: "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace;
+ --markdown-block-gap: 0.65rem;
+ --markdown-code-bg-elevated: color-mix(in srgb, var(--color-bg-primary) 92%, #ffffff 8%);
color: var(--color-text-primary);
line-height: var(--line-height-relaxed);
@@ -33,7 +35,7 @@
.markdown-renderer > * + * {
- margin-top: 0.25rem;
+ margin-top: var(--markdown-block-gap);
}
@@ -50,7 +52,7 @@
.markdown-renderer p + p {
- margin-top: 0.25rem;
+ margin-top: 0.5rem;
}
@@ -71,9 +73,9 @@
.markdown-renderer p {
margin-top: 0;
- margin-bottom: 0.5rem;
+ margin-bottom: 0.65rem;
display: block;
- line-height: 1.5;
+ line-height: 1.62;
font-size: 0.9rem;
color: var(--color-text-primary);
}
@@ -106,7 +108,7 @@
margin: 1.5rem 0 1rem 0;
padding: 0 0 0.6rem 0;
color: var(--color-text-primary);
- border-bottom: 1px solid rgba(255, 255, 255, 0.12);
+ border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.12));
}
@@ -117,7 +119,7 @@
margin: 1.25rem 0 0.75rem 0;
padding-bottom: 0.4rem;
color: var(--color-text-primary);
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
}
@@ -210,20 +212,20 @@
.markdown-renderer .inline-code {
- padding: 0.1em 0.4em;
- margin: 0 0.05em;
- font-size: 0.9em;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 4px;
+ padding: 0.12em 0.38em;
+ margin: 0 0.04em;
+ font-size: 0.88em;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.07);
+ border-radius: 5px;
font-family: var(--markdown-font-mono);
white-space: nowrap;
vertical-align: baseline;
- line-height: 1.4;
+ line-height: 1.45;
user-select: text !important;
- color: #a8b4c8;
+ color: color-mix(in srgb, var(--color-text-primary) 88%, #a8b4c8 12%);
font-weight: 500;
- transition: all 0.15s ease;
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
@@ -242,41 +244,71 @@
.markdown-renderer .code-block-wrapper {
- margin: 0.25rem 0.3rem;
- border-radius: 4px;
- background: var(--color-bg-primary);
- border: 1px dashed rgba(255, 255, 255, 0.1);
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ display: flex;
+ flex-direction: column;
+ margin: 0.75rem 0;
+ border-radius: 8px;
+ background: var(--markdown-code-bg-elevated);
+ border: 1px solid var(--border-color, rgba(255, 255, 255, 0.09));
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset, 0 8px 24px rgba(0, 0, 0, 0.22);
overflow: hidden;
position: relative;
- transition: all 0.3s ease;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.markdown-renderer .code-block-wrapper:hover {
- border: 1px solid rgba(255, 255, 255, 0.15);
- border-color: rgba(255, 255, 255, 0.15);
+ border-color: color-mix(in srgb, var(--border-color, rgba(255, 255, 255, 0.09)) 70%, var(--primary-color, #3b82f6) 30%);
+ box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset, 0 10px 28px rgba(0, 0, 0, 0.26);
+}
+
+
+.markdown-renderer .code-block-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ min-height: 2.25rem;
+ padding: 0.35rem 0.5rem 0.35rem 0.75rem;
+ flex-shrink: 0;
+ background: color-mix(in srgb, var(--color-bg-primary) 94%, #ffffff 6%);
+ border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.08));
+}
+
+
+.markdown-renderer .code-block-lang {
+ font-family: var(--font-family-sans);
+ font-size: 0.6875rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--color-text-muted);
+ user-select: none;
+}
+
+
+.markdown-renderer .code-block-body {
+ min-width: 0;
+ overflow-x: auto;
}
.markdown-renderer .copy-button {
- position: absolute;
- top: 0.35rem;
- transform: none;
- right: 0.75rem;
- padding: 0.5rem;
+ position: relative;
+ flex-shrink: 0;
+ padding: 0.35rem;
background: transparent;
border: none;
border-radius: 6px;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--color-text-muted);
cursor: pointer;
- transition: color 0.25s cubic-bezier(0.4, 0, 0.2, 1), background 0.25s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.25s cubic-bezier(0.4, 0, 0.2, 1), transform 0.25s cubic-bezier(0.4, 0, 0.2, 1);
+ transition: color 0.2s ease, background-color 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
- opacity: 0;
+ opacity: 0.9;
z-index: 10;
}
@@ -290,45 +322,30 @@
}
.markdown-renderer .copy-button:hover {
- background: transparent;
- color: #3b82f6;
- transform: scale(1.1);
+ background: rgba(255, 255, 255, 0.06);
+ color: var(--primary-color, #3b82f6);
}
.markdown-renderer .copy-button:active {
- transform: scale(1);
color: #2563eb;
}
-.markdown-renderer .code-block-wrapper--single-line .copy-button {
- top: 50%;
- transform: translateY(-50%);
-}
-
-.markdown-renderer .code-block-wrapper--single-line .copy-button:hover {
- transform: translateY(-50%) scale(1.1);
-}
-
-.markdown-renderer .code-block-wrapper--single-line .copy-button:active {
- transform: translateY(-50%) scale(1);
-}
-
.markdown-renderer .code-block-wrapper pre[class*="language-"] {
margin: 0 !important;
border: none !important;
- border-radius: 8px !important;
- padding: 1.25rem !important;
+ border-radius: 0 0 8px 8px !important;
+ padding: 1rem 1rem 1rem 0.75rem !important;
background: var(--color-bg-primary) !important;
box-shadow: none !important;
- font-size: 0.7rem !important;
+ font-size: 0.875rem !important;
}
.markdown-renderer .code-block-wrapper pre code {
font-family: var(--markdown-font-mono, "Fira Code", "JetBrains Mono", Consolas, "Courier New", monospace) !important;
background: var(--color-bg-primary) !important;
- font-size: 0.7rem !important;
+ font-size: 0.875rem !important;
}
@@ -336,13 +353,13 @@
background: var(--color-bg-primary) !important;
border: none !important;
box-shadow: none !important;
- font-size: 0.5rem !important;
+ font-size: 0.875rem !important;
}
.markdown-renderer .code-block-wrapper code[style] {
background: var(--color-bg-primary) !important;
border: none !important;
- font-size: 0.8rem !important;
+ font-size: 0.875rem !important;
}
.markdown-renderer .inline-code:hover {
@@ -355,24 +372,21 @@
.markdown-renderer pre {
padding: 1.25rem;
overflow: auto;
- font-size: 0.3rem;
- line-height: 1.5;
+ font-size: 0.875rem;
+ line-height: 1.55;
background: var(--color-bg-primary);
- border: 1px dashed rgba(255, 255, 255, 0.1);
- border-radius: 4px;
- margin: 0.25rem 0.3rem;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--border-color, rgba(255, 255, 255, 0.09));
+ border-radius: 8px;
+ margin: 0.65rem 0;
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
position: relative;
- transition: all 0.3s ease;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.markdown-renderer pre:hover {
- border: 1px solid rgba(255, 255, 255, 0.15);
- box-shadow:
- 0 8px 24px rgba(0, 0, 0, 0.4),
- inset 0 1px 0 rgba(255, 255, 255, 0.1),
- inset 0 -1px 0 rgba(255, 255, 255, 0.05);
+ border-color: color-mix(in srgb, var(--border-color, rgba(255, 255, 255, 0.09)) 75%, var(--primary-color, #3b82f6) 25%);
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
}
@@ -381,7 +395,7 @@
background: transparent !important;
border: none !important;
border-radius: 0 !important;
- margin: 1rem 0.3rem !important;
+ margin: 0.75rem 0 !important;
box-shadow: none !important;
position: static !important;
}
@@ -422,7 +436,7 @@
font-family: var(--markdown-font-mono);
user-select: text !important;
box-shadow: none;
- font-size: 0.7rem !important;
+ font-size: 0.875rem !important;
color: inherit;
font-weight: inherit;
}
@@ -599,8 +613,7 @@
.markdown-renderer strong {
font-weight: 650;
- color: #f5f5f5;
- text-shadow: 0 0 1px rgba(255, 255, 255, 0.15);
+ color: var(--color-text-primary);
}
@@ -714,6 +727,15 @@
border-color: rgba(0, 0, 0, 0.15);
}
+ .code-block-toolbar {
+ background: #eef1f6;
+ border-bottom-color: rgba(15, 23, 42, 0.1);
+ }
+
+ .code-block-lang {
+ color: #64748b;
+ }
+
pre {
background: #f6f8fa;
border-color: rgba(0, 0, 0, 0.1);
@@ -730,6 +752,7 @@
.copy-button:hover {
color: #0969da;
+ background: rgba(15, 23, 42, 0.06);
}
@@ -872,14 +895,6 @@
}
-.markdown-renderer pre:hover {
- box-shadow:
- 0 8px 24px rgba(0, 0, 0, 0.4),
- inset 0 1px 0 rgba(255, 255, 255, 0.1),
- inset 0 -1px 0 rgba(255, 255, 255, 0.05);
- border-color: rgba(255, 255, 255, 0.15);
-}
-
.markdown-renderer blockquote:hover {
background: rgba(255, 255, 255, 0.03);
border-left-color: rgba(255, 255, 255, 0.2);
diff --git a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx
index 0836eb3f..3de1d3dd 100644
--- a/src/web-ui/src/component-library/components/Markdown/Markdown.tsx
+++ b/src/web-ui/src/component-library/components/Markdown/Markdown.tsx
@@ -422,6 +422,60 @@ function isEditorOpenableFilePath(filePath: string): boolean {
return EDITOR_OPENABLE_EXTENSIONS.has(fileName.slice(dotIdx + 1));
}
+/** Human-readable label for Prism language ids (code block toolbar). */
+function formatCodeLanguageLabel(lang: string): string {
+ if (!lang) return 'Text';
+ const key = lang.toLowerCase();
+ const aliases: Record = {
+ js: 'JavaScript',
+ jsx: 'JavaScript',
+ mjs: 'JavaScript',
+ cjs: 'JavaScript',
+ ts: 'TypeScript',
+ tsx: 'TSX',
+ py: 'Python',
+ rs: 'Rust',
+ go: 'Go',
+ rb: 'Ruby',
+ sh: 'Shell',
+ bash: 'Bash',
+ zsh: 'Zsh',
+ fish: 'Fish',
+ md: 'Markdown',
+ yml: 'YAML',
+ yaml: 'YAML',
+ json: 'JSON',
+ html: 'HTML',
+ css: 'CSS',
+ scss: 'SCSS',
+ sass: 'Sass',
+ less: 'Less',
+ cpp: 'C++',
+ cxx: 'C++',
+ hpp: 'C++',
+ hxx: 'C++',
+ cc: 'C++',
+ c: 'C',
+ cs: 'C#',
+ fs: 'F#',
+ swift: 'Swift',
+ kt: 'Kotlin',
+ java: 'Java',
+ sql: 'SQL',
+ graphql: 'GraphQL',
+ dockerfile: 'Dockerfile',
+ makefile: 'Makefile',
+ toml: 'TOML',
+ xml: 'XML',
+ rust: 'Rust',
+ typescript: 'TypeScript',
+ javascript: 'JavaScript',
+ };
+ if (aliases[key]) return aliases[key];
+ const raw = lang.replace(/[_-]/g, ' ');
+ return raw.charAt(0).toUpperCase() + raw.slice(1).toLowerCase();
+}
+
const CopyButton: React.FC<{ code: string }> = ({ code }) => {
const { t } = useI18n('components');
const [copied, setCopied] = useState(false);
@@ -598,16 +652,20 @@ export const Markdown = React.memo(({
return (
-
+
+ {formatCodeLanguageLabel(normalizedLang)}
+
+
+
(({
>
{code}
+
);
},
diff --git a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx
index ddee181f..14228b65 100644
--- a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx
+++ b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx
@@ -26,7 +26,10 @@ import './RemoteFileBrowser.scss';
interface RemoteFileBrowserProps {
connectionId: string;
+ /** Defaults to `~` (remote home) to avoid listing `/` on restricted hosts. */
initialPath?: string;
+ /** Used by the Home button; defaults to `initialPath`. */
+ homePath?: string;
onSelect: (path: string) => void;
onCancel: () => void;
}
@@ -48,20 +51,43 @@ function joinRemotePath(dir: string, fileName: string): string {
if (!dir || dir === '/') {
return `/${name}`;
}
+ if (dir === '~') {
+ return name ? `~/${name}` : '~';
+ }
const base = dir.endsWith('/') ? dir.slice(0, -1) : dir;
return `${base}/${name}`;
}
+/** Parent directory for remote paths (supports `~` and absolute POSIX paths). */
+function getRemoteParentPath(path: string): string | null {
+ if (path === '/' || path === '~') return null;
+ if (path.startsWith('~/')) {
+ const rest = path.slice(2);
+ const parts = rest.split('/').filter(Boolean);
+ if (parts.length === 0) return null;
+ parts.pop();
+ if (parts.length === 0) return '~';
+ return `~/${parts.join('/')}`;
+ }
+ const parts = path.split('/').filter(Boolean);
+ if (parts.length === 0) return null;
+ if (parts.length === 1) return '/';
+ parts.pop();
+ return `/${parts.join('/')}`;
+}
+
function isTauriDesktop(): boolean {
return typeof window !== 'undefined' && '__TAURI__' in window;
}
export const RemoteFileBrowser: React.FC = ({
connectionId,
- initialPath = '/',
+ initialPath = '~',
+ homePath,
onSelect,
onCancel,
}) => {
+ const homeAnchor = homePath ?? initialPath;
const { t } = useI18n('common');
const [currentPath, setCurrentPath] = useState(initialPath);
const [pathInputValue, setPathInputValue] = useState(initialPath);
@@ -131,7 +157,12 @@ export const RemoteFileBrowser: React.FC = ({
if (e.key === 'Enter') {
const val = pathInputValue.trim();
if (val) {
- navigateTo(val.startsWith('/') ? val : `/${val}`);
+ const nav = val.startsWith('~')
+ ? val
+ : val.startsWith('/')
+ ? val
+ : `/${val}`;
+ navigateTo(nav);
}
} else if (e.key === 'Escape') {
setPathInputValue(currentPath);
@@ -221,7 +252,7 @@ export const RemoteFileBrowser: React.FC = ({
return;
}
- const parentPath = getParentPath(renameEntry.path) || '/';
+ const parentPath = getRemoteParentPath(renameEntry.path) ?? '/';
const newPath = parentPath.endsWith('/')
? `${parentPath}${renameValue.trim()}`
: `${parentPath}/${renameValue.trim()}`;
@@ -235,13 +266,6 @@ export const RemoteFileBrowser: React.FC = ({
}
};
- const getParentPath = (path: string): string | null => {
- if (path === '/') return null;
- const parts = path.split('/').filter(Boolean);
- parts.pop();
- return '/' + parts.join('/');
- };
-
const handleDownloadEntry = async (entry: RemoteFileEntry) => {
if (entry.isDir) return;
if (!isTauriDesktop()) {
@@ -327,7 +351,22 @@ export const RemoteFileBrowser: React.FC = ({
return ;
};
- const pathParts = currentPath.split('/').filter(Boolean);
+ const pathParts = (() => {
+ if (currentPath === '/' || currentPath === '') return [];
+ if (currentPath === '~') return ['~'];
+ if (currentPath.startsWith('~/')) {
+ return ['~', ...currentPath.slice(2).split('/').filter(Boolean)];
+ }
+ return currentPath.split('/').filter(Boolean);
+ })();
+
+ const pathAtSegment = (index: number) => {
+ if (pathParts[0] === '~') {
+ if (index === 0) return '~';
+ return `~/${pathParts.slice(1, index + 1).join('/')}`;
+ }
+ return `/${pathParts.slice(0, index + 1).join('/')}`;
+ };
return (
@@ -366,8 +405,8 @@ export const RemoteFileBrowser: React.FC
= ({
>
@@ -376,13 +415,13 @@ export const RemoteFileBrowser: React.FC = ({
/
) : (
pathParts.map((part, index) => {
- const path = '/' + pathParts.slice(0, index + 1).join('/');
+ const segPath = pathAtSegment(index);
const isLast = index === pathParts.length - 1;
return (
-
+
@@ -407,9 +446,12 @@ export const RemoteFileBrowser: React.FC = ({
@@ -462,10 +504,10 @@ export const RemoteFileBrowser: React.FC = ({
{/* Parent directory link */}
- {currentPath !== '/' && (
+ {getRemoteParentPath(currentPath) !== null && (
{
- const parent = getParentPath(currentPath);
+ const parent = getRemoteParentPath(currentPath);
if (parent !== null) navigateTo(parent);
}}
className="remote-file-browser__row remote-file-browser__row--parent"
diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx
index f1b845f3..cd3252b7 100644
--- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx
+++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx
@@ -47,6 +47,8 @@ interface SSHContextValue {
showConnectionDialog: boolean;
showFileBrowser: boolean;
error: string | null;
+ /** Default path for remote folder picker (`~` or resolved `$HOME` from server). */
+ remoteFileBrowserInitialPath: string;
// Actions
connect: (connectionId: string, config: SSHConnectionConfig) => Promise;
@@ -85,6 +87,7 @@ export const SSHRemoteProvider: React.FC = ({ children }
const [showFileBrowser, setShowFileBrowser] = useState(false);
const [error, setError] = useState(null);
const [connectionError, setConnectionError] = useState(null);
+ const [remoteFileBrowserInitialPath, setRemoteFileBrowserInitialPath] = useState('~');
// Per-workspace connection statuses (keyed by connectionId)
const [workspaceStatuses, setWorkspaceStatuses] = useState>({});
const heartbeatInterval = useRef(null);
@@ -394,6 +397,10 @@ export const SSHRemoteProvider: React.FC = ({ children }
if (result.success && result.connectionId) {
log.info('SSH connection successful', { connectionId: result.connectionId });
+ const home = result.serverInfo?.homeDir?.trim();
+ setRemoteFileBrowserInitialPath(
+ home && home.length > 0 ? normalizeRemoteWorkspacePath(home) : '~'
+ );
setStatus('connected');
setIsConnected(true);
setConnectionId(result.connectionId);
@@ -446,6 +453,7 @@ export const SSHRemoteProvider: React.FC = ({ children }
setRemoteWorkspace(null);
setIsConnected(false);
setShowFileBrowser(false);
+ setRemoteFileBrowserInitialPath('~');
if (currentRemoteWorkspace) {
setWorkspaceStatus(currentRemoteWorkspace.connectionId, 'disconnected');
@@ -515,6 +523,7 @@ export const SSHRemoteProvider: React.FC = ({ children }
showConnectionDialog,
showFileBrowser,
error,
+ remoteFileBrowserInitialPath,
connect,
disconnect,
openWorkspace,
diff --git a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss
index a1ab6863..db778b27 100644
--- a/src/web-ui/src/flow_chat/components/FlowTextBlock.scss
+++ b/src/web-ui/src/flow_chat/components/FlowTextBlock.scss
@@ -4,7 +4,21 @@
.flow-text-block {
width: 100%;
-
+
+ /* Chat: slightly roomier reading measure than generic markdown preview */
+ .markdown-renderer {
+ font-size: 0.9375rem;
+ line-height: 1.65;
+ letter-spacing: 0.01em;
+ }
+
+ .markdown-renderer p,
+ .markdown-renderer li,
+ .markdown-renderer ul,
+ .markdown-renderer ol {
+ font-size: inherit;
+ }
+
.text-content {
color: var(--color-text-primary);
line-height: 1.6;
diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts
index 25ec85fc..b2a8f83a 100644
--- a/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts
+++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.test.ts
@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest';
import type { Session } from '../types/flow-chat';
-import { compareSessionsForDisplay, getSessionSortTimestamp } from './sessionOrdering';
+import {
+ compareSessionsForDisplay,
+ getSessionSortTimestamp,
+ sessionBelongsToWorkspaceNavRow,
+} from './sessionOrdering';
function createSession(overrides: Partial = {}): Session {
return {
@@ -55,4 +59,29 @@ describe('sessionOrdering', () => {
const orderedIds = [...sessions].sort(compareSessionsForDisplay).map(session => session.sessionId);
expect(orderedIds).toEqual(['a', 'b']);
});
+
+ it('remote SSH: same host but different remote root does not share nav row', () => {
+ const conn = 'ssh-user@myserver.example.com:22';
+ const host = 'myserver.example.com';
+ const rowPath = '/home/u/project-a';
+ const otherPath = '/home/u/project-b';
+
+ const sessionA = {
+ workspacePath: rowPath,
+ remoteConnectionId: conn,
+ remoteSshHost: host,
+ };
+ const sessionB = {
+ workspacePath: otherPath,
+ remoteConnectionId: conn,
+ remoteSshHost: host,
+ };
+
+ expect(
+ sessionBelongsToWorkspaceNavRow(sessionA, rowPath, conn, host)
+ ).toBe(true);
+ expect(
+ sessionBelongsToWorkspaceNavRow(sessionB, rowPath, conn, host)
+ ).toBe(false);
+ });
});
diff --git a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts
index d4353795..191fb93b 100644
--- a/src/web-ui/src/flow_chat/utils/sessionOrdering.ts
+++ b/src/web-ui/src/flow_chat/utils/sessionOrdering.ts
@@ -20,8 +20,8 @@ function effectiveWorkspaceSshHost(
/**
* Whether a persisted session belongs to a nav row for this workspace.
- * Remote mirror lists sessions by host+path on disk; metadata `workspacePath` / `remoteSshHost` can be stale,
- * so we must match by SSH host (from metadata or embedded in connection id) before rejecting on path alone.
+ * Remote workspaces are scoped by **SSH host + normalized remote root** (and connection id when present).
+ * We must never treat "same host" as sufficient: two tabs to the same server at `/a` vs `/b` are distinct.
*/
export function sessionBelongsToWorkspaceNavRow(
session: Pick,
@@ -40,10 +40,11 @@ export function sessionBelongsToWorkspaceNavRow(
const wsConnHost = hostFromSshConnectionId(wsConn);
if (wsHostEff.length > 0) {
- if (sessHost === wsHostEff) {
+ // Host match alone is insufficient (same server, different remote folders).
+ if (sessHost === wsHostEff && sp === wp) {
return true;
}
- if (sessConnHost === wsHostEff) {
+ if (sessConnHost === wsHostEff && sp === wp) {
return true;
}
if (sessConnHost && wsConnHost && sessConnHost === wsConnHost) {
diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json
index 4cc9a2db..59a22bda 100644
--- a/src/web-ui/src/locales/en-US/common.json
+++ b/src/web-ui/src/locales/en-US/common.json
@@ -52,7 +52,7 @@
"showChatPanel": "Show Chat Panel",
"hideChatPanel": "Hide Chat Panel",
"switchToToolbar": "Floating window mode",
- "remoteConnect": "Remote Control (Beta)",
+ "remoteConnect": "Remote Control",
"modeSwitchAriaLabel": "View mode switch",
"modeCowork": "Cowork",
"modeCoder": "Coder",
@@ -368,7 +368,7 @@
"errorCreateFailed": "Failed to create project"
},
"remoteConnect": {
- "title": "Remote Control (Beta)",
+ "title": "Remote Control",
"tabLan": "LAN",
"tabBitfunServer": "BitFun Server",
"tabNgrok": "NAT Traversal",
@@ -434,7 +434,7 @@
"openNgrokSetup": "Open ngrok setup page",
"disclaimerTitle": "Remote Connect Disclaimer",
"disclaimerIntro": "Before enabling Remote Connect, please read and accept the following:",
- "disclaimerItemBeta": "Remote Connect is currently in Beta. It may contain undiscovered security vulnerabilities, functional defects, or incompatible changes. Please use it with full awareness of the risks.",
+ "disclaimerItemGeneralRisk": "Remote Connect may contain undiscovered security vulnerabilities, functional defects, or incompatible changes. Please use it with full awareness of the risks.",
"disclaimerItemSecurity": "Remote Connect enables network communication paths (including but not limited to LAN, third-party relay, self-hosted relay, bot channels, and other pathways). Use it only on trusted devices and networks.",
"disclaimerItemEncryption": "Remote message payloads are protected with end-to-end encryption (X25519 ECDH + AES-256-GCM with ephemeral key pairs per session); relay servers cannot decrypt message content. However, required metadata (such as device name, connection state, service endpoint, and other connection-context details) is not covered by business-message encryption and may still be visible to network paths, service nodes, or other infrastructure.",
"disclaimerItemOpenSource": "BitFun's Remote Connect encryption implementation is fully open-source. You are free to audit the source code to verify its security.",
@@ -942,6 +942,8 @@
"pickPrivateKeyDialogTitle": "Select SSH private key",
"passphrase": "Passphrase",
"passphraseOptional": "Leave empty if none",
+ "homeFolder": "Home folder",
+ "clickToEditPath": "Click to edit path",
"selectWorkspace": "Select Workspace Directory",
"openWorkspace": "Open as Workspace",
"selected": "Selected",
diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json
index bcc6105d..31ed7f84 100644
--- a/src/web-ui/src/locales/zh-CN/common.json
+++ b/src/web-ui/src/locales/zh-CN/common.json
@@ -52,7 +52,7 @@
"showChatPanel": "显示聊天面板",
"hideChatPanel": "隐藏聊天面板",
"switchToToolbar": "悬浮窗模式",
- "remoteConnect": "远程控制 (Beta)",
+ "remoteConnect": "远程控制",
"modeSwitchAriaLabel": "视图模式切换",
"modeCowork": "Cowork",
"modeCoder": "Coder",
@@ -368,7 +368,7 @@
"errorCreateFailed": "创建工程失败"
},
"remoteConnect": {
- "title": "远程控制 (Beta)",
+ "title": "远程控制",
"tabLan": "局域网",
"tabBitfunServer": "BitFun服务器",
"tabNgrok": "内网穿透",
@@ -434,7 +434,7 @@
"openNgrokSetup": "打开 ngrok 安装与配置页面",
"disclaimerTitle": "远程连接免责声明",
"disclaimerIntro": "启用远程连接前,请确认你已理解并接受以下事项:",
- "disclaimerItemBeta": "远程连接目前为 Beta 版本,可能存在未发现的安全漏洞、功能缺陷或不兼容变更,请在充分了解风险后使用。",
+ "disclaimerItemGeneralRisk": "远程连接可能存在未发现的安全漏洞、功能缺陷或不兼容变更,请在充分了解风险后使用。",
"disclaimerItemSecurity": "远程连接会开启与网络通信相关的能力(包括但不限于局域网、第三方中继、自建服务、机器人通道等),请仅在可信网络和可信设备上使用。",
"disclaimerItemEncryption": "远程连接采用端到端加密(X25519 ECDH + AES-256-GCM,每次会话生成临时密钥对)传输业务消息,中继服务器无法解密消息内容;但设备名称、连接状态、服务地址等必要元数据及其他连接上下文信息不属于业务消息密文范畴,仍可能被网络路径、服务节点或其他基础设施感知。",
"disclaimerItemOpenSource": "BitFun 远程连接的加密实现完全开源,你可以自行审计源码以验证安全性。",
@@ -942,6 +942,8 @@
"pickPrivateKeyDialogTitle": "选择 SSH 私钥",
"passphrase": "密码短语",
"passphraseOptional": "留空表示无密码短语",
+ "homeFolder": "主目录",
+ "clickToEditPath": "点击编辑路径",
"selectWorkspace": "选择工作区目录",
"openWorkspace": "打开为工作区",
"selected": "已选择",