Compare commits

..

10 Commits

Author SHA1 Message Date
Pascal André
3bad0afd7d perf(ui): defer locale and overlay bundles (#238)
## Summary
- defer locale and overlay loading work away from the first critical
render path
- seed locale state from the bootstrap preload so the first render can
use the preloaded language immediately
- keep bootstrap cache and locale fallback behavior consistent on
subsequent launches

## Testing
- npm run build --workspace @codenomad/ui
2026-03-23 15:12:28 +00:00
Pascal André
8567d49178 perf(ui): split right panel and secondary viewer chunks (#239)
## Summary
- split the right panel, picker, and tool call secondary viewers into
smaller deferred chunks
- release hidden right-panel file buffers and stop tracking static
tool-call scrollers when they are not needed
- keep this branch focused on the remaining secondary viewer chunking
work now that the Monaco-specific chunking moved into PR 215

## Testing
- npm run build --workspace @codenomad/ui
2026-03-23 08:47:03 +00:00
MusiCode1
09284ee2ce feat(ui): add RTL support for Hebrew/Arabic text (#229)
## What and why

CodeNomad had no RTL (right-to-left) support, so users writing in Hebrew
or Arabic would see their messages displayed left-to-right — misaligned
text, broken reading flow, wrong punctuation placement.

This PR adds automatic direction detection to all elements that display
user or model text. The browser detects direction from the first strong
character in each text block: Hebrew/Arabic → RTL, Latin/code → LTR. No
configuration needed — it just works per message, per paragraph.

## Technical notes

The natural fix is `dir="auto"` on the containing elements. However,
Chromium does not propagate direction detection from a parent `<div>`
into its `<p>` children — so Hebrew inside `<p>` rendered via
`innerHTML` (as markdown is) was still detected as LTR. The fix is to
apply `unicode-bidi: plaintext` via CSS directly on the block-level
elements (`p`, `li`, headings, etc.), which has the same auto-detection
semantics but applies per element.

## Summary

- Add `dir="auto"` to all elements containing user-generated or
model-generated text (message content, prompt input, session names, tool
outputs) so the browser auto-detects text direction
- Add `unicode-bidi: plaintext` via CSS to markdown block elements (`p`,
`li`, headings, `blockquote`, `td`/`th`) to fix per-paragraph RTL
detection in Chromium (where `dir="auto"` on a parent div does not
recurse into block children)
- Convert physical CSS properties to logical equivalents in
`markdown.css`: `border-left` → `border-inline-start`, `padding-left` →
`padding-inline-start`, `text-align: left` → `text-align: start`,
`margin-left` → `margin-inline-start`

## Affected components

- `markdown.tsx` — main markdown renderer
- `message-part.tsx` — text part wrapper and plain-text fallback
- `message-item.tsx` — message body and error blocks
- `prompt-input.tsx` — user input textarea
- `session-list.tsx` — session titles in sidebar
- `session-rename-dialog.tsx` — session rename input
- `instance-welcome-view.tsx` — Resume Session dialog
- `tool-call/markdown-render.tsx` — tool output markdown fallback
- `tool-call/ansi-render.tsx` — ANSI output
- `tool-call/diagnostics-section.tsx` — diagnostic messages

## Test plan

- [ ] Send a Hebrew-only message → text right-aligned
- [ ] Send a mixed Hebrew + English message → correct per-paragraph
direction
- [ ] Message containing a code block → code stays LTR
- [ ] Type Hebrew in the prompt textarea → input flows right-to-left
- [ ] Hebrew session name in sidebar → right-aligned
- [ ] Hebrew session name in Resume Session dialog → right-aligned

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 20:18:24 +00:00
Pascal André
a2e30f1b54 fix(tauri): restore desktop menu controls and fullscreen shortcut (#226)
## Summary
- restore the missing desktop View and Window menu controls
- use native reload and window actions where supported instead of
brittle webview-only behavior
- restore the working fullscreen keyboard shortcut while keeping the
zoom menu labels aligned with the intended desktop behavior

## Testing
- cargo check --manifest-path packages/tauri-app/src-tauri/Cargo.toml
2026-03-22 20:13:29 +00:00
Shantur Rathore
a4af811de3 Merge branch 'dev' of github.com:NeuralNomadsAI/CodeNomad into dev 2026-03-22 20:09:24 +00:00
Shantur Rathore
c5aa59ca75 fix(ui): keep reasoning streams pinned to bottom 2026-03-22 20:04:45 +00:00
Shantur Rathore
b8e0714b68 fix(ui): reduce message stream follow threshold 2026-03-22 19:54:28 +00:00
Shantur Rathore
3f890e5de1 fix(ui): restore spacing between virtualized message parts 2026-03-22 19:46:44 +00:00
Shantur Rathore
935926d875 ci: skip draft PR builds until ready 2026-03-22 19:41:48 +00:00
Pascal André
74f753abf4 perf(ui): lazy-load markdown and defer diff rendering (#215)
## Summary
- lazy-load the markdown and diff render paths so they stop inflating
initial UI startup work
- move shared text rendering helpers out of the markdown path and keep
diff rendering on the deferred path
- defer the Monaco secondary viewers so the markdown and diff path no
longer keeps that work in the main bundle

## Follow-ups
- related fork follow-up: Pagecran/CodeNomad#1
- that follow-up is now independent on dev and only keeps the remaining
right panel, picker, and tool-call secondary chunking work

## Testing
- npm run typecheck --workspace @codenomad/ui
- npm run build --workspace @codenomad/ui
2026-03-22 11:54:05 +00:00
27 changed files with 756 additions and 418 deletions

6
manifest.json Normal file
View File

@@ -0,0 +1,6 @@
{
"minServerVersion": "0.12.3",
"latestUIVersion": "0.12.3-rtl",
"uiPackageURL": "https://github.com/MusiCode1/CodeNomad/releases/download/v0.12.3-rtl/codenomad-ui-rtl.zip",
"sha256": "a2ce1aaa04345a2f9ca9d3c3149567867f3a5e477cf6eb269381e6dc1bec7ca2"
}

View File

@@ -473,6 +473,7 @@ dependencies = [
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-notification", "tauri-plugin-notification",
"tauri-plugin-opener", "tauri-plugin-opener",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -1350,6 +1351,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix 1.1.4",
"windows-link 0.2.1",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.16" version = "0.1.16"
@@ -1482,6 +1493,24 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "global-hotkey"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9247516746aa8e53411a0db9b62b0e24efbcf6a76e0ba73e5a91b512ddabed7"
dependencies = [
"crossbeam-channel",
"keyboard-types",
"objc2",
"objc2-app-kit",
"once_cell",
"serde",
"thiserror 2.0.18",
"windows-sys 0.59.0",
"x11rb",
"xkeysym",
]
[[package]] [[package]]
name = "gobject-sys" name = "gobject-sys"
version = "0.18.0" version = "0.18.0"
@@ -4055,6 +4084,21 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-global-shortcut"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "424af23c7e88d05e4a1a6fc2c7be077912f8c76bd7900fd50aa2b7cbf5a2c405"
dependencies = [
"global-hotkey",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "tauri-plugin-notification" name = "tauri-plugin-notification"
version = "2.3.3" version = "2.3.3"
@@ -5735,6 +5779,29 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "x11rb"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
"gethostname",
"rustix 1.1.4",
"x11rb-protocol",
]
[[package]]
name = "x11rb-protocol"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "xkeysym"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.1" version = "0.8.1"

View File

@@ -23,6 +23,7 @@ keepawake = "0.6"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
dirs = "5" dirs = "5"
tauri-plugin-opener = "2" tauri-plugin-opener = "2"
tauri-plugin-global-shortcut = "2"
url = "2" url = "2"
tauri-plugin-notification = "2" tauri-plugin-notification = "2"

File diff suppressed because one or more lines are too long

View File

@@ -2378,6 +2378,72 @@
"const": "dialog:deny-save", "const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope." "markdownDescription": "Denies the save command without any pre-configured scope."
}, },
{
"description": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n",
"type": "string",
"const": "global-shortcut:default",
"markdownDescription": "No features are enabled by default, as we believe\nthe shortcuts can be inherently dangerous and it is\napplication specific if specific shortcuts should be\nregistered or unregistered.\n"
},
{
"description": "Enables the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-is-registered",
"markdownDescription": "Enables the is_registered command without any pre-configured scope."
},
{
"description": "Enables the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register",
"markdownDescription": "Enables the register command without any pre-configured scope."
},
{
"description": "Enables the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-register-all",
"markdownDescription": "Enables the register_all command without any pre-configured scope."
},
{
"description": "Enables the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister",
"markdownDescription": "Enables the unregister command without any pre-configured scope."
},
{
"description": "Enables the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:allow-unregister-all",
"markdownDescription": "Enables the unregister_all command without any pre-configured scope."
},
{
"description": "Denies the is_registered command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-is-registered",
"markdownDescription": "Denies the is_registered command without any pre-configured scope."
},
{
"description": "Denies the register command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register",
"markdownDescription": "Denies the register command without any pre-configured scope."
},
{
"description": "Denies the register_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-register-all",
"markdownDescription": "Denies the register_all command without any pre-configured scope."
},
{
"description": "Denies the unregister command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister",
"markdownDescription": "Denies the unregister command without any pre-configured scope."
},
{
"description": "Denies the unregister_all command without any pre-configured scope.",
"type": "string",
"const": "global-shortcut:deny-unregister-all",
"markdownDescription": "Denies the unregister_all command without any pre-configured scope."
},
{ {
"description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`", "description": "This permission set configures which\nnotification features are by default exposed.\n\n#### Granted Permissions\n\nIt allows all notification related features.\n\n\n#### This default permission set includes:\n\n- `allow-is-permission-granted`\n- `allow-request-permission`\n- `allow-notify`\n- `allow-register-action-types`\n- `allow-register-listener`\n- `allow-cancel`\n- `allow-get-pending`\n- `allow-remove-active`\n- `allow-get-active`\n- `allow-check-permissions`\n- `allow-show`\n- `allow-batch`\n- `allow-list-channels`\n- `allow-delete-channel`\n- `allow-create-channel`\n- `allow-permission-state`",
"type": "string", "type": "string",

View File

@@ -8,10 +8,14 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder}; use tauri::menu::{MenuBuilder, MenuItem, SubmenuBuilder};
use tauri::plugin::{Builder as PluginBuilder, TauriPlugin}; use tauri::plugin::{Builder as PluginBuilder, TauriPlugin};
use tauri::webview::Webview; use tauri::webview::Webview;
use tauri::{AppHandle, Emitter, Manager, Runtime, Wry}; use tauri::{AppHandle, Emitter, Manager, Runtime, WindowEvent, Wry};
use tauri_plugin_global_shortcut::{
Code as ShortcutCode, GlobalShortcutExt, Shortcut, ShortcutState,
};
use tauri_plugin_opener::OpenerExt; use tauri_plugin_opener::OpenerExt;
use url::Url; use url::Url;
@@ -25,6 +29,10 @@ use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; use windows_sys::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID;
static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false); static QUIT_REQUESTED: AtomicBool = AtomicBool::new(false);
const DEFAULT_ZOOM_LEVEL: f64 = 1.0;
const ZOOM_STEP: f64 = 0.2;
const MIN_ZOOM_LEVEL: f64 = 0.2;
const MAX_ZOOM_LEVEL: f64 = 5.0;
#[cfg(windows)] #[cfg(windows)]
const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client"; const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
@@ -32,6 +40,7 @@ const WINDOWS_APP_USER_MODEL_ID: &str = "ai.neuralnomads.codenomad.client";
pub struct AppState { pub struct AppState {
pub manager: CliProcessManager, pub manager: CliProcessManager,
pub wake_lock: Mutex<Option<KeepAwake>>, pub wake_lock: Mutex<Option<KeepAwake>>,
pub zoom_level: Mutex<f64>,
} }
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
@@ -157,6 +166,83 @@ fn emit_folder_drop_event(
} }
} }
fn clamp_zoom_level(value: f64) -> f64 {
value.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL)
}
fn set_main_window_zoom(app_handle: &AppHandle, next_zoom: f64) {
if let Some(window) = app_handle.get_webview_window("main") {
let normalized = clamp_zoom_level(next_zoom);
if window.set_zoom(normalized).is_ok() {
if let Ok(mut zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
*zoom_level = normalized;
}
}
}
}
fn reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.reload();
}
}
fn force_reload_main_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
if let Ok(mut url) = window.url() {
if should_allow_internal(&url) {
let reload_token = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.to_string();
let existing_pairs: Vec<(String, String)> = url
.query_pairs()
.into_owned()
.filter(|(key, _)| key != "__codenomad_force_reload")
.collect();
{
let mut pairs = url.query_pairs_mut();
pairs.clear();
for (key, value) in existing_pairs {
pairs.append_pair(&key, &value);
}
pairs.append_pair("__codenomad_force_reload", &reload_token);
}
let _ = window.navigate(url);
return;
}
}
let _ = window.reload();
}
}
fn toggle_fullscreen_window(app_handle: &AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let next_fullscreen = !window.is_fullscreen().unwrap_or(false);
let _ = window.set_fullscreen(next_fullscreen);
if cfg!(not(target_os = "macos")) {
if next_fullscreen {
let _ = window.hide_menu();
} else {
let _ = window.show_menu();
}
}
}
}
fn fullscreen_shortcut() -> Option<Shortcut> {
if cfg!(target_os = "macos") {
None
} else {
Some(Shortcut::new(None, ShortcutCode::F11))
}
}
#[cfg(windows)] #[cfg(windows)]
fn set_windows_app_user_model_id() { fn set_windows_app_user_model_id() {
let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID) let app_id: Vec<u16> = OsStr::new(WINDOWS_APP_USER_MODEL_ID)
@@ -181,15 +267,48 @@ fn main() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(
tauri_plugin_global_shortcut::Builder::new()
.with_handler(|app, shortcut, event| {
if event.state() != ShortcutState::Pressed {
return;
}
if fullscreen_shortcut().as_ref() == Some(shortcut) {
toggle_fullscreen_window(app);
}
})
.build(),
)
.plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_notification::init())
.plugin(navigation_guard) .plugin(navigation_guard)
.manage(AppState { .manage(AppState {
manager: CliProcessManager::new(), manager: CliProcessManager::new(),
wake_lock: Mutex::new(None), wake_lock: Mutex::new(None),
zoom_level: Mutex::new(DEFAULT_ZOOM_LEVEL),
}) })
.setup(|app| { .setup(|app| {
set_windows_app_user_model_id(); set_windows_app_user_model_id();
build_menu(&app.handle())?; build_menu(&app.handle())?;
if let Some(shortcut) = fullscreen_shortcut() {
let shortcut_manager = app.handle().global_shortcut();
let _ = shortcut_manager.register(shortcut.clone());
if let Some(window) = app.get_webview_window("main") {
let app_handle = app.handle().clone();
window.on_window_event(move |event| {
if let WindowEvent::Focused(focused) = event {
let shortcut_manager = app_handle.global_shortcut();
if *focused {
let _ = shortcut_manager.register(shortcut.clone());
} else {
let _ = shortcut_manager.unregister(shortcut.clone());
}
}
});
}
}
let dev_mode = is_dev_mode(); let dev_mode = is_dev_mode();
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let manager = app.state::<AppState>().manager.clone(); let manager = app.state::<AppState>().manager.clone();
@@ -214,36 +333,42 @@ fn main() {
let _ = window.emit("menu:newInstance", ()); let _ = window.emit("menu:newInstance", ());
} }
} }
"close" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
"quit" => { "quit" => {
app_handle.exit(0); app_handle.exit(0);
} }
// View menu // View menu
"reload" => { "reload" => {
if let Some(window) = app_handle.get_webview_window("main") { reload_main_window(app_handle);
let _ = window.eval("window.location.reload()");
}
} }
"force_reload" => { "force_reload" => {
if let Some(window) = app_handle.get_webview_window("main") { force_reload_main_window(app_handle);
let _ = window.eval("window.location.reload(true)");
}
} }
"toggle_devtools" => { "toggle_devtools" => {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = app_handle.get_webview_window("main") {
window.open_devtools(); if window.is_devtools_open() {
window.close_devtools();
} else {
window.open_devtools();
}
}
}
"reset_zoom" => {
set_main_window_zoom(app_handle, DEFAULT_ZOOM_LEVEL);
}
"zoom_in" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level + ZOOM_STEP);
}
}
"zoom_out" => {
if let Ok(zoom_level) = app_handle.state::<AppState>().zoom_level.lock() {
set_main_window_zoom(app_handle, *zoom_level - ZOOM_STEP);
} }
} }
"toggle_fullscreen" => { "toggle_fullscreen" => {
if let Some(window) = app_handle.get_webview_window("main") { toggle_fullscreen_window(app_handle);
let _ = window.set_fullscreen(!window.is_fullscreen().unwrap_or(false));
}
} }
// Window menu // Window menu
@@ -257,6 +382,11 @@ fn main() {
let _ = window.maximize(); let _ = window.maximize();
} }
} }
"close_window" => {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.close();
}
}
// App menu (macOS) // App menu (macOS)
"about" => { "about" => {
@@ -344,6 +474,7 @@ fn main() {
fn build_menu(app: &AppHandle) -> tauri::Result<()> { fn build_menu(app: &AppHandle) -> tauri::Result<()> {
let is_mac = cfg!(target_os = "macos"); let is_mac = cfg!(target_os = "macos");
let is_linux = cfg!(target_os = "linux");
// Create submenus // Create submenus
let mut submenus = Vec::new(); let mut submenus = Vec::new();
@@ -371,16 +502,74 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
Some("CmdOrCtrl+N"), Some("CmdOrCtrl+N"),
)?; )?;
let file_menu = SubmenuBuilder::new(app, "File") let file_menu = if is_mac {
.item(&new_instance_item) SubmenuBuilder::new(app, "File")
.separator() .item(&new_instance_item)
.text( .separator()
if is_mac { "close" } else { "quit" }, .close_window()
if is_mac { "Close" } else { "Quit" }, .build()?
) } else {
.build()?; SubmenuBuilder::new(app, "File")
.item(&new_instance_item)
.separator()
.text("quit", "Quit")
.build()?
};
submenus.push(file_menu); submenus.push(file_menu);
let reload_item = MenuItem::with_id(app, "reload", "Reload", true, Some("CmdOrCtrl+R"))?;
let force_reload_item = MenuItem::with_id(
app,
"force_reload",
"Force Reload",
true,
Some("CmdOrCtrl+Shift+R"),
)?;
let toggle_devtools_item = MenuItem::with_id(
app,
"toggle_devtools",
"Toggle Developer Tools",
true,
Some("Alt+CmdOrCtrl+I"),
)?;
let reset_zoom_item =
MenuItem::with_id(app, "reset_zoom", "Actual Size", true, Some("CmdOrCtrl+0"))?;
let zoom_in_item = MenuItem::with_id(
app,
"zoom_in",
if is_mac { "Zoom In" } else { "Zoom In\tCtrl++" },
true,
None::<&str>,
)?;
let zoom_out_item = MenuItem::with_id(
app,
"zoom_out",
if is_mac {
"Zoom Out"
} else {
"Zoom Out\tCtrl+-"
},
true,
None::<&str>,
)?;
let toggle_fullscreen_item = MenuItem::with_id(
app,
"toggle_fullscreen",
if is_mac {
"Toggle Full Screen"
} else {
"Toggle Full Screen\tF11"
},
true,
if is_mac {
Some("Ctrl+Cmd+F")
} else {
None::<&str>
},
)?;
let close_window_item =
MenuItem::with_id(app, "close_window", "Close", true, Some("CmdOrCtrl+W"))?;
// Edit menu with predefined items for standard functionality // Edit menu with predefined items for standard functionality
let edit_menu = SubmenuBuilder::new(app, "Edit") let edit_menu = SubmenuBuilder::new(app, "Edit")
.undo() .undo()
@@ -396,20 +585,39 @@ fn build_menu(app: &AppHandle) -> tauri::Result<()> {
// View menu // View menu
let view_menu = SubmenuBuilder::new(app, "View") let view_menu = SubmenuBuilder::new(app, "View")
.text("reload", "Reload") .item(&reload_item)
.text("force_reload", "Force Reload") .item(&force_reload_item)
.text("toggle_devtools", "Toggle Developer Tools") .item(&toggle_devtools_item)
.separator() .separator()
.item(&reset_zoom_item)
.item(&zoom_in_item)
.item(&zoom_out_item)
.separator() .separator()
.text("toggle_fullscreen", "Toggle Full Screen") .item(&toggle_fullscreen_item)
.build()?; .build()?;
submenus.push(view_menu); submenus.push(view_menu);
// Window menu // Window menu
let window_menu = SubmenuBuilder::new(app, "Window") let window_menu = if is_linux {
.text("minimize", "Minimize") SubmenuBuilder::new(app, "Window")
.text("zoom", "Zoom") .text("minimize", "Minimize")
.build()?; .text("zoom", "Zoom")
.separator()
.item(&close_window_item)
.build()?
} else if is_mac {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.build()?
} else {
SubmenuBuilder::new(app, "Window")
.minimize()
.maximize()
.separator()
.close_window()
.build()?
};
submenus.push(window_menu); submenus.push(window_menu);
// Build the main menu with all submenus // Build the main menu with all submenus

View File

@@ -404,6 +404,7 @@ const InstanceWelcomeView: Component<InstanceWelcomeViewProps> = (props) => {
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span <span
class="text-sm font-medium text-primary whitespace-normal break-words transition-colors" class="text-sm font-medium text-primary whitespace-normal break-words transition-colors"
dir="auto"
classList={{ classList={{
"text-accent": isFocused(), "text-accent": isFocused(),
}} }}

View File

@@ -1,8 +1,10 @@
import { import {
Show, Show,
Suspense,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
lazy,
onCleanup, onCleanup,
type Accessor, type Accessor,
type Component, type Component,
@@ -20,11 +22,6 @@ import type { Session } from "../../../../types/session"
import type { DrawerViewState } from "../types" import type { DrawerViewState } from "../types"
import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types" import type { DiffContextMode, DiffViewMode, DiffWordWrapMode, RightPanelTab } from "./types"
import ChangesTab from "./tabs/ChangesTab"
import FilesTab from "./tabs/FilesTab"
import GitChangesTab from "./tabs/GitChangesTab"
import StatusTab from "./tabs/StatusTab"
import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees" import { getDefaultWorktreeSlug, getOrCreateWorktreeClient, getWorktreeSlugForSession } from "../../../../stores/worktrees"
import { requestData } from "../../../../lib/opencode-api" import { requestData } from "../../../../lib/opencode-api"
import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse" import { buildUnifiedDiffFromSdkPatch, tryReverseApplyUnifiedDiff } from "../../../../lib/unified-diff-reverse"
@@ -49,6 +46,15 @@ import {
readStoredRightPanelTab, readStoredRightPanelTab,
} from "../storage" } from "../storage"
const LazyChangesTab = lazy(() => import("./tabs/ChangesTab"))
const LazyGitChangesTab = lazy(() => import("./tabs/GitChangesTab"))
const LazyFilesTab = lazy(() => import("./tabs/FilesTab"))
const LazyStatusTab = lazy(() => import("./tabs/StatusTab"))
function RightPanelTabFallback() {
return <div class="flex-1 min-h-0" />
}
interface RightPanelProps { interface RightPanelProps {
t: (key: string, vars?: Record<string, any>) => string t: (key: string, vars?: Record<string, any>) => string
@@ -565,6 +571,13 @@ const RightPanel: Component<RightPanelProps> = (props) => {
void loadBrowserEntries(browserPath()) void loadBrowserEntries(browserPath())
}) })
createEffect(() => {
if (rightPanelTab() === "files") return
setBrowserSelectedContent(null)
setBrowserSelectedLoading(false)
setBrowserSelectedError(null)
})
createEffect(() => { createEffect(() => {
if (rightPanelTab() !== "git-changes") return if (rightPanelTab() !== "git-changes") return
if (gitStatusLoading()) return if (gitStatusLoading()) return
@@ -572,6 +585,14 @@ const RightPanel: Component<RightPanelProps> = (props) => {
void loadGitStatus() void loadGitStatus()
}) })
createEffect(() => {
if (rightPanelTab() === "git-changes") return
setGitSelectedBefore(null)
setGitSelectedAfter(null)
setGitSelectedLoading(false)
setGitSelectedError(null)
})
const handleSelectChangesFile = (file: string, closeList: boolean) => { const handleSelectChangesFile = (file: string, closeList: boolean) => {
setSelectedFile(file) setSelectedFile(file)
if (closeList) { if (closeList) {
@@ -738,101 +759,109 @@ const RightPanel: Component<RightPanelProps> = (props) => {
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
<Show when={rightPanelTab() === "changes"}> <Show when={rightPanelTab() === "changes"}>
<ChangesTab <Suspense fallback={<RightPanelTabFallback />}>
t={props.t} <LazyChangesTab
instanceId={props.instanceId} t={props.t}
activeSessionId={props.activeSessionId} instanceId={props.instanceId}
activeSessionDiffs={props.activeSessionDiffs} activeSessionId={props.activeSessionId}
selectedFile={selectedFile} activeSessionDiffs={props.activeSessionDiffs}
onSelectFile={handleSelectChangesFile} selectedFile={selectedFile}
diffViewMode={diffViewMode} onSelectFile={handleSelectChangesFile}
diffContextMode={diffContextMode} diffViewMode={diffViewMode}
diffWordWrapMode={diffWordWrapMode} diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode} diffWordWrapMode={diffWordWrapMode}
onContextModeChange={setDiffContextMode} onViewModeChange={setDiffViewMode}
onWordWrapModeChange={setDiffWordWrapMode} onContextModeChange={setDiffContextMode}
listOpen={changesListOpen} onWordWrapModeChange={setDiffWordWrapMode}
onToggleList={toggleChangesList} listOpen={changesListOpen}
splitWidth={changesSplitWidth} onToggleList={toggleChangesList}
onResizeMouseDown={handleSplitResizeMouseDown("changes")} splitWidth={changesSplitWidth}
onResizeTouchStart={handleSplitResizeTouchStart("changes")} onResizeMouseDown={handleSplitResizeMouseDown("changes")}
isPhoneLayout={props.isPhoneLayout} onResizeTouchStart={handleSplitResizeTouchStart("changes")}
/> isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show> </Show>
<Show when={rightPanelTab() === "git-changes"}> <Show when={rightPanelTab() === "git-changes"}>
<GitChangesTab <Suspense fallback={<RightPanelTabFallback />}>
t={props.t} <LazyGitChangesTab
activeSessionId={props.activeSessionId} t={props.t}
entries={gitStatusEntries} activeSessionId={props.activeSessionId}
statusLoading={gitStatusLoading} entries={gitStatusEntries}
statusError={gitStatusError} statusLoading={gitStatusLoading}
selectedPath={gitSelectedPath} statusError={gitStatusError}
selectedLoading={gitSelectedLoading} selectedPath={gitSelectedPath}
selectedError={gitSelectedError} selectedLoading={gitSelectedLoading}
selectedBefore={gitSelectedBefore} selectedError={gitSelectedError}
selectedAfter={gitSelectedAfter} selectedBefore={gitSelectedBefore}
mostChangedPath={gitMostChangedPath} selectedAfter={gitSelectedAfter}
scopeKey={gitScopeKey} mostChangedPath={gitMostChangedPath}
diffViewMode={diffViewMode} scopeKey={gitScopeKey}
diffContextMode={diffContextMode} diffViewMode={diffViewMode}
diffWordWrapMode={diffWordWrapMode} diffContextMode={diffContextMode}
onViewModeChange={setDiffViewMode} diffWordWrapMode={diffWordWrapMode}
onContextModeChange={setDiffContextMode} onViewModeChange={setDiffViewMode}
onWordWrapModeChange={setDiffWordWrapMode} onContextModeChange={setDiffContextMode}
onOpenFile={(path) => void openGitFile(path)} onWordWrapModeChange={setDiffWordWrapMode}
onRefresh={() => void refreshGitStatus()} onOpenFile={(path: string) => void openGitFile(path)}
listOpen={gitChangesListOpen} onRefresh={() => void refreshGitStatus()}
onToggleList={toggleGitList} listOpen={gitChangesListOpen}
splitWidth={gitChangesSplitWidth} onToggleList={toggleGitList}
onResizeMouseDown={handleSplitResizeMouseDown("git-changes")} splitWidth={gitChangesSplitWidth}
onResizeTouchStart={handleSplitResizeTouchStart("git-changes")} onResizeMouseDown={handleSplitResizeMouseDown("git-changes")}
isPhoneLayout={props.isPhoneLayout} onResizeTouchStart={handleSplitResizeTouchStart("git-changes")}
/> isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show> </Show>
<Show when={rightPanelTab() === "files"}> <Show when={rightPanelTab() === "files"}>
<FilesTab <Suspense fallback={<RightPanelTabFallback />}>
t={props.t} <LazyFilesTab
browserPath={browserPath} t={props.t}
browserEntries={browserEntries} browserPath={browserPath}
browserLoading={browserLoading} browserEntries={browserEntries}
browserError={browserError} browserLoading={browserLoading}
browserSelectedPath={browserSelectedPath} browserError={browserError}
browserSelectedContent={browserSelectedContent} browserSelectedPath={browserSelectedPath}
browserSelectedLoading={browserSelectedLoading} browserSelectedContent={browserSelectedContent}
browserSelectedError={browserSelectedError} browserSelectedLoading={browserSelectedLoading}
parentPath={browserParentPath} browserSelectedError={browserSelectedError}
scopeKey={browserScopeKey} parentPath={browserParentPath}
onLoadEntries={(path) => void loadBrowserEntries(path)} scopeKey={browserScopeKey}
onOpenFile={(path) => void openBrowserFile(path)} onLoadEntries={(path: string) => void loadBrowserEntries(path)}
onRefresh={() => void refreshFilesTab()} onOpenFile={(path: string) => void openBrowserFile(path)}
listOpen={filesListOpen} onRefresh={() => void refreshFilesTab()}
onToggleList={toggleFilesList} listOpen={filesListOpen}
splitWidth={filesSplitWidth} onToggleList={toggleFilesList}
onResizeMouseDown={handleSplitResizeMouseDown("files")} splitWidth={filesSplitWidth}
onResizeTouchStart={handleSplitResizeTouchStart("files")} onResizeMouseDown={handleSplitResizeMouseDown("files")}
isPhoneLayout={props.isPhoneLayout} onResizeTouchStart={handleSplitResizeTouchStart("files")}
/> isPhoneLayout={props.isPhoneLayout}
/>
</Suspense>
</Show> </Show>
<Show when={rightPanelTab() === "status"}> <Show when={rightPanelTab() === "status"}>
<StatusTab <Suspense fallback={<RightPanelTabFallback />}>
t={props.t} <LazyStatusTab
instanceId={props.instanceId} t={props.t}
instance={props.instance} instanceId={props.instanceId}
activeSessionId={props.activeSessionId} instance={props.instance}
activeSession={props.activeSession} activeSessionId={props.activeSessionId}
activeSessionDiffs={props.activeSessionDiffs} activeSession={props.activeSession}
latestTodoState={props.latestTodoState} activeSessionDiffs={props.activeSessionDiffs}
backgroundProcessList={props.backgroundProcessList} latestTodoState={props.latestTodoState}
onOpenBackgroundOutput={props.onOpenBackgroundOutput} backgroundProcessList={props.backgroundProcessList}
onStopBackgroundProcess={props.onStopBackgroundProcess} onOpenBackgroundOutput={props.onOpenBackgroundOutput}
onTerminateBackgroundProcess={props.onTerminateBackgroundProcess} onStopBackgroundProcess={props.onStopBackgroundProcess}
expandedItems={rightPanelExpandedItems} onTerminateBackgroundProcess={props.onTerminateBackgroundProcess}
onExpandedItemsChange={handleAccordionChange} expandedItems={rightPanelExpandedItems}
onOpenChangesTab={openChangesTabFromStatus} onExpandedItemsChange={handleAccordionChange}
/> onOpenChangesTab={openChangesTabFromStatus}
/>
</Suspense>
</Show> </Show>
</div> </div>
</div> </div>

View File

@@ -244,6 +244,7 @@ export function Markdown(props: MarkdownProps) {
<div <div
ref={containerRef} ref={containerRef}
class="markdown-body" class="markdown-body"
dir="auto"
data-view="markdown" data-view="markdown"
data-part-id={resolved().partId} data-part-id={resolved().partId}
data-markdown-theme={resolved().themeKey} data-markdown-theme={resolved().themeKey}

View File

@@ -1,7 +1,6 @@
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup, untrack } from "solid-js" import { For, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack } from "solid-js"
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid" import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
import MessageItem from "./message-item" import MessageItem from "./message-item"
import ToolCall from "./tool-call"
import type { InstanceMessageStore } from "../stores/message-v2/instance-store" import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
import type { ClientPart, MessageInfo } from "../types/message" import type { ClientPart, MessageInfo } from "../types/message"
import { partHasRenderableText } from "../types/message" import { partHasRenderableText } from "../types/message"
@@ -29,6 +28,12 @@ const USER_BORDER_COLOR = "var(--message-user-border)"
const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)" const ASSISTANT_BORDER_COLOR = "var(--message-assistant-border)"
const TOOL_BORDER_COLOR = "var(--message-tool-border)" const TOOL_BORDER_COLOR = "var(--message-tool-border)"
const LazyToolCall = lazy(() => import("./tool-call"))
function ToolCallFallback() {
return <div class="tool-call tool-call-loading" />
}
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
@@ -500,16 +505,18 @@ function ToolCallItem(props: ToolCallItemProps) {
</div> </div>
</div> </div>
<ToolCall <Suspense fallback={<ToolCallFallback />}>
toolCall={resolvedToolPart()} <LazyToolCall
toolCallId={props.partId} toolCall={resolvedToolPart()}
messageId={props.messageId} toolCallId={props.partId}
messageVersion={messageVersion()} messageId={props.messageId}
partVersion={partVersion()} messageVersion={messageVersion()}
instanceId={props.instanceId} partVersion={partVersion()}
sessionId={props.sessionId} instanceId={props.instanceId}
onContentRendered={props.onContentRendered} sessionId={props.sessionId}
/> onContentRendered={props.onContentRendered}
/>
</Suspense>
</div> </div>
)} )}
</Show> </Show>
@@ -902,6 +909,7 @@ export default function MessageBlock(props: MessageBlockProps) {
onDeleteMessagesUpTo={props.onDeleteMessagesUpTo} onDeleteMessagesUpTo={props.onDeleteMessagesUpTo}
selectedMessageIds={props.selectedMessageIds} selectedMessageIds={props.selectedMessageIds}
onToggleSelectedMessage={props.onToggleSelectedMessage} onToggleSelectedMessage={props.onToggleSelectedMessage}
onContentRendered={props.onContentRendered}
/> />
</Match> </Match>
</Switch> </Switch>
@@ -1280,6 +1288,7 @@ interface ReasoningCardProps {
onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void> onDeleteMessagesUpTo?: (messageId: string) => void | Promise<void>
selectedMessageIds?: () => Set<string> selectedMessageIds?: () => Set<string>
onToggleSelectedMessage?: (messageId: string, selected: boolean) => void onToggleSelectedMessage?: (messageId: string, selected: boolean) => void
onContentRendered?: () => void
} }
function ReasoningCard(props: ReasoningCardProps) { function ReasoningCard(props: ReasoningCardProps) {
@@ -1288,6 +1297,25 @@ function ReasoningCard(props: ReasoningCardProps) {
const [deletingMessage, setDeletingMessage] = createSignal(false) const [deletingMessage, setDeletingMessage] = createSignal(false)
const [deletingUpTo, setDeletingUpTo] = createSignal(false) const [deletingUpTo, setDeletingUpTo] = createSignal(false)
const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId)) const isSelectedForDeletion = () => Boolean(props.selectedMessageIds?.().has(props.messageId))
let pendingRenderNotificationFrame: number | null = null
const notifyContentRendered = () => {
if (!props.onContentRendered || typeof requestAnimationFrame !== "function") return
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
}
pendingRenderNotificationFrame = requestAnimationFrame(() => {
pendingRenderNotificationFrame = null
props.onContentRendered?.()
})
}
onCleanup(() => {
if (pendingRenderNotificationFrame !== null) {
cancelAnimationFrame(pendingRenderNotificationFrame)
pendingRenderNotificationFrame = null
}
})
createEffect(() => { createEffect(() => {
setExpanded(Boolean(props.defaultExpanded)) setExpanded(Boolean(props.defaultExpanded))
@@ -1356,6 +1384,12 @@ function ReasoningCard(props: ReasoningCardProps) {
const viewHideLabel = () => const viewHideLabel = () =>
expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view") expanded() ? t("messageBlock.reasoning.indicator.hide") : t("messageBlock.reasoning.indicator.view")
createEffect(() => {
if (!expanded()) return
reasoningText()
notifyContentRendered()
})
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage() const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
const handleDeleteMessage = async (event: MouseEvent) => { const handleDeleteMessage = async (event: MouseEvent) => {

View File

@@ -542,7 +542,7 @@ export default function MessageItem(props: MessageItemProps) {
</header> </header>
<div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]"> <div class="pt-1 whitespace-pre-wrap break-words leading-[1.1]" dir="auto">
<Show when={props.isQueued && isUser()}> <Show when={props.isQueued && isUser()}>
@@ -550,7 +550,7 @@ export default function MessageItem(props: MessageItemProps) {
</Show> </Show>
<Show when={errorMessage()}> <Show when={errorMessage()}>
<div class="message-error-block"> {errorMessage()}</div> <div class="message-error-block" dir="auto"> {errorMessage()}</div>
</Show> </Show>
<Show when={isGenerating()}> <Show when={isGenerating()}>

View File

@@ -1,5 +1,4 @@
import { Show, Match, Switch } from "solid-js" import { Match, Show, Suspense, Switch, lazy } from "solid-js"
import ToolCall from "./tool-call"
import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state" import { isItemExpanded, toggleItemExpanded } from "../stores/tool-call-state"
import { Markdown } from "./markdown" import { Markdown } from "./markdown"
import { useTheme } from "../lib/theme" import { useTheme } from "../lib/theme"
@@ -7,6 +6,8 @@ import { partHasRenderableText, SDKPart, TextPart, ClientPart } from "../types/m
type ToolCallPart = Extract<ClientPart, { type: "tool" }> type ToolCallPart = Extract<ClientPart, { type: "tool" }>
const LazyToolCall = lazy(() => import("./tool-call"))
interface MessagePartProps { interface MessagePartProps {
part: ClientPart part: ClientPart
messageType?: "user" | "assistant" messageType?: "user" | "assistant"
@@ -133,11 +134,12 @@ export default function MessagePart(props: MessagePartProps) {
<Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}> <Show when={!shouldHideTextPart() && partHasRenderableText(props.part)}>
<div <div
class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()} class={canRenderMarkdown() ? markdownContainerClass() : textContainerClass()}
dir="auto"
data-role={textContainerRole()} data-role={textContainerRole()}
data-part-type="text" data-part-type="text"
data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined} data-part-id={typeof (props.part as any)?.id === "string" ? (props.part as any).id : undefined}
> >
<Show when={canRenderMarkdown()} fallback={<span class="text-primary">{plainTextContent()}</span>}> <Show when={canRenderMarkdown()} fallback={<span class="text-primary" dir="auto">{plainTextContent()}</span>}>
<Markdown <Markdown
part={createTextPartForMarkdown()} part={createTextPartForMarkdown()}
instanceId={props.instanceId} instanceId={props.instanceId}
@@ -152,12 +154,14 @@ export default function MessagePart(props: MessagePartProps) {
</Match> </Match>
<Match when={partType() === "tool"}> <Match when={partType() === "tool"}>
<ToolCall <Suspense fallback={<div class="tool-call tool-call-loading" />}>
toolCall={props.part as ToolCallPart} <LazyToolCall
toolCallId={props.part?.id} toolCall={props.part as ToolCallPart}
instanceId={props.instanceId} toolCallId={props.part?.id}
sessionId={props.sessionId} instanceId={props.instanceId}
/> sessionId={props.sessionId}
/>
</Suspense>
</Match> </Match>

View File

@@ -19,7 +19,7 @@ import type { DeleteHoverState } from "../types/delete-hover"
import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache" import { buildRecordDisplayData } from "../stores/message-v2/record-display-cache"
import { getPartCharCount } from "../lib/token-utils" import { getPartCharCount } from "../lib/token-utils"
const SCROLL_SENTINEL_MARGIN_PX = 48 const SCROLL_SENTINEL_MARGIN_PX = 8
const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream" const MESSAGE_SCROLL_CACHE_SCOPE = "message-stream"
const QUOTE_SELECTION_MAX_LENGTH = 2000 const QUOTE_SELECTION_MAX_LENGTH = 2000
const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href

View File

@@ -1,4 +1,4 @@
import { For, Show, createMemo, createSignal, createEffect, onCleanup, type Component } from "solid-js" import { For, Show, Suspense, createMemo, createSignal, createEffect, lazy, onCleanup, type Component } from "solid-js"
import type { PermissionRequestLike } from "../types/permission" import type { PermissionRequestLike } from "../types/permission"
import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission" import { getPermissionCallId, getPermissionDisplayTitle, getPermissionKind, getPermissionMessageId, getPermissionSessionId } from "../types/permission"
import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question" import { getQuestionCallId, getQuestionMessageId, getQuestionSessionId, type QuestionRequest } from "../types/question"
@@ -12,7 +12,8 @@ import {
} from "../stores/instances" } from "../stores/instances"
import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions" import { ensureSessionParentExpanded, loadMessages, sessions as sessionStateSessions, setActiveSessionFromList } from "../stores/sessions"
import { messageStoreBus } from "../stores/message-v2/bus" import { messageStoreBus } from "../stores/message-v2/bus"
import ToolCall from "./tool-call"
const LazyToolCall = lazy(() => import("./tool-call"))
interface PermissionApprovalModalProps { interface PermissionApprovalModalProps {
instanceId: string instanceId: string
@@ -408,15 +409,17 @@ const PermissionApprovalModal: Component<PermissionApprovalModalProps> = (props)
} }
> >
{(data) => ( {(data) => (
<ToolCall <Suspense fallback={<div class="tool-call tool-call-loading" />}>
toolCall={data().toolPart} <LazyToolCall
toolCallId={data().toolPart.id} toolCall={data().toolPart}
messageId={data().messageId} toolCallId={data().toolPart.id}
messageVersion={data().messageVersion} messageId={data().messageId}
partVersion={data().partVersion} messageVersion={data().messageVersion}
instanceId={props.instanceId} partVersion={data().partVersion}
sessionId={data().sessionId} instanceId={props.instanceId}
/> sessionId={data().sessionId}
/>
</Suspense>
)} )}
</Show> </Show>
</div> </div>

View File

@@ -1,6 +1,5 @@
import { createSignal, Show, onMount, onCleanup, createEffect, on } from "solid-js" import { Suspense, createEffect, createSignal, lazy, on, onCleanup, onMount, Show } from "solid-js"
import { ArrowBigUp, ArrowBigDown } from "lucide-solid" import { ArrowBigUp, ArrowBigDown } from "lucide-solid"
import UnifiedPicker from "./unified-picker"
import ExpandButton from "./expand-button" import ExpandButton from "./expand-button"
import { clearAttachments, removeAttachment } from "../stores/attachments" import { clearAttachments, removeAttachment } from "../stores/attachments"
import { resolvePastedPlaceholders } from "../lib/prompt-placeholders" import { resolvePastedPlaceholders } from "../lib/prompt-placeholders"
@@ -20,6 +19,7 @@ import { usePromptAttachments } from "./prompt-input/usePromptAttachments"
import { usePromptPicker } from "./prompt-input/usePromptPicker" import { usePromptPicker } from "./prompt-input/usePromptPicker"
import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown" import { usePromptKeyDown } from "./prompt-input/usePromptKeyDown"
const log = getLogger("actions") const log = getLogger("actions")
const LazyUnifiedPicker = lazy(() => import("./unified-picker"))
function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] { function getConsumedPastedTextAttachmentIds(text: string, attachments: Attachment[]): string[] {
if (!text || attachments.length === 0) return [] if (!text || attachments.length === 0) return []
@@ -467,18 +467,20 @@ export default function PromptInput(props: PromptInputProps) {
onDrop={handleDrop} onDrop={handleDrop}
> >
<Show when={showPicker() && instance()}> <Show when={showPicker() && instance()}>
<UnifiedPicker <Suspense fallback={null}>
open={showPicker()} <LazyUnifiedPicker
mode={pickerMode()} open={showPicker()}
onClose={handlePickerClose} mode={pickerMode()}
onSelect={handlePickerSelect} onClose={handlePickerClose}
agents={instanceAgents()} onSelect={handlePickerSelect}
commands={getCommands(props.instanceId)} agents={instanceAgents()}
instanceClient={instance()!.client} commands={getCommands(props.instanceId)}
searchQuery={searchQuery()} instanceClient={instance()!.client}
textareaRef={textareaRef} searchQuery={searchQuery()}
workspaceId={props.instanceId} textareaRef={textareaRef}
/> workspaceId={props.instanceId}
/>
</Suspense>
</Show> </Show>
<div class="flex flex-1 flex-col"> <div class="flex flex-1 flex-col">
@@ -488,6 +490,7 @@ export default function PromptInput(props: PromptInputProps) {
<textarea <textarea
ref={textareaRef} ref={textareaRef}
class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`} class={`prompt-input ${mode() === "shell" ? "shell-mode" : ""} ${expandState() === "expanded" ? "is-expanded" : ""}`}
dir="auto"
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
value={prompt()} value={prompt()}
onInput={handleInput} onInput={handleInput}

View File

@@ -444,7 +444,7 @@ const SessionList: Component<SessionListProps> = (props) => {
</Show> </Show>
{rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />} {rowProps.isChild ? <Bot class="w-4 h-4 flex-shrink-0" /> : <User class="w-4 h-4 flex-shrink-0" />}
<span class="session-item-title session-item-title--clamp">{title()}</span> <span class="session-item-title session-item-title--clamp" dir="auto">{title()}</span>
</div> </div>
</div> </div>
<div class="session-item-row session-item-meta"> <div class="session-item-row session-item-meta">

View File

@@ -76,6 +76,7 @@ const SessionRenameDialog: Component<SessionRenameDialogProps> = (props) => {
inputRef = element inputRef = element
}} }}
type="text" type="text"
dir="auto"
value={title()} value={title()}
onInput={(event) => setTitle(event.currentTarget.value)} onInput={(event) => setTitle(event.currentTarget.value)}
placeholder={t("sessionRenameDialog.input.placeholder")} placeholder={t("sessionRenameDialog.input.placeholder")}

View File

@@ -514,6 +514,7 @@ function ToolCallDetails(props: {
}) })
const { renderDiffContent } = createDiffContentRenderer({ const { renderDiffContent } = createDiffContentRenderer({
toolState: props.toolState,
preferences: props.preferences, preferences: props.preferences,
setDiffViewMode: props.setDiffViewMode, setDiffViewMode: props.setDiffViewMode,
isDark: props.isDark, isDark: props.isDark,

View File

@@ -20,6 +20,14 @@ export function createAnsiContentRenderer(params: {
const runningAnsiRenderer = createAnsiStreamRenderer() const runningAnsiRenderer = createAnsiStreamRenderer()
let runningAnsiSource = "" let runningAnsiSource = ""
const registerTracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element)
}
const registerUntracked = (element: HTMLDivElement | null) => {
params.scrollHelpers.registerContainer(element, { disableTracking: true })
}
const getMode = () => { const getMode = () => {
const version = params.partVersion?.() const version = params.partVersion?.()
return typeof version === "number" ? String(version) : undefined return typeof version === "number" ? String(version) : undefined
@@ -36,6 +44,8 @@ export function createAnsiContentRenderer(params: {
const cached = cacheHandle.get<AnsiRenderCache>() const cached = cacheHandle.get<AnsiRenderCache>()
const mode = getMode() const mode = getMode()
const isRunningVariant = options.variant === "running" const isRunningVariant = options.variant === "running"
const disableScrollTracking = !isRunningVariant
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
let nextCache: AnsiRenderCache let nextCache: AnsiRenderCache
@@ -87,9 +97,9 @@ export function createAnsiContentRenderer(params: {
} }
return ( return (
<div class={messageClass} ref={params.scrollHelpers.registerContainer} onScroll={params.scrollHelpers.handleScroll}> <div class={messageClass} ref={registerRef} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}>
<pre class="tool-call-content tool-call-ansi" innerHTML={nextCache.html} /> <pre class="tool-call-content tool-call-ansi" dir="auto" innerHTML={nextCache.html} />
{params.scrollHelpers.renderSentinel()} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )
} }

View File

@@ -42,7 +42,7 @@ export function renderDiagnosticsSection(
{entry.displayPath} {entry.displayPath}
<span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span> <span class="tool-call-diagnostic-coords">:L{entry.line || "-"}:C{entry.column || "-"}</span>
</span> </span>
<span class="tool-call-diagnostic-message">{entry.message}</span> <span class="tool-call-diagnostic-message" dir="auto">{entry.message}</span>
</div> </div>
)} )}
</For> </For>

View File

@@ -1,4 +1,5 @@
import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js" import { Suspense, lazy, onMount, type Accessor, type JSXElement } from "solid-js"
import type { ToolState } from "@opencode-ai/sdk/v2"
import type { RenderCache } from "../../types/message" import type { RenderCache } from "../../types/message"
import type { DiffViewMode } from "../../stores/preferences" import type { DiffViewMode } from "../../stores/preferences"
import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types" import type { DiffPayload, DiffRenderOptions, ToolScrollHelpers } from "./types"
@@ -31,6 +32,7 @@ type DiffPrefs = {
} }
export function createDiffContentRenderer(params: { export function createDiffContentRenderer(params: {
toolState: Accessor<ToolState | undefined>
preferences: Accessor<DiffPrefs> preferences: Accessor<DiffPrefs>
setDiffViewMode: (mode: DiffViewMode) => void setDiffViewMode: (mode: DiffViewMode) => void
isDark: Accessor<boolean> isDark: Accessor<boolean>
@@ -58,7 +60,10 @@ export function createDiffContentRenderer(params: {
const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache const cacheHandle = selectedVariant === "permission-diff" ? params.permissionDiffCache : params.diffCache
const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode const diffMode = () => (params.preferences().diffViewMode || "split") as DiffViewMode
const themeKey = params.isDark() ? "dark" : "light" const themeKey = params.isDark() ? "dark" : "light"
const disableScrollTracking = Boolean(options?.disableScrollTracking) const state = params.toolState()
const disableScrollTracking = Boolean(
options?.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending"),
)
const registerRef = disableScrollTracking ? registerUntracked : registerTracked const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const baseEntryParams = cacheHandle.params() as any const baseEntryParams = cacheHandle.params() as any

View File

@@ -31,10 +31,9 @@ export function createMarkdownContentRenderer(params: {
const size = options.size || "default" const size = options.size || "default"
const disableHighlight = options.disableHighlight || false const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const disableScrollTracking = options.disableScrollTracking || false
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const state = params.toolState() const state = params.toolState()
const disableScrollTracking = options.disableScrollTracking || (state?.status !== "running" && state?.status !== "pending")
const registerRef = disableScrollTracking ? registerUntracked : registerTracked
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight) const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) { if (shouldDeferMarkdown) {
return ( return (
@@ -43,7 +42,7 @@ export function createMarkdownContentRenderer(params: {
ref={registerRef} ref={registerRef}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll} onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
> >
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre> <pre class="whitespace-pre-wrap break-words text-sm font-mono" dir="auto">{options.content}</pre>
{params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )

View File

@@ -1,200 +0,0 @@
import { createLowlight, common } from "lowlight"
type AstNode = {
type: string
value?: string
children?: AstNode[]
startIndex?: number
endIndex?: number
lineNumber?: number
}
type SyntaxNodeEntry = {
node: AstNode
wrapper?: AstNode
}
type SyntaxFileLine = {
value: string
lineNumber: number
valueLength: number
nodeList: SyntaxNodeEntry[]
}
type LowlightApi = ReturnType<typeof createLowlight>
export function processAST(ast: { children: AstNode[] }) {
let lineNumber = 1
const syntaxObj: Record<number, SyntaxFileLine> = {}
const loopAST = (nodes: AstNode[], wrapper?: AstNode) => {
nodes.forEach((node) => {
if (node.type === "text") {
const textValue = node.value ?? ""
if (!textValue.includes("\n")) {
const valueLength = textValue.length
if (!syntaxObj[lineNumber]) {
node.startIndex = 0
node.endIndex = valueLength - 1
syntaxObj[lineNumber] = {
value: textValue,
lineNumber,
valueLength,
nodeList: [{ node, wrapper }],
}
} else {
node.startIndex = syntaxObj[lineNumber].valueLength
node.endIndex = node.startIndex + valueLength - 1
syntaxObj[lineNumber].value += textValue
syntaxObj[lineNumber].valueLength += valueLength
syntaxObj[lineNumber].nodeList.push({ node, wrapper })
}
node.lineNumber = lineNumber
return
}
const lines = textValue.split("\n")
node.children = node.children || []
for (let index = 0; index < lines.length; index++) {
const value = index === lines.length - 1 ? lines[index] : `${lines[index]}\n`
const currentLineNumber = index === 0 ? lineNumber : ++lineNumber
const valueLength = value.length
const childNode: AstNode = {
type: "text",
value,
startIndex: Infinity,
endIndex: Infinity,
lineNumber: currentLineNumber,
}
if (!syntaxObj[currentLineNumber]) {
childNode.startIndex = 0
childNode.endIndex = valueLength - 1
syntaxObj[currentLineNumber] = {
value,
lineNumber: currentLineNumber,
valueLength,
nodeList: [{ node: childNode, wrapper }],
}
} else {
childNode.startIndex = syntaxObj[currentLineNumber].valueLength
childNode.endIndex = childNode.startIndex + valueLength - 1
syntaxObj[currentLineNumber].value += value
syntaxObj[currentLineNumber].valueLength += valueLength
syntaxObj[currentLineNumber].nodeList.push({ node: childNode, wrapper })
}
node.children.push(childNode)
}
node.lineNumber = lineNumber
return
}
if (node.children) {
loopAST(node.children, node)
node.lineNumber = lineNumber
}
})
}
loopAST(ast.children)
return { syntaxFileObject: syntaxObj, syntaxFileLineNumber: lineNumber }
}
export function _getAST() {
return {}
}
const lowlight = createLowlight(common)
lowlight.register("vue", function hljsDefineVue(hljs: any) {
return {
subLanguage: "xml",
contains: [
hljs.COMMENT("<!--", "-->", { relevance: 10 }),
{
begin: /^(\s*)(<script>)/gm,
end: /^(\s*)(<\/script>)/gm,
subLanguage: "javascript",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<script\s+lang=(["'])ts\1>)/gm,
end: /^(\s*)(<\/script>)/gm,
subLanguage: "typescript",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(\s*)(<style(\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "css",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])(?:s[ca]ss)\1(?:\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "scss",
excludeBegin: true,
excludeEnd: true,
},
{
begin: /^(?:\s*)(?:<style(?:\s+scoped)?\s+lang=(["'])stylus\1(?:\s+scoped)?>)/gm,
end: /^(\s*)(<\/style>)/gm,
subLanguage: "stylus",
excludeBegin: true,
excludeEnd: true,
},
],
}
})
let maxLineToIgnoreSyntax = 2000
const ignoreSyntaxHighlightList: (string | RegExp)[] = []
export const highlighter = {
name: "lowlight",
get maxLineToIgnoreSyntax() {
return maxLineToIgnoreSyntax
},
setMaxLineToIgnoreSyntax(value: number) {
maxLineToIgnoreSyntax = value
},
get ignoreSyntaxHighlightList() {
return ignoreSyntaxHighlightList
},
setIgnoreSyntaxHighlightList(values: (string | RegExp)[]) {
ignoreSyntaxHighlightList.length = 0
ignoreSyntaxHighlightList.push(...values)
},
getAST(raw: string, fileName?: string, lang?: string) {
const language = typeof lang === "string" ? lang.trim() : ""
if (
fileName &&
ignoreSyntaxHighlightList.some((item) => (item instanceof RegExp ? item.test(fileName) : fileName === item))
) {
return undefined
}
if (language && lowlight.registered(language)) {
return lowlight.highlight(language, raw)
}
return lowlight.highlightAuto(raw)
},
processAST(ast: { children: AstNode[] }) {
return processAST(ast)
},
hasRegisteredCurrentLang(lang: string) {
return lowlight.registered(lang)
},
getHighlighterEngine(): LowlightApi {
return lowlight
},
type: "class" as const,
}
export const versions = "local-common"

View File

@@ -2,11 +2,6 @@ import { createContext, createEffect, createMemo, createSignal, onCleanup, onMou
import type { ParentComponent } from "solid-js" import type { ParentComponent } from "solid-js"
import { useConfig } from "../../stores/preferences" import { useConfig } from "../../stores/preferences"
import { enMessages } from "./messages/en" import { enMessages } from "./messages/en"
import { esMessages } from "./messages/es"
import { frMessages } from "./messages/fr"
import { ruMessages } from "./messages/ru"
import { jaMessages } from "./messages/ja"
import { zhHansMessages } from "./messages/zh-Hans"
type Messages = Record<string, string> type Messages = Record<string, string>
@@ -15,14 +10,18 @@ export type TranslateParams = Record<string, unknown>
export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans" export type Locale = "en" | "es" | "fr" | "ru" | "ja" | "zh-Hans"
const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const const SUPPORTED_LOCALES: readonly Locale[] = ["en", "es", "fr", "ru", "ja", "zh-Hans"] as const
const SUPPORTED_LOCALES_BY_LOWER = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
const messagesByLocale: Record<Locale, Messages> = { const localeMessagesCache = new Map<Locale, Messages>([["en", enMessages]])
en: enMessages, const localeMessagesPromises = new Map<Locale, Promise<Messages>>()
es: esMessages,
fr: frMessages, const localeLoaders: Record<Locale, () => Promise<Messages>> = {
ru: ruMessages, en: async () => enMessages,
ja: jaMessages, es: async () => (await import("./messages/es")).esMessages,
"zh-Hans": zhHansMessages, fr: async () => (await import("./messages/fr")).frMessages,
ru: async () => (await import("./messages/ru")).ruMessages,
ja: async () => (await import("./messages/ja")).jaMessages,
"zh-Hans": async () => (await import("./messages/zh-Hans")).zhHansMessages,
} }
function normalizeLocaleTag(value: string): string { function normalizeLocaleTag(value: string): string {
@@ -34,8 +33,7 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
const normalized = normalizeLocaleTag(value) const normalized = normalizeLocaleTag(value)
const lower = normalized.toLowerCase() const lower = normalized.toLowerCase()
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale])) const exact = SUPPORTED_LOCALES_BY_LOWER.get(lower)
const exact = supportedLower.get(lower)
if (exact) return exact if (exact) return exact
const parts = lower.split("-") const parts = lower.split("-")
@@ -43,11 +41,11 @@ function matchSupportedLocale(value: string | undefined): Locale | null {
if (!base) return null if (!base) return null
if (base === "zh") { if (base === "zh") {
const zhHans = supportedLower.get("zh-hans") const zhHans = SUPPORTED_LOCALES_BY_LOWER.get("zh-hans")
return zhHans ?? null return zhHans ?? null
} }
const baseMatch = supportedLower.get(base) const baseMatch = SUPPORTED_LOCALES_BY_LOWER.get(base)
return baseMatch ?? null return baseMatch ?? null
} }
@@ -84,8 +82,54 @@ function translateFrom(messages: Messages, key: string, params?: TranslateParams
} }
const [globalRevision, setGlobalRevision] = createSignal(0) const [globalRevision, setGlobalRevision] = createSignal(0)
const initialGlobalLocale: Locale = detectNavigatorLocale() ?? "en" let globalMessages: Messages = enMessages
let globalMessages: Messages = messagesByLocale[initialGlobalLocale] let globalLocale: Locale = "en"
function getMessagesForLocale(locale: Locale): Messages {
return localeMessagesCache.get(locale) ?? enMessages
}
async function loadLocaleMessages(locale: Locale): Promise<Messages> {
const cached = localeMessagesCache.get(locale)
if (cached) {
return cached
}
const pending = localeMessagesPromises.get(locale)
if (pending) {
return pending
}
const loader = localeLoaders[locale]
const promise = loader()
.then((messages) => {
localeMessagesCache.set(locale, messages)
localeMessagesPromises.delete(locale)
return messages
})
.catch((error) => {
localeMessagesPromises.delete(locale)
throw error
})
localeMessagesPromises.set(locale, promise)
return promise
}
export async function preloadLocaleMessages(preferredLocale?: string | null): Promise<Locale> {
const resolvedLocale = matchSupportedLocale(preferredLocale ?? undefined) ?? detectNavigatorLocale() ?? "en"
try {
globalMessages = await loadLocaleMessages(resolvedLocale)
globalLocale = resolvedLocale
setGlobalRevision((value) => value + 1)
return resolvedLocale
} catch {
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
return "en"
}
}
export function tGlobal(key: string, params?: TranslateParams): string { export function tGlobal(key: string, params?: TranslateParams): string {
globalRevision() globalRevision()
@@ -101,9 +145,10 @@ const I18nContext = createContext<I18nContextValue>()
export const I18nProvider: ParentComponent = (props) => { export const I18nProvider: ParentComponent = (props) => {
const { preferences } = useConfig() const { preferences } = useConfig()
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en") const [detectedLocale, setDetectedLocale] = createSignal<Locale>(globalLocale)
const [resolvedLocale, setResolvedLocale] = createSignal<Locale>(globalLocale)
const previousMessages = globalMessages const previousGlobalMessages = globalMessages
const previousGlobalLocale = globalLocale
onMount(() => { onMount(() => {
const detected = detectNavigatorLocale() const detected = detectNavigatorLocale()
@@ -115,19 +160,44 @@ export const I18nProvider: ParentComponent = (props) => {
return configured ?? detectedLocale() ?? "en" return configured ?? detectedLocale() ?? "en"
}) })
const messages = createMemo<Messages>(() => messagesByLocale[locale()]) const messages = createMemo<Messages>(() => getMessagesForLocale(resolvedLocale()))
function t(key: string, params?: TranslateParams): string { function t(key: string, params?: TranslateParams): string {
return translateFrom(messages(), key, params) return translateFrom(messages(), key, params)
} }
createEffect(() => { createEffect(() => {
globalMessages = messages() const nextLocale = locale()
setGlobalRevision((value) => value + 1) let cancelled = false
void loadLocaleMessages(nextLocale)
.then((loadedMessages) => {
if (cancelled) {
return
}
setResolvedLocale(nextLocale)
globalLocale = nextLocale
globalMessages = loadedMessages
setGlobalRevision((value) => value + 1)
})
.catch(() => {
if (cancelled) {
return
}
setResolvedLocale("en")
globalMessages = enMessages
globalLocale = "en"
setGlobalRevision((value) => value + 1)
})
onCleanup(() => {
cancelled = true
})
}) })
onCleanup(() => { onCleanup(() => {
globalMessages = previousMessages globalMessages = previousGlobalMessages
globalLocale = previousGlobalLocale
setGlobalRevision((value) => value + 1) setGlobalRevision((value) => value + 1)
}) })

View File

@@ -4,7 +4,7 @@ import { ThemeProvider } from "./lib/theme"
import { ConfigProvider } from "./stores/preferences" import { ConfigProvider } from "./stores/preferences"
import { InstanceConfigProvider } from "./stores/instance-config" import { InstanceConfigProvider } from "./stores/instance-config"
import { runtimeEnv } from "./lib/runtime-env" import { runtimeEnv } from "./lib/runtime-env"
import { I18nProvider } from "./lib/i18n" import { I18nProvider, preloadLocaleMessages } from "./lib/i18n"
import { storage } from "./lib/storage" import { storage } from "./lib/storage"
import "./index.css" import "./index.css"
import "@git-diff-view/solid/styles/diff-view-pure.css" import "@git-diff-view/solid/styles/diff-view-pure.css"
@@ -31,15 +31,19 @@ async function bootstrap() {
try { try {
const uiConfig = await storage.loadConfigOwner("ui") const uiConfig = await storage.loadConfigOwner("ui")
const theme = (uiConfig as any)?.theme ?? "system" const theme = (uiConfig as any)?.theme
const locale = typeof (uiConfig as any)?.settings?.locale === "string" ? (uiConfig as any).settings.locale : undefined
if (theme === "system") { if (theme === "light" || theme === "dark") {
document.documentElement.removeAttribute("data-theme")
} else {
document.documentElement.setAttribute("data-theme", theme) document.documentElement.setAttribute("data-theme", theme)
} else {
document.documentElement.removeAttribute("data-theme")
} }
await preloadLocaleMessages(locale)
} catch { } catch {
// If config fails to load, fall back to CSS defaults. // If config fails to load, fall back to CSS defaults.
await preloadLocaleMessages()
} }
} }

View File

@@ -24,6 +24,21 @@
color: inherit; color: inherit;
} }
/* Auto-detect text direction per block element for RTL language support (e.g. Hebrew, Arabic) */
.markdown-body p,
.markdown-body li,
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6,
.markdown-body blockquote,
.markdown-body td,
.markdown-body th {
unicode-bidi: plaintext;
}
.markdown-body h1, .markdown-body h1,
.markdown-body h2, .markdown-body h2,
.markdown-body h3, .markdown-body h3,
@@ -129,16 +144,19 @@
} }
.markdown-body blockquote { .markdown-body blockquote {
border-left: 3px solid var(--border-base); border-inline-start: 3px solid var(--border-base);
color: var(--text-secondary); color: var(--text-secondary);
background-color: var(--surface-muted); background-color: var(--surface-muted);
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
border-radius: 0 8px 8px 0; border-start-start-radius: 0;
border-start-end-radius: 8px;
border-end-end-radius: 8px;
border-end-start-radius: 0;
} }
.markdown-body ul, .markdown-body ul,
.markdown-body ol { .markdown-body ol {
padding-left: 1.5rem; padding-inline-start: 1.5rem;
margin: 0.5rem 0; margin: 0.5rem 0;
} }
@@ -166,7 +184,7 @@
.markdown-body td { .markdown-body td {
border: 1px solid var(--border-base); border: 1px solid var(--border-base);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
text-align: left; text-align: start;
color: var(--text-primary); color: var(--text-primary);
background-color: transparent; background-color: transparent;
} }
@@ -221,7 +239,7 @@
cursor: pointer; cursor: pointer;
color: var(--text-secondary); color: var(--text-secondary);
transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease; transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
margin-left: auto; margin-inline-start: auto;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
} }

View File

@@ -132,6 +132,13 @@
margin-bottom: 0; margin-bottom: 0;
} }
.message-stream-block {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 2px;
}
.message-step-start { .message-step-start {
background-color: var(--message-assistant-bg); background-color: var(--message-assistant-bg);
border-left: 4px solid var(--message-assistant-border); border-left: 4px solid var(--message-assistant-border);