From 1413d77b1ff36ed030c179b3bc59dc6a9b9679b3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 12 Feb 2026 17:44:06 +0800 Subject: [PATCH 001/437] desktop: sqlite migration progress bar (#13294) --- packages/desktop/src-tauri/Cargo.lock | 13 ++ packages/desktop/src-tauri/Cargo.toml | 1 + packages/desktop/src-tauri/src/cli.rs | 163 ++++++++++++++++------- packages/desktop/src-tauri/src/lib.rs | 71 ++++++---- packages/desktop/src-tauri/src/server.rs | 8 +- packages/desktop/src/bindings.ts | 3 + packages/desktop/src/loading.tsx | 18 ++- 7 files changed, 197 insertions(+), 80 deletions(-) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 8ce97b2b724d..a2bb2532af7e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -3117,6 +3117,7 @@ dependencies = [ "tauri-plugin-window-state", "tauri-specta", "tokio", + "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", @@ -5631,6 +5632,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.17" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index e9ba55b039a5..67efd8d8c9b5 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -51,6 +51,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-appender = "0.2" chrono = "0.4" +tokio-stream = { version = "0.1.18", features = ["sync"] } [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index b9e1ed4bd504..dade1a28186e 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -1,35 +1,46 @@ +use futures::{FutureExt, Stream, StreamExt, future}; use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri_plugin_shell::{ ShellExt, - process::{Command, CommandChild, CommandEvent, TerminatedPayload}, + process::{CommandChild, CommandEvent, TerminatedPayload}, }; use tauri_plugin_store::StoreExt; +use tauri_specta::Event; use tokio::sync::oneshot; +use tracing::Instrument; use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY}; const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_BINARY_NAME: &str = "opencode"; -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] pub struct ServerConfig { pub hostname: Option, pub port: Option, } -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Debug)] pub struct Config { pub server: Option, } pub async fn get_config(app: &AppHandle) -> Option { - create_command(app, "debug config", &[]) - .output() + let (events, _) = spawn_command(app, "debug config", &[]).ok()?; + + events + .fold(String::new(), async |mut config_str, event| { + if let CommandEvent::Stdout(stdout) = event + && let Ok(s) = str::from_utf8(&stdout) + { + config_str += s + } + + config_str + }) + .map(|v| serde_json::from_str::(&v)) .await - .inspect_err(|e| tracing::warn!("Failed to read OC config: {e}")) .ok() - .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok()) - .and_then(|s| serde_json::from_str::(&s).ok()) } fn get_cli_install_path() -> Option { @@ -175,7 +186,11 @@ fn shell_escape(input: &str) -> String { escaped } -pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command { +pub fn spawn_command( + app: &tauri::AppHandle, + args: &str, + extra_env: &[(&str, String)], +) -> Result<(impl Stream + 'static, CommandChild), tauri_plugin_shell::Error> { let state_dir = app .path() .resolve("", BaseDirectory::AppLocalData) @@ -202,7 +217,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St .map(|(key, value)| (key.to_string(), value.clone())), ); - if cfg!(windows) { + let cmd = if cfg!(windows) { if is_wsl_enabled(app) { tracing::info!("WSL is enabled, spawning CLI server in WSL"); let version = app.package_info().version.to_string(); @@ -234,10 +249,9 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args)); - return app - .shell() + app.shell() .command("wsl") - .args(["-e", "bash", "-lc", &script.join("\n")]); + .args(["-e", "bash", "-lc", &script.join("\n")]) } else { let mut cmd = app .shell() @@ -249,7 +263,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St cmd = cmd.env(key, value); } - return cmd; + cmd } } else { let sidecar = get_sidecar_path(app); @@ -268,7 +282,13 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St } cmd - } + }; + + let (rx, child) = cmd.spawn()?; + let event_stream = tokio_stream::wrappers::ReceiverStream::new(rx); + let event_stream = sqlite_migration::logs_middleware(app.clone(), event_stream); + + Ok((event_stream, child)) } pub fn serve( @@ -286,45 +306,96 @@ pub fn serve( ("OPENCODE_SERVER_PASSWORD", password.to_string()), ]; - let (mut rx, child) = create_command( + let (events, child) = spawn_command( app, format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(), &envs, ) - .spawn() .expect("Failed to spawn opencode"); - tokio::spawn(async move { - let mut exit_tx = Some(exit_tx); - while let Some(event) = rx.recv().await { - match event { - CommandEvent::Stdout(line_bytes) => { - let line = String::from_utf8_lossy(&line_bytes); - tracing::info!(target: "sidecar", "{line}"); - } - CommandEvent::Stderr(line_bytes) => { - let line = String::from_utf8_lossy(&line_bytes); - tracing::info!(target: "sidecar", "{line}"); - } - CommandEvent::Error(err) => { - tracing::error!(target: "sidecar", "{err}"); - } - CommandEvent::Terminated(payload) => { - tracing::info!( - target: "sidecar", - code = ?payload.code, - signal = ?payload.signal, - "Sidecar terminated" - ); - - if let Some(tx) = exit_tx.take() { - let _ = tx.send(payload); + let mut exit_tx = Some(exit_tx); + tokio::spawn( + events + .for_each(move |event| { + match event { + CommandEvent::Stdout(line_bytes) => { + let line = String::from_utf8_lossy(&line_bytes); + tracing::info!("{line}"); + } + CommandEvent::Stderr(line_bytes) => { + let line = String::from_utf8_lossy(&line_bytes); + tracing::info!("{line}"); } + CommandEvent::Error(err) => { + tracing::error!("{err}"); + } + CommandEvent::Terminated(payload) => { + tracing::info!( + code = ?payload.code, + signal = ?payload.signal, + "Sidecar terminated" + ); + + if let Some(tx) = exit_tx.take() { + let _ = tx.send(payload); + } + } + _ => {} } - _ => {} - } - } - }); + + future::ready(()) + }) + .instrument(tracing::info_span!("sidecar")), + ); (child, exit_rx) } + +pub mod sqlite_migration { + use super::*; + + #[derive( + tauri_specta::Event, serde::Serialize, serde::Deserialize, Clone, Copy, Debug, specta::Type, + )] + #[serde(tag = "type", content = "value")] + pub enum SqliteMigrationProgress { + InProgress(u8), + Done, + } + + pub(super) fn logs_middleware( + app: AppHandle, + stream: impl Stream, + ) -> impl Stream { + let app = app.clone(); + let mut done = false; + + stream.filter_map(move |event| { + if done { + return future::ready(Some(event)); + } + + future::ready(match &event { + CommandEvent::Stdout(stdout) => { + let Ok(s) = str::from_utf8(stdout) else { + return future::ready(None); + }; + + if let Some(s) = s.strip_prefix("sqlite-migration:").map(|s| s.trim()) { + if let Ok(progress) = s.parse::() { + let _ = SqliteMigrationProgress::InProgress(progress).emit(&app); + } else if s == "done" { + done = true; + let _ = SqliteMigrationProgress::Done.emit(&app); + } + + None + } else { + Some(event) + } + } + _ => Some(event), + }) + }) + } +} diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index e0187a76bc35..bec72c04fabc 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -24,16 +24,17 @@ use std::{ sync::{Arc, Mutex}, time::Duration, }; -use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel}; +use tauri::{AppHandle, Listener, Manager, RunEvent, State, ipc::Channel}; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_shell::process::CommandChild; +use tauri_specta::Event; use tokio::{ sync::{oneshot, watch}, time::{sleep, timeout}, }; -use crate::cli::sync_cli; +use crate::cli::{sqlite_migration::SqliteMigrationProgress, sync_cli}; use crate::constants::*; use crate::server::get_saved_server_url; use crate::windows::{LoadingWindow, MainWindow}; @@ -122,8 +123,8 @@ async fn await_initialization( let mut rx = init_state.current.clone(); let events = async { - let e = (*rx.borrow()).clone(); - let _ = events.send(e).unwrap(); + let e = *rx.borrow(); + let _ = events.send(e); while rx.changed().await.is_ok() { let step = *rx.borrow_and_update(); @@ -517,7 +518,10 @@ fn make_specta_builder() -> tauri_specta::Builder { wsl_path, resolve_app_path ]) - .events(tauri_specta::collect_events![LoadingWindowComplete]) + .events(tauri_specta::collect_events![ + LoadingWindowComplete, + SqliteMigrationProgress + ]) .error_handling(tauri_specta::ErrorHandlingMode::Throw) } @@ -556,17 +560,46 @@ async fn initialize(app: AppHandle) { tracing::info!("Main and loading windows created"); + // SQLite migration handling: + // We only do this if the sqlite db doesn't exist, and we're expecting the sidecar to create it + // First, we spawn a task that listens for SqliteMigrationProgress events that can + // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor. + // Then in the loading task, we wait for sqlite migration to complete before + // starting our health check against the server, otherwise long migrations could result in a timeout. let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some(); + let sqlite_done = (sqlite_enabled && !sqlite_file_exists()).then(|| { + tracing::info!( + path = %opencode_db_path().expect("failed to get db path").display(), + "Sqlite file not found, waiting for it to be generated" + ); + + let (done_tx, done_rx) = oneshot::channel::<()>(); + let done_tx = Arc::new(Mutex::new(Some(done_tx))); - let loading_task = tokio::spawn({ let init_tx = init_tx.clone(); + let id = SqliteMigrationProgress::listen(&app, move |e| { + let _ = init_tx.send(InitStep::SqliteWaiting); + + if matches!(e.payload, SqliteMigrationProgress::Done) + && let Some(done_tx) = done_tx.lock().unwrap().take() + { + let _ = done_tx.send(()); + } + }); + let app = app.clone(); + tokio::spawn(done_rx.map(async move |_| { + app.unlisten(id); + })) + }); - async move { - let mut sqlite_exists = sqlite_file_exists(); + let loading_task = tokio::spawn({ + let app = app.clone(); + async move { tracing::info!("Setting up server connection"); let server_connection = setup_server_connection(app.clone()).await; + tracing::info!("Server connection setup"); // we delay spawning this future so that the timeout is created lazily let cli_health_check = match server_connection { @@ -622,23 +655,12 @@ async fn initialize(app: AppHandle) { } }; + tracing::info!("server connection started"); + if let Some(cli_health_check) = cli_health_check { - if sqlite_enabled { - tracing::debug!(sqlite_exists, "Checking sqlite file existence"); - if !sqlite_exists { - tracing::info!( - path = %opencode_db_path().expect("failed to get db path").display(), - "Sqlite file not found, waiting for it to be generated" - ); - let _ = init_tx.send(InitStep::SqliteWaiting); - - while !sqlite_exists { - sleep(Duration::from_secs(1)).await; - sqlite_exists = sqlite_file_exists(); - } - } + if let Some(sqlite_done_rx) = sqlite_done { + let _ = sqlite_done_rx.await; } - tokio::spawn(cli_health_check); } @@ -654,11 +676,11 @@ async fn initialize(app: AppHandle) { .is_err() { tracing::debug!("Loading task timed out, showing loading window"); - let app = app.clone(); let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window"); sleep(Duration::from_secs(1)).await; Some(loading_window) } else { + tracing::debug!("Showing main window without loading window"); MainWindow::create(&app).expect("Failed to create main window"); None @@ -667,7 +689,6 @@ async fn initialize(app: AppHandle) { let _ = loading_task.await; tracing::info!("Loading done, completing initialisation"); - let _ = init_tx.send(InitStep::Done); if loading_window.is_some() { diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 81e0595af714..6dcf0e5860ae 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -11,17 +11,11 @@ use crate::{ constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY}, }; -#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug, Default)] pub struct WslConfig { pub enabled: bool, } -impl Default for WslConfig { - fn default() -> Self { - Self { enabled: false } - } -} - #[tauri::command] #[specta::specta] pub fn get_default_server_url(app: AppHandle) -> Result, String> { diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 3d588a17155f..67816ad414ec 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -23,6 +23,7 @@ export const commands = { /** Events */ export const events = { loadingWindowComplete: makeEvent("loading-window-complete"), + sqliteMigrationProgress: makeEvent("sqlite-migration-progress"), }; /* Types */ @@ -37,6 +38,8 @@ export type ServerReadyData = { password: string | null, }; +export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }; + export type WslConfig = { enabled: boolean, }; diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx index 752cde893b7c..a1d537a00eb3 100644 --- a/packages/desktop/src/loading.tsx +++ b/packages/desktop/src/loading.tsx @@ -4,7 +4,7 @@ import "@opencode-ai/app/index.css" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import "./styles.css" -import { createSignal, Match, onMount } from "solid-js" +import { createSignal, Match, onCleanup, onMount } from "solid-js" import { commands, events, InitStep } from "./bindings" import { Channel } from "@tauri-apps/api/core" import { Switch } from "solid-js" @@ -57,15 +57,29 @@ render(() => { "This could take a couple of minutes", ] const [textIndex, setTextIndex] = createSignal(0) + const [progress, setProgress] = createSignal(0) onMount(async () => { + const listener = events.sqliteMigrationProgress.listen((e) => { + if (e.payload.type === "InProgress") setProgress(e.payload.value) + }) + onCleanup(() => listener.then((c) => c())) + await new Promise((res) => setTimeout(res, 3000)) setTextIndex(1) await new Promise((res) => setTimeout(res, 6000)) setTextIndex(2) }) - return <>{textItems[textIndex()]} + return ( +
+ {textItems[textIndex()]} + Progress: {progress()}% +
+
+
+
+ ) }} From 0eaeb4588e0d44023a2e89c2ed516dbfe68c0e43 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 12 Feb 2026 18:46:56 +0800 Subject: [PATCH 002/437] Testing SignPath Integration (#13308) --- .github/workflows/sign-cli.yml | 54 +++++++++++++++++++++++++++++ .signpath/policies/test-signing.yml | 7 ++++ 2 files changed, 61 insertions(+) create mode 100644 .github/workflows/sign-cli.yml create mode 100644 .signpath/policies/test-signing.yml diff --git a/.github/workflows/sign-cli.yml b/.github/workflows/sign-cli.yml new file mode 100644 index 000000000000..d9d61fd800eb --- /dev/null +++ b/.github/workflows/sign-cli.yml @@ -0,0 +1,54 @@ +name: sign-cli + +on: + push: + branches: + - brendan/desktop-signpath + workflow_dispatch: + +permissions: + contents: read + actions: read + +jobs: + sign-cli: + runs-on: blacksmith-4vcpu-ubuntu-2404 + if: github.repository == 'anomalyco/opencode' + steps: + - uses: actions/checkout@v3 + with: + fetch-tags: true + + - uses: ./.github/actions/setup-bun + + - name: Build + run: | + ./packages/opencode/script/build.ts + + - name: Upload unsigned Windows CLI + id: upload_unsigned_windows_cli + uses: actions/upload-artifact@v4 + with: + name: unsigned-opencode-windows-cli + path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe + if-no-files-found: error + + - name: Submit SignPath signing request + id: submit_signpath_signing_request + uses: signpath/github-action-submit-signing-request@v1 + with: + api-token: ${{ secrets.SIGNPATH_API_KEY }} + organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }} + project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }} + signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }} + artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }} + github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }} + wait-for-completion: true + output-artifact-directory: signed-opencode-cli + + - name: Upload signed Windows CLI + uses: actions/upload-artifact@v4 + with: + name: signed-opencode-windows-cli + path: signed-opencode-cli/*.exe + if-no-files-found: error diff --git a/.signpath/policies/test-signing.yml b/.signpath/policies/test-signing.yml new file mode 100644 index 000000000000..4c9f654cd328 --- /dev/null +++ b/.signpath/policies/test-signing.yml @@ -0,0 +1,7 @@ +github-policies: + runners: + allowed_groups: + - "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt" + build: + disallow_reruns: false + branch_rulesets: From fa97475ee82eaca292a72baa01d7da0ef1695f1b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 12 Feb 2026 18:50:00 +0800 Subject: [PATCH 003/437] ci: move test-sigining policy --- .signpath/policies/{ => opencode}/test-signing.yml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .signpath/policies/{ => opencode}/test-signing.yml (100%) diff --git a/.signpath/policies/test-signing.yml b/.signpath/policies/opencode/test-signing.yml similarity index 100% rename from .signpath/policies/test-signing.yml rename to .signpath/policies/opencode/test-signing.yml From 5f421883a8aa92338bee1399532f359c5e986f41 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:16:30 -0600 Subject: [PATCH 004/437] chore: style loading screen --- packages/desktop/src/index.tsx | 13 +-- packages/desktop/src/loading.tsx | 136 +++++++++++++----------- packages/desktop/src/styles.css | 10 -- packages/ui/src/components/progress.css | 63 +++++++++++ packages/ui/src/components/progress.tsx | 39 +++++++ packages/ui/src/styles/index.css | 1 + packages/ui/src/styles/theme.css | 2 +- packages/ui/src/theme/themes/oc-1.json | 2 +- 8 files changed, 179 insertions(+), 87 deletions(-) create mode 100644 packages/ui/src/components/progress.css create mode 100644 packages/ui/src/components/progress.tsx diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index ca603da5f973..620914dd7e73 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,14 +1,7 @@ // @refresh reload import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" -import { - AppBaseProviders, - AppInterface, - PlatformProvider, - Platform, - DisplayBackend, - useCommand, -} from "@opencode-ai/app" +import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" @@ -29,7 +22,7 @@ import { UPDATER_ENABLED } from "./updater" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" -import { commands, InitStep, type WslConfig } from "./bindings" +import { commands, InitStep } from "./bindings" import { Channel } from "@tauri-apps/api/core" import { createMenu } from "./menu" @@ -487,11 +480,9 @@ type ServerReadyData = { url: string; password: string | null } // Gate component that waits for the server to be ready function ServerGate(props: { children: (data: Accessor) => JSX.Element }) { const [serverData] = createResource(() => commands.awaitInitialization(new Channel() as any)) - if (serverData.state === "errored") throw serverData.error return ( - // Not using suspense as not all components are compatible with it (undefined refs) { - let splash!: SVGSVGElement - const [state, setState] = createSignal(null) + const [step, setStep] = createSignal(null) + const [line, setLine] = createSignal(0) + const [percent, setPercent] = createSignal(0) + + const phase = createMemo(() => step()?.phase) + + const value = createMemo(() => { + if (phase() === "done") return 100 + return Math.max(25, Math.min(100, percent())) + }) const channel = new Channel() - channel.onmessage = (e) => setState(e) - commands.awaitInitialization(channel as any).then(() => { - const currentOpacity = getComputedStyle(splash).opacity - - splash.style.animation = "none" - splash.style.animationPlayState = "paused" - splash.style.opacity = currentOpacity - - requestAnimationFrame(() => { - splash.style.transition = "opacity 0.3s ease" - requestAnimationFrame(() => { - splash.style.opacity = "1" + channel.onmessage = (next) => setStep(next) + commands.awaitInitialization(channel as any).catch(() => undefined) + + createEffect(() => { + if (phase() !== "sqlite_waiting") return + + setLine(0) + setPercent(0) + + const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms)) + + let stop: (() => void) | undefined + let active = true + + void events.sqliteMigrationProgress + .listen((e) => { + if (e.payload.type === "InProgress") setPercent(Math.max(0, Math.min(100, e.payload.value))) + if (e.payload.type === "Done") setPercent(100) }) + .then((unlisten) => { + if (active) { + stop = unlisten + return + } + + unlisten() + }) + .catch(() => undefined) + + onCleanup(() => { + active = false + timers.forEach(clearTimeout) + stop?.() }) }) + createEffect(() => { + if (phase() !== "done") return + + const timer = setTimeout(() => events.loadingWindowComplete.emit(null), 1000) + onCleanup(() => clearTimeout(timer)) + }) + + const status = createMemo(() => { + if (phase() === "done") return "All done" + if (phase() === "sqlite_waiting") return lines[line()] + return "Just a moment..." + }) + return (
-
- - - - - {(_) => { - onMount(() => { - setTimeout(() => events.loadingWindowComplete.emit(null), 1000) - }) - - return "All done" - }} - - - {(_) => { - const textItems = [ - "Just a moment...", - "Migrating your database", - "This could take a couple of minutes", - ] - const [textIndex, setTextIndex] = createSignal(0) - const [progress, setProgress] = createSignal(0) - - onMount(async () => { - const listener = events.sqliteMigrationProgress.listen((e) => { - if (e.payload.type === "InProgress") setProgress(e.payload.value) - }) - onCleanup(() => listener.then((c) => c())) - - await new Promise((res) => setTimeout(res, 3000)) - setTextIndex(1) - await new Promise((res) => setTimeout(res, 6000)) - setTextIndex(2) - }) - - return ( -
- {textItems[textIndex()]} - Progress: {progress()}% -
-
-
-
- ) - }} - - - +
+ +
+ + {status()} + + `${Math.round(value)}%`} + /> +
diff --git a/packages/desktop/src/styles.css b/packages/desktop/src/styles.css index 941fb95d747f..143a21312e12 100644 --- a/packages/desktop/src/styles.css +++ b/packages/desktop/src/styles.css @@ -5,13 +5,3 @@ button#decorum-tb-close, div[data-tauri-decorum-tb] { height: calc(var(--spacing) * 10) !important; } - -@keyframes pulse-splash { - 0%, - 100% { - opacity: 0.1; - } - 50% { - opacity: 0.3; - } -} diff --git a/packages/ui/src/components/progress.css b/packages/ui/src/components/progress.css new file mode 100644 index 000000000000..c728912f76db --- /dev/null +++ b/packages/ui/src/components/progress.css @@ -0,0 +1,63 @@ +[data-component="progress"] { + display: flex; + flex-direction: column; + gap: 4px; + + [data-slot="progress-header"] { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + } + + [data-slot="progress-label"], + [data-slot="progress-value-label"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="progress-label"] { + color: var(--text-base); + } + + [data-slot="progress-value-label"] { + color: var(--text-weak); + font-variant-numeric: tabular-nums; + } + + [data-slot="progress-track"] { + position: relative; + width: 100%; + height: 8px; + overflow: hidden; + border-radius: 999px; + border: 1px solid var(--border-weak-base); + background-color: var(--surface-base); + } + + [data-slot="progress-fill"] { + height: 100%; + width: var(--kb-progress-fill-width); + border-radius: inherit; + background-color: var(--border-active); + transition: width 200ms ease; + } + + &[data-indeterminate] [data-slot="progress-fill"] { + width: 35%; + animation: progress-indeterminate 1.3s ease-in-out infinite; + } +} + +@keyframes progress-indeterminate { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(300%); + } +} diff --git a/packages/ui/src/components/progress.tsx b/packages/ui/src/components/progress.tsx new file mode 100644 index 000000000000..bfe10a1d1ec3 --- /dev/null +++ b/packages/ui/src/components/progress.tsx @@ -0,0 +1,39 @@ +import { Progress as Kobalte } from "@kobalte/core/progress" +import { Show, splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface ProgressProps extends ParentProps> { + hideLabel?: boolean + showValueLabel?: boolean +} + +export function Progress(props: ProgressProps) { + const [local, others] = splitProps(props, ["children", "class", "classList", "hideLabel", "showValueLabel"]) + + return ( + + +
+ + + {local.children} + + + + + +
+
+ + + +
+ ) +} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index c85df7ba34fd..167eb64c80d0 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -36,6 +36,7 @@ @import "../components/message-part.css" layer(components); @import "../components/message-nav.css" layer(components); @import "../components/popover.css" layer(components); +@import "../components/progress.css" layer(components); @import "../components/progress-circle.css" layer(components); @import "../components/radio-group.css" layer(components); @import "../components/resize-handle.css" layer(components); diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css index 951450d540a3..7ecac53fe3c8 100644 --- a/packages/ui/src/styles/theme.css +++ b/packages/ui/src/styles/theme.css @@ -510,7 +510,7 @@ --icon-success-base: var(--apple-dark-7); --icon-success-hover: var(--apple-dark-8); --icon-success-active: var(--apple-dark-11); - --icon-warning-base: var(--amber-dark-7); + --icon-warning-base: var(--amber-dark-9); --icon-warning-hover: var(--amber-dark-8); --icon-warning-active: var(--amber-dark-11); --icon-critical-base: var(--ember-dark-9); diff --git a/packages/ui/src/theme/themes/oc-1.json b/packages/ui/src/theme/themes/oc-1.json index 7dfad9ec38a5..54a2bf674189 100644 --- a/packages/ui/src/theme/themes/oc-1.json +++ b/packages/ui/src/theme/themes/oc-1.json @@ -444,7 +444,7 @@ "icon-success-base": "var(--apple-dark-9)", "icon-success-hover": "var(--apple-dark-10)", "icon-success-active": "var(--apple-dark-11)", - "icon-warning-base": "var(--amber-dark-7)", + "icon-warning-base": "var(--amber-dark-9)", "icon-warning-hover": "var(--amber-dark-8)", "icon-warning-active": "var(--amber-dark-11)", "icon-critical-base": "var(--ember-dark-9)", From ecb274273a04920c215625b4bf93845d166411e2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:25:58 -0600 Subject: [PATCH 005/437] wip(ui): diff virtualization (#12693) --- bun.lock | 30 ++----- package.json | 2 +- packages/app/src/pages/session/review-tab.tsx | 2 +- packages/ui/src/components/code.tsx | 15 +++- packages/ui/src/components/diff-ssr.tsx | 86 +++++++++++++------ packages/ui/src/components/diff.tsx | 62 ++++++++----- packages/ui/src/context/marked.tsx | 2 +- packages/ui/src/pierre/index.ts | 70 +++++---------- packages/ui/src/pierre/virtualizer.ts | 76 ++++++++++++++++ packages/ui/src/pierre/worker.ts | 1 + 10 files changed, 220 insertions(+), 126 deletions(-) create mode 100644 packages/ui/src/pierre/virtualizer.ts diff --git a/bun.lock b/bun.lock index 6f399805b828..0131a8e0c09b 100644 --- a/bun.lock +++ b/bun.lock @@ -513,7 +513,7 @@ "@kobalte/core": "0.13.11", "@octokit/rest": "22.0.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/diffs": "1.0.2", + "@pierre/diffs": "1.1.0-beta.13", "@playwright/test": "1.51.0", "@solid-primitives/storage": "4.3.3", "@solidjs/meta": "0.29.4", @@ -1409,7 +1409,7 @@ "@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="], - "@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0-beta.13", "", { "dependencies": { "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-D35rxDu5V7XHX5aVGU6PF12GhscL+I+9QYgxK/i3h0d2XSirAxDdVNm49aYwlOhgmdvL0NbS1IHxPswVB5yJvw=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], @@ -4387,13 +4387,9 @@ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="], - "@pierre/diffs/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], + "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], - "@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="], - - "@pierre/diffs/@shikijs/transformers": ["@shikijs/transformers@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="], - - "@pierre/diffs/shiki": ["shiki@3.19.0", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="], + "@pierre/diffs/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -4973,23 +4969,9 @@ "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], - - "@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="], - - "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="], - - "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="], - - "@pierre/diffs/shiki/@shikijs/core": ["@shikijs/core@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="], - - "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="], - - "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="], - - "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.19.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="], + "@pierre/diffs/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], - "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/types@3.19.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="], + "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], diff --git a/package.json b/package.json index ae790e0a5e15..c396905d458f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@tsconfig/bun": "1.0.9", "@cloudflare/workers-types": "4.20251008.0", "@openauthjs/openauth": "0.0.0-20250322224806", - "@pierre/diffs": "1.0.2", + "@pierre/diffs": "1.1.0-beta.13", "@solid-primitives/storage": "4.3.3", "@tailwindcss/vite": "4.1.11", "diff": "8.0.2", diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index a4232dd74e39..72518c68e407 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -139,7 +139,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) { open={props.view().review.open()} onOpenChange={props.view().review.setOpen} classes={{ - root: props.classes?.root ?? "pb-40", + root: props.classes?.root ?? "pb-6", header: props.classes?.header ?? "px-6", container: props.classes?.container ?? "px-6", }} diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 4e7c82d78837..2fe0e035279d 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -318,7 +318,7 @@ export function Code(props: CodeProps) { const needle = query.toLowerCase() const out: Range[] = [] - const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter( + const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) @@ -537,17 +537,28 @@ export function Code(props: CodeProps) { node.removeAttribute("data-comment-selected") } + const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + for (const range of ranges) { const start = Math.max(1, Math.min(range.start, range.end)) const end = Math.max(range.start, range.end) for (let line = start; line <= end; line++) { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"]`)) + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`)) for (const node of nodes) { if (!(node instanceof HTMLElement)) continue node.setAttribute("data-comment-selected", "") } } + + for (const annotation of annotations) { + const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) + if (Number.isNaN(line)) continue + if (line < start || line > end) continue + annotation.setAttribute("data-comment-selected", "") + } } } diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index 602e59a2f57b..e739afc16d83 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -1,8 +1,9 @@ -import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange } from "@pierre/diffs" +import { DIFFS_TAG_NAME, FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { Dynamic, isServer } from "solid-js/web" import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre" +import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { useWorkerPool } from "../context/worker-pool" export type SSRDiffProps = DiffProps & { @@ -24,10 +25,21 @@ export function Diff(props: SSRDiffProps) { const workerPool = useWorkerPool(props.diffStyle) let fileDiffInstance: FileDiff | undefined + let sharedVirtualizer: NonNullable> | undefined const cleanupFunctions: Array<() => void> = [] const getRoot = () => fileDiffRef?.shadowRoot ?? undefined + const getVirtualizer = () => { + if (sharedVirtualizer) return sharedVirtualizer.virtualizer + + const result = acquireVirtualizer(container) + if (!result) return + + sharedVirtualizer = result + return result.virtualizer + } + const applyScheme = () => { const scheme = document.documentElement.dataset.colorScheme if (scheme === "dark" || scheme === "light") { @@ -70,10 +82,10 @@ export function Diff(props: SSRDiffProps) { const root = getRoot() if (!root) return - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" const start = rowIndex(root, split, range.start, range.side) const end = rowIndex(root, split, range.end, range.endSide ?? range.side) @@ -132,15 +144,19 @@ export function Diff(props: SSRDiffProps) { node.removeAttribute("data-comment-selected") } - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" + + const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (rows.length === 0) return - const code = Array.from(diffs.querySelectorAll("[data-code]")).filter( + const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) - if (code.length === 0) return const lineIndex = (element: HTMLElement) => { const raw = element.dataset.lineIndex @@ -183,19 +199,18 @@ export function Diff(props: SSRDiffProps) { const first = Math.min(start, end) const last = Math.max(start, end) - for (const block of code) { - for (const element of Array.from(block.children)) { - if (!(element instanceof HTMLElement)) continue - const idx = lineIndex(element) - if (idx === undefined) continue - if (idx > last) break - if (idx < first) continue - element.setAttribute("data-comment-selected", "") - const next = element.nextSibling - if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) { - next.setAttribute("data-comment-selected", "") - } - } + for (const row of rows) { + const idx = lineIndex(row) + if (idx === undefined) continue + if (idx < first || idx > last) continue + row.setAttribute("data-comment-selected", "") + } + + for (const annotation of annotations) { + const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) + if (Number.isNaN(idx)) continue + if (idx < first || idx > last) continue + annotation.setAttribute("data-comment-selected", "") } } } @@ -212,14 +227,27 @@ export function Diff(props: SSRDiffProps) { onCleanup(() => monitor.disconnect()) } - fileDiffInstance = new FileDiff( - { - ...createDefaultOptions(props.diffStyle), - ...others, - ...props.preloadedDiff, - }, - workerPool, - ) + const virtualizer = getVirtualizer() + + fileDiffInstance = virtualizer + ? new VirtualizedFileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...props.preloadedDiff, + }, + virtualizer, + virtualMetrics, + workerPool, + ) + : new FileDiff( + { + ...createDefaultOptions(props.diffStyle), + ...others, + ...props.preloadedDiff, + }, + workerPool, + ) // @ts-expect-error - fileContainer is private but needed for SSR hydration fileDiffInstance.fileContainer = fileDiffRef fileDiffInstance.hydrate({ @@ -273,6 +301,8 @@ export function Diff(props: SSRDiffProps) { // Clean up FileDiff event handlers and dispose SolidJS components fileDiffInstance?.cleanUp() cleanupFunctions.forEach((dispose) => dispose()) + sharedVirtualizer?.release() + sharedVirtualizer = undefined }) return ( diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 21dada535034..0966db75e036 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -1,8 +1,9 @@ import { checksum } from "@opencode-ai/util/encode" -import { FileDiff, type SelectedLineRange } from "@pierre/diffs" +import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" +import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer" import { getWorkerPool } from "../pierre/worker" type SelectionSide = "additions" | "deletions" @@ -52,6 +53,7 @@ function findSide(node: Node | null): SelectionSide | undefined { export function Diff(props: DiffProps) { let container!: HTMLDivElement let observer: MutationObserver | undefined + let sharedVirtualizer: NonNullable> | undefined let renderToken = 0 let selectionFrame: number | undefined let dragFrame: number | undefined @@ -92,6 +94,16 @@ export function Diff(props: DiffProps) { const [current, setCurrent] = createSignal | undefined>(undefined) const [rendered, setRendered] = createSignal(0) + const getVirtualizer = () => { + if (sharedVirtualizer) return sharedVirtualizer.virtualizer + + const result = acquireVirtualizer(container) + if (!result) return + + sharedVirtualizer = result + return result.virtualizer + } + const getRoot = () => { const host = container.querySelector("diffs-container") if (!(host instanceof HTMLElement)) return @@ -147,10 +159,10 @@ export function Diff(props: DiffProps) { const root = getRoot() if (!root) return - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" const start = rowIndex(root, split, range.start, range.side) const end = rowIndex(root, split, range.end, range.endSide ?? range.side) @@ -261,15 +273,19 @@ export function Diff(props: DiffProps) { node.removeAttribute("data-comment-selected") } - const diffs = root.querySelector("[data-diffs]") + const diffs = root.querySelector("[data-diff]") if (!(diffs instanceof HTMLElement)) return - const split = diffs.dataset.type === "split" + const split = diffs.dataset.diffType === "split" + + const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (rows.length === 0) return - const code = Array.from(diffs.querySelectorAll("[data-code]")).filter( + const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter( (node): node is HTMLElement => node instanceof HTMLElement, ) - if (code.length === 0) return for (const range of ranges) { const start = rowIndex(root, split, range.start, range.side) @@ -285,19 +301,18 @@ export function Diff(props: DiffProps) { const first = Math.min(start, end) const last = Math.max(start, end) - for (const block of code) { - for (const element of Array.from(block.children)) { - if (!(element instanceof HTMLElement)) continue - const idx = lineIndex(split, element) - if (idx === undefined) continue - if (idx > last) break - if (idx < first) continue - element.setAttribute("data-comment-selected", "") - const next = element.nextSibling - if (next instanceof HTMLElement && next.hasAttribute("data-line-annotation")) { - next.setAttribute("data-comment-selected", "") - } - } + for (const row of rows) { + const idx = lineIndex(split, row) + if (idx === undefined) continue + if (idx < first || idx > last) continue + row.setAttribute("data-comment-selected", "") + } + + for (const annotation of annotations) { + const idx = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10) + if (Number.isNaN(idx)) continue + if (idx < first || idx > last) continue + annotation.setAttribute("data-comment-selected", "") } } } @@ -514,12 +529,15 @@ export function Diff(props: DiffProps) { createEffect(() => { const opts = options() const workerPool = getWorkerPool(props.diffStyle) + const virtualizer = getVirtualizer() const annotations = local.annotations const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" instance?.cleanUp() - instance = new FileDiff(opts, workerPool) + instance = virtualizer + ? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool) + : new FileDiff(opts, workerPool) setCurrent(instance) container.innerHTML = "" @@ -606,6 +624,8 @@ export function Diff(props: DiffProps) { instance?.cleanUp() setCurrent(undefined) + sharedVirtualizer?.release() + sharedVirtualizer = undefined }) return
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 0c6d58b93532..c5ff3c767f6a 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -10,7 +10,7 @@ registerCustomTheme("OpenCode", () => { return Promise.resolve({ name: "OpenCode", colors: { - "editor.background": "transparent", + "editor.background": "var(--color-background-stronger)", "editor.foreground": "var(--text-base)", "gitDecoration.addedResourceForeground": "var(--syntax-diff-add)", "gitDecoration.deletedResourceForeground": "var(--syntax-diff-delete)", diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index f6446f3cc859..dc9d857bf871 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -13,7 +13,7 @@ export type DiffProps = FileDiffOptions & { } const unsafeCSS = ` -[data-diffs] { +[data-diff] { --diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg)); --diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)))); --diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer)))); @@ -44,7 +44,7 @@ const unsafeCSS = ` --diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2); } -:host([data-color-scheme='dark']) [data-diffs] { +:host([data-color-scheme='dark']) [data-diff] { --diffs-selection-number-fg: #fdfbfb; --diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65)); --diffs-bg-selection-number: var( @@ -53,7 +53,7 @@ const unsafeCSS = ` ); } -[data-diffs] ::selection { +[data-diff] ::selection { background-color: var(--diffs-bg-selection-text); } @@ -65,61 +65,48 @@ const unsafeCSS = ` background-color: rgb(from var(--surface-warning-strong) r g b / 0.55); } -[data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-content] { +[data-diff] [data-line][data-comment-selected]:not([data-selected-line]) { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); } -[data-diffs] [data-comment-selected]:not([data-selected-line]) [data-column-number] { +[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number); color: var(--diffs-selection-number-fg); } -[data-diffs] [data-selected-line] { +[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] { + box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); +} + +[data-diff] [data-line][data-selected-line] { background-color: var(--diffs-bg-selection); box-shadow: inset 2px 0 0 var(--diffs-selection-border); } -[data-diffs] [data-selected-line] [data-column-number] { +[data-diff] [data-column-number][data-selected-line] { background-color: var(--diffs-bg-selection-number); color: var(--diffs-selection-number-fg); } -[data-diffs] [data-line-type='context'][data-selected-line] [data-column-number], -[data-diffs] [data-line-type='context-expanded'][data-selected-line] [data-column-number], -[data-diffs] [data-line-type='change-addition'][data-selected-line] [data-column-number], -[data-diffs] [data-line-type='change-deletion'][data-selected-line] [data-column-number] { +[data-diff] [data-column-number][data-line-type='context'][data-selected-line], +[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line], +[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line], +[data-diff] [data-column-number][data-line-type='change-deletion'][data-selected-line] { color: var(--diffs-selection-number-fg); } /* The deletion word-diff emphasis is stronger than additions; soften it while selected so the selection highlight reads consistently. */ -[data-diffs] [data-line-type='change-deletion'][data-selected-line] { +[data-diff] [data-line][data-line-type='change-deletion'][data-selected-line] { --diffs-bg-deletion-emphasis: light-dark( rgb(from var(--diffs-deletion-base) r g b / 0.07), rgb(from var(--diffs-deletion-base) r g b / 0.1) ); } -[data-diffs-header], -[data-diffs] { - [data-separator-wrapper] { - margin: 0 !important; - border-radius: 0 !important; - } - [data-expand-button] { - width: 6.5ch !important; - height: 24px !important; - justify-content: end !important; - padding-left: 3ch !important; - padding-inline: 1ch !important; - } - [data-separator-multi-button] { - grid-template-rows: 10px 10px !important; - [data-expand-button] { - height: 12px !important; - } - } - [data-separator-content] { - height: 24px !important; +[data-diff-header], +[data-diff] { + [data-separator] { + height: 24px; } [data-column-number] { background-color: var(--background-stronger); @@ -146,28 +133,15 @@ export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) overflow: "wrap", diffStyle: style ?? "unified", diffIndicators: "bars", + lineHoverHighlight: "both", disableBackground: false, expansionLineCount: 20, + hunkSeparators: "line-info-basic", lineDiffType: style === "split" ? "word-alt" : "none", maxLineDiffLength: 1000, maxLineLengthForHighlighting: 1000, disableFileHeader: true, unsafeCSS, - // hunkSeparators(hunkData: HunkData) { - // const fragment = document.createDocumentFragment() - // const numCol = document.createElement("div") - // numCol.innerHTML = ` ` - // numCol.dataset["slot"] = "diff-hunk-separator-line-number" - // fragment.appendChild(numCol) - // const contentCol = document.createElement("div") - // contentCol.dataset["slot"] = "diff-hunk-separator-content" - // const span = document.createElement("span") - // span.dataset["slot"] = "diff-hunk-separator-content-span" - // span.textContent = `${hunkData.lines} unmodified lines` - // contentCol.appendChild(span) - // fragment.appendChild(contentCol) - // return fragment - // }, } as const } diff --git a/packages/ui/src/pierre/virtualizer.ts b/packages/ui/src/pierre/virtualizer.ts new file mode 100644 index 000000000000..4957afc12552 --- /dev/null +++ b/packages/ui/src/pierre/virtualizer.ts @@ -0,0 +1,76 @@ +import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs" + +type Target = { + key: Document | HTMLElement + root: Document | HTMLElement + content: HTMLElement | undefined +} + +type Entry = { + virtualizer: Virtualizer + refs: number +} + +const cache = new WeakMap() + +export const virtualMetrics: Partial = { + lineHeight: 24, + hunkSeparatorHeight: 24, + fileGap: 0, +} + +function target(container: HTMLElement): Target | undefined { + if (typeof document === "undefined") return + + const root = container.closest("[data-component='session-review']") + if (root instanceof HTMLElement) { + const content = root.querySelector("[data-slot='session-review-container']") + return { + key: root, + root, + content: content instanceof HTMLElement ? content : undefined, + } + } + + return { + key: document, + root: document, + content: undefined, + } +} + +export function acquireVirtualizer(container: HTMLElement) { + const resolved = target(container) + if (!resolved) return + + let entry = cache.get(resolved.key) + if (!entry) { + const virtualizer = new Virtualizer() + virtualizer.setup(resolved.root, resolved.content) + entry = { + virtualizer, + refs: 0, + } + cache.set(resolved.key, entry) + } + + entry.refs += 1 + let done = false + + return { + virtualizer: entry.virtualizer, + release() { + if (done) return + done = true + + const current = cache.get(resolved.key) + if (!current) return + + current.refs -= 1 + if (current.refs > 0) return + + current.virtualizer.cleanUp() + cache.delete(resolved.key) + }, + } +} diff --git a/packages/ui/src/pierre/worker.ts b/packages/ui/src/pierre/worker.ts index 0d117c3683ff..1993ad7aa6fc 100644 --- a/packages/ui/src/pierre/worker.ts +++ b/packages/ui/src/pierre/worker.ts @@ -21,6 +21,7 @@ function createPool(lineDiffType: "none" | "word-alt") { { theme: "OpenCode", lineDiffType, + preferredHighlighter: "shiki-wasm", }, ) From 9f9f0fb8eb10ab4e90a6f38c222eb40116becb50 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 12 Feb 2026 13:36:53 +0000 Subject: [PATCH 006/437] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index a14d2afaf343..70d7378493dd 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-pp2gb4nxiIT3VltB6Xli2wZPH32JfnMsI+BbihyU1+E=", - "aarch64-linux": "sha256-hJwxhBICZz/pbIxQsF/sIpZTlFIgLpcAyF44O8wxMdU=", - "aarch64-darwin": "sha256-DPONXP52XOg/ApdSnLp32a+K5XCOnDGhbTUto2Rme0g=", - "x86_64-darwin": "sha256-KX1h5LRJSgthpbOPqWlbM/sPf8cvQrdRJvxtrz/FzBQ=" + "x86_64-linux": "sha256-XIf7b6yALzH1/MkGGrsmq2DeXIC9vgD9a7D/dxhi6iU=", + "aarch64-linux": "sha256-mKDCs6QhIelWc3E17zOufaSDTovtjO/Xyh3JtlWl01s=", + "aarch64-darwin": "sha256-wC7bbbIyZ62uMxTr9FElTbEBMrfz0S/ndqwZZ3V9EOA=", + "x86_64-darwin": "sha256-/7Nn65m5Zhvzz0TKsG9nWd2v5WDHQNi3UzCfuAR8SLo=" } } From d723147083ef972e82de5e33765874e35be64079 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Thu, 12 Feb 2026 09:13:38 -0500 Subject: [PATCH 007/437] feat: update to not post comment on workflows when no duplicates found (#13238) --- .github/workflows/pr-management.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml index 25bea2f24fa9..008272415492 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/pr-management.yml @@ -60,9 +60,11 @@ jobs: run: | COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates") - gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_ + if [ "$COMMENT" != "No duplicate PRs found" ]; then + gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_ $COMMENT" + fi add-contributor-label: runs-on: ubuntu-latest From d82d22b2d760e85a4e9a84ff7a69e43420553e20 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 12 Feb 2026 09:17:47 -0500 Subject: [PATCH 008/437] wip: zen --- packages/console/app/src/routes/zen/util/handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 37708944d681..246c61638086 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -390,7 +390,8 @@ export async function handler( } if (retry.retryCount === MAX_FAILOVER_RETRIES) { - return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider) + const provider = modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider) + if (provider) return provider } const providers = modelInfo.providers From a115565054c9c905788b1684f9b64f0e6dc2dbb4 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 12 Feb 2026 09:26:28 -0500 Subject: [PATCH 009/437] core: allow model configurations without npm/api provider details Makes npm and api fields optional in the provider schema so model definitions can be more flexible when provider package details aren't needed. --- packages/opencode/src/provider/models.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/models.ts b/packages/opencode/src/provider/models.ts index 4725d0d39821..8ce543d374ab 100644 --- a/packages/opencode/src/provider/models.ts +++ b/packages/opencode/src/provider/models.ts @@ -64,7 +64,7 @@ export namespace ModelsDev { status: z.enum(["alpha", "beta", "deprecated"]).optional(), options: z.record(z.string(), z.any()), headers: z.record(z.string(), z.string()).optional(), - provider: z.object({ npm: z.string(), api: z.string() }).optional(), + provider: z.object({ npm: z.string().optional(), api: z.string().optional() }).optional(), variants: z.record(z.string(), z.record(z.string(), z.any())).optional(), }) export type Model = z.infer From 892bb75265602cd3dbcbe1cfc634f1d7f4ca7f5e Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 12 Feb 2026 14:45:45 +0000 Subject: [PATCH 010/437] release: v1.1.61 --- bun.lock | 30 ++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/sdk/js/src/v2/gen/types.gen.ts | 8 +++---- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 19 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 0131a8e0c09b..f98c9b27eb35 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -215,7 +215,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -244,7 +244,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -260,7 +260,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.60", + "version": "1.1.61", "bin": { "opencode": "./bin/opencode", }, @@ -366,7 +366,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -386,7 +386,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.60", + "version": "1.1.61", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -397,7 +397,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -410,7 +410,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -452,7 +452,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "zod": "catalog:", }, @@ -463,7 +463,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index f6614f25a6fd..2f45aac2e8e7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.60", + "version": "1.1.61", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 3b5520fe0209..f0486d561658 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.60", + "version": "1.1.61", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 61c80347f988..c1a1d6d1ca99 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.60", + "version": "1.1.61", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 56fbafd12f07..dca442d23a34 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.60", + "version": "1.1.61", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 7d6dd5833dba..ebdffdd2a777 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.60", + "version": "1.1.61", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d897e1f9e780..e4678dc008d0 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.60", + "version": "1.1.61", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index a8a71e8f8a97..b5d833de8402 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.60", + "version": "1.1.61", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 2cc381067c80..c42120c7de18 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.60" +version = "1.1.61" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.60/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.61/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index 310787448667..7d2a42bc7b5f 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.60", + "version": "1.1.61", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 671c12e674ec..41c5f7ce1f53 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.60", + "version": "1.1.61", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fa348d5a402a..ebfa1ba0da94 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.60", + "version": "1.1.61", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a979b5a9b755..9a082d613459 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.60", + "version": "1.1.61", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 26a3bd20e66f..b22b7e9af4e1 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1548,8 +1548,8 @@ export type ProviderConfig = { [key: string]: string } provider?: { - npm: string - api: string + npm?: string + api?: string } /** * Variant-specific configuration @@ -4068,8 +4068,8 @@ export type ProviderListResponses = { [key: string]: string } provider?: { - npm: string - api: string + npm?: string + api?: string } variants?: { [key: string]: { diff --git a/packages/slack/package.json b/packages/slack/package.json index 3005f13f3368..fdb52e3db1e8 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.60", + "version": "1.1.61", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 4eb6a03d29af..121776fb9234 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.60", + "version": "1.1.61", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index a0c5bc974c43..5fafd7584780 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.60", + "version": "1.1.61", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 03766a71f4fd..d9fa64ade47b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.60", + "version": "1.1.61", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 20508e209e5e..29a600939817 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.60", + "version": "1.1.61", "publisher": "sst-dev", "repository": { "type": "git", From 85df1067130ef17e819900e303caec30ab012384 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 12 Feb 2026 14:28:17 +0000 Subject: [PATCH 011/437] chore: generate --- packages/sdk/openapi.json | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5b79514ab97c..70596431bb62 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3800,8 +3800,7 @@ "api": { "type": "string" } - }, - "required": ["npm", "api"] + } }, "variants": { "type": "object", @@ -9405,8 +9404,7 @@ "api": { "type": "string" } - }, - "required": ["npm", "api"] + } }, "variants": { "description": "Variant-specific configuration", From ae811ad8d249c5d37622c26f2078eb0bef40087b Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 12 Feb 2026 09:28:51 -0500 Subject: [PATCH 012/437] wip: zen --- .../app/src/routes/zen/util/handler.ts | 31 ++++++++++--------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 246c61638086..d2bcaa851b2d 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -389,24 +389,25 @@ export async function handler( if (provider) return provider } - if (retry.retryCount === MAX_FAILOVER_RETRIES) { - const provider = modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider) + if (retry.retryCount !== MAX_FAILOVER_RETRIES) { + const providers = modelInfo.providers + .filter((provider) => !provider.disabled) + .filter((provider) => !retry.excludeProviders.includes(provider.id)) + .flatMap((provider) => Array(provider.weight ?? 1).fill(provider)) + + // Use the last 4 characters of session ID to select a provider + let h = 0 + const l = sessionId.length + for (let i = l - 4; i < l; i++) { + h = (h * 31 + sessionId.charCodeAt(i)) | 0 // 32-bit int + } + const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 + const provider = providers[index || 0] if (provider) return provider } - const providers = modelInfo.providers - .filter((provider) => !provider.disabled) - .filter((provider) => !retry.excludeProviders.includes(provider.id)) - .flatMap((provider) => Array(provider.weight ?? 1).fill(provider)) - - // Use the last 4 characters of session ID to select a provider - let h = 0 - const l = sessionId.length - for (let i = l - 4; i < l; i++) { - h = (h * 31 + sessionId.charCodeAt(i)) | 0 // 32-bit int - } - const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1 - return providers[index || 0] + // fallback provider + return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider) })() if (!modelProvider) throw new ModelError("No provider available") From 56ad2db02055955f926fda0e4a89055b22ead6f9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Thu, 12 Feb 2026 09:54:47 -0500 Subject: [PATCH 013/437] core: expose tool arguments in shell hook for plugin visibility --- packages/opencode/src/session/prompt.ts | 3 +++ packages/plugin/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 1d3d74509454..99d44cd850f7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -454,6 +454,7 @@ export namespace SessionPrompt { tool: "task", sessionID, callID: part.id, + args: taskArgs, }, result, ) @@ -805,6 +806,7 @@ export namespace SessionPrompt { tool: item.id, sessionID: ctx.sessionID, callID: ctx.callID, + args, }, result, ) @@ -850,6 +852,7 @@ export namespace SessionPrompt { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId, + args, }, result, ) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 4cc84a5f3255..664f2c967315 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -187,7 +187,7 @@ export interface Hooks { ) => Promise "shell.env"?: (input: { cwd: string }, output: { env: Record }) => Promise "tool.execute.after"?: ( - input: { tool: string; sessionID: string; callID: string }, + input: { tool: string; sessionID: string; callID: string; args: any }, output: { title: string output: string From ff4414bb152acfddb5c0eb073c38bedc1df4ae14 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:49:14 -0600 Subject: [PATCH 014/437] chore: refactor packages/app files (#13236) Co-authored-by: opencode-agent[bot] Co-authored-by: Frank --- packages/app/e2e/files/file-open.spec.ts | 19 +- packages/app/e2e/files/file-viewer.spec.ts | 39 +- .../projects/workspace-new-session.spec.ts | 10 +- packages/app/e2e/projects/workspaces.spec.ts | 86 ++- packages/app/e2e/prompt/context.spec.ts | 101 ++- packages/app/e2e/prompt/prompt.spec.ts | 3 - .../app/e2e/session/session-undo-redo.spec.ts | 32 +- packages/app/e2e/session/session.spec.ts | 45 +- packages/app/src/app.tsx | 173 ++--- .../components/dialog-connect-provider.tsx | 594 +++++++++--------- .../src/components/dialog-custom-provider.tsx | 328 +++++----- .../src/components/dialog-edit-project.tsx | 67 +- packages/app/src/components/dialog-fork.tsx | 25 +- .../src/components/dialog-manage-models.tsx | 27 +- .../src/components/dialog-release-notes.tsx | 20 +- .../components/dialog-select-directory.tsx | 277 ++++---- .../app/src/components/dialog-select-file.tsx | 330 ++++++---- .../app/src/components/dialog-select-mcp.tsx | 45 +- .../components/dialog-select-model-unpaid.tsx | 13 +- .../src/components/dialog-select-model.tsx | 70 +-- .../src/components/dialog-select-provider.tsx | 18 +- .../src/components/dialog-select-server.tsx | 361 +++++------ .../app/src/components/dialog-settings.tsx | 9 - packages/app/src/components/file-tree.tsx | 386 +++++++----- packages/app/src/components/link.tsx | 17 +- packages/app/src/components/prompt-input.tsx | 111 ++-- .../components/prompt-input/context-items.tsx | 109 ++-- .../components/prompt-input/drag-overlay.tsx | 7 +- .../prompt-input/image-attachments.tsx | 15 +- .../components/prompt-input/slash-popover.tsx | 79 ++- packages/app/src/components/question-dock.tsx | 86 +-- .../app/src/components/server/server-row.tsx | 22 +- .../src/components/session-context-usage.tsx | 22 +- .../session/session-context-breakdown.test.ts | 61 ++ .../session/session-context-breakdown.ts | 132 ++++ .../session/session-context-format.ts | 20 + .../session/session-context-tab.tsx | 288 +++------ .../src/components/session/session-header.tsx | 348 +++++----- .../components/session/session-new-view.tsx | 4 +- .../session/session-sortable-tab.tsx | 8 +- .../session/session-sortable-terminal-tab.tsx | 31 +- .../app/src/components/settings-agents.tsx | 1 + .../app/src/components/settings-commands.tsx | 1 + .../app/src/components/settings-general.tsx | 530 ++++++++-------- .../app/src/components/settings-keybinds.tsx | 303 ++++----- packages/app/src/components/settings-mcp.tsx | 1 + .../app/src/components/settings-models.tsx | 35 +- .../src/components/settings-permissions.tsx | 8 +- .../app/src/components/settings-providers.tsx | 64 +- .../app/src/components/status-popover.tsx | 267 ++++---- packages/app/src/components/terminal.tsx | 192 +++--- packages/app/src/components/titlebar.tsx | 48 +- packages/app/src/context/command.tsx | 42 +- packages/app/src/context/comments.test.ts | 41 ++ packages/app/src/context/comments.tsx | 58 +- packages/app/src/context/file.tsx | 101 +-- packages/app/src/context/global-sdk.tsx | 22 +- packages/app/src/context/global-sync.tsx | 70 ++- packages/app/src/context/highlights.tsx | 79 +-- packages/app/src/context/language.tsx | 147 ++--- packages/app/src/context/layout.tsx | 105 ++-- packages/app/src/context/local.tsx | 72 +-- packages/app/src/context/models.tsx | 39 +- packages/app/src/context/notification.tsx | 142 +++-- packages/app/src/context/permission.tsx | 16 +- packages/app/src/context/platform.tsx | 14 +- packages/app/src/context/prompt.tsx | 109 ++-- packages/app/src/context/sdk.tsx | 8 +- packages/app/src/context/server.tsx | 131 ++-- packages/app/src/context/settings.tsx | 35 +- packages/app/src/context/sync.tsx | 169 ++--- packages/app/src/context/terminal.tsx | 67 +- packages/app/src/entry.tsx | 190 +++--- packages/app/src/env.d.ts | 10 + packages/app/src/pages/directory-layout.tsx | 75 +-- packages/app/src/pages/error.tsx | 51 +- packages/app/src/pages/home.tsx | 14 +- packages/app/src/pages/layout.tsx | 388 ++++++------ .../app/src/pages/layout/inline-editor.tsx | 17 +- .../app/src/pages/layout/sidebar-items.tsx | 238 ++++--- .../app/src/pages/layout/sidebar-project.tsx | 381 ++++++----- .../app/src/pages/layout/sidebar-shell.tsx | 11 +- .../src/pages/layout/sidebar-workspace.tsx | 445 ++++++++----- packages/app/src/pages/session.tsx | 69 +- packages/app/src/pages/session/file-tabs.tsx | 94 ++- .../src/pages/session/message-timeline.tsx | 78 +-- packages/app/src/pages/session/review-tab.tsx | 14 +- .../src/pages/session/session-mobile-tabs.tsx | 14 +- .../src/pages/session/session-prompt-dock.tsx | 9 +- .../src/pages/session/session-side-panel.tsx | 24 +- .../app/src/pages/session/terminal-panel.tsx | 5 +- .../pages/session/use-session-commands.tsx | 152 +++-- packages/app/src/utils/solid-dnd.tsx | 78 ++- 93 files changed, 5326 insertions(+), 4386 deletions(-) create mode 100644 packages/app/src/components/session/session-context-breakdown.test.ts create mode 100644 packages/app/src/components/session/session-context-breakdown.ts create mode 100644 packages/app/src/components/session/session-context-format.ts diff --git a/packages/app/e2e/files/file-open.spec.ts b/packages/app/e2e/files/file-open.spec.ts index 3c636d748a79..abb28242da57 100644 --- a/packages/app/e2e/files/file-open.spec.ts +++ b/packages/app/e2e/files/file-open.spec.ts @@ -1,15 +1,28 @@ import { test, expect } from "../fixtures" -import { openPalette, clickListItem } from "../actions" +import { promptSelector } from "../selectors" test("can open a file tab from the search palette", async ({ page, gotoSession }) => { await gotoSession() - const dialog = await openPalette(page) + await page.locator(promptSelector).click() + await page.keyboard.type("/open") + + const command = page.locator('[data-slash-id="file.open"]').first() + await expect(command).toBeVisible() + await page.keyboard.press("Enter") + + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() const input = dialog.getByRole("textbox").first() await input.fill("package.json") - await clickListItem(dialog, { keyStartsWith: "file:" }) + const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first() + await expect(item).toBeVisible({ timeout: 30_000 }) + await item.click() await expect(dialog).toHaveCount(0) diff --git a/packages/app/e2e/files/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts index 52838449759f..b968acc130e6 100644 --- a/packages/app/e2e/files/file-viewer.spec.ts +++ b/packages/app/e2e/files/file-viewer.spec.ts @@ -1,18 +1,41 @@ import { test, expect } from "../fixtures" -import { openPalette, clickListItem } from "../actions" +import { promptSelector } from "../selectors" test("smoke file viewer renders real file content", async ({ page, gotoSession }) => { await gotoSession() - const sep = process.platform === "win32" ? "\\" : "/" - const file = ["packages", "app", "package.json"].join(sep) + await page.locator(promptSelector).click() + await page.keyboard.type("/open") - const dialog = await openPalette(page) + const command = page.locator('[data-slash-id="file.open"]').first() + await expect(command).toBeVisible() + await page.keyboard.press("Enter") - const input = dialog.getByRole("textbox").first() - await input.fill(file) + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() - await clickListItem(dialog, { text: /packages.*app.*package.json/ }) + const input = dialog.getByRole("textbox").first() + await input.fill("package.json") + + const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]') + let index = -1 + await expect + .poll( + async () => { + const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? "")) + index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, ""))) + return index >= 0 + }, + { timeout: 30_000 }, + ) + .toBe(true) + + const item = items.nth(index) + await expect(item).toBeVisible() + await item.click() await expect(dialog).toHaveCount(0) @@ -22,5 +45,5 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession } const code = page.locator('[data-component="code"]').first() await expect(code).toBeVisible() - await expect(code.getByText("@opencode-ai/app")).toBeVisible() + await expect(code.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible() }) diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts index 5af314cafaec..f33972cc3a31 100644 --- a/packages/app/e2e/projects/workspace-new-session.spec.ts +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -69,15 +69,19 @@ async function createSessionFromWorkspace(page: Page, slug: string, text: string const prompt = page.locator(promptSelector) await expect(prompt).toBeVisible() + await expect(prompt).toBeEditable() await prompt.click() - await page.keyboard.type(text) - await page.keyboard.press("Enter") + await expect(prompt).toBeFocused() + await prompt.fill(text) + await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text) + await prompt.press("Enter") await expect.poll(() => slugFromUrl(page.url())).toBe(slug) - await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 }) + await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") const sessionID = sessionIDFromUrl(page.url()) if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${sessionID}(?:[/?#]|$)`)) return sessionID } diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 071c398b22df..3867395267b5 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -11,18 +11,12 @@ import { cleanupTestProject, clickMenuItem, confirmDialog, - openProjectMenu, openSidebar, openWorkspaceMenu, setWorkspacesEnabled, } from "../actions" -import { - inlineInputSelector, - projectSwitchSelector, - projectWorkspacesToggleSelector, - workspaceItemSelector, -} from "../selectors" -import { dirSlug } from "../utils" +import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" +import { createSdk, dirSlug } from "../utils" function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" @@ -143,26 +137,35 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n") try { - await withProject( - async () => { - await openSidebar(page) + await withProject(async () => { + await page.goto(`/${nonGitSlug}/session`) + + await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("") - const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first() - await expect(nonGitButton).toBeVisible() - await nonGitButton.click() - await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`)) + const activeDir = base64Decode(slugFromUrl(page.url())) + expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-") - const menu = await openProjectMenu(page, nonGitSlug) - const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first() + await openSidebar(page) + await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - await expect(toggle).toBeVisible() - await expect(toggle).toBeDisabled() + const trigger = page.locator('[data-action="project-menu"]').first() + const hasMenu = await trigger + .isVisible() + .then((x) => x) + .catch(() => false) + if (!hasMenu) return - await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) - await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0) - }, - { extra: [nonGit] }, - ) + await trigger.click({ force: true }) + + const menu = page.locator(dropdownMenuContentSelector).first() + await expect(menu).toBeVisible() + + const toggle = menu.locator('[data-action="project-workspaces-toggle"]').first() + + await expect(toggle).toBeVisible() + await expect(toggle).toBeDisabled() + await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0) + }) } finally { await cleanupTestProject(nonGit) } @@ -256,14 +259,45 @@ test("can delete a workspace", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async (project) => { - const { rootSlug, slug } = await setupWorkspaceTest(page, project) + const sdk = createSdk(project.directory) + const { rootSlug, slug, directory } = await setupWorkspaceTest(page, project) + + await expect + .poll( + async () => { + const worktrees = await sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 30_000 }, + ) + .toBe(true) const menu = await openWorkspaceMenu(page, slug) await clickMenuItem(menu, /^Delete$/i, { force: true }) await confirmDialog(page, /^Delete workspace$/i) await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) - await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) + + await expect + .poll( + async () => { + const worktrees = await sdk.worktree + .list() + .then((r) => r.data ?? []) + .catch(() => [] as string[]) + return worktrees.includes(directory) + }, + { timeout: 60_000 }, + ) + .toBe(false) + + await project.gotoSession() + + await openSidebar(page) + await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 }) await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() }) }) diff --git a/packages/app/e2e/prompt/context.spec.ts b/packages/app/e2e/prompt/context.spec.ts index 80aa9ea334d1..366191fd70d5 100644 --- a/packages/app/e2e/prompt/context.spec.ts +++ b/packages/app/e2e/prompt/context.spec.ts @@ -1,40 +1,95 @@ import { test, expect } from "../fixtures" +import type { Page } from "@playwright/test" import { promptSelector } from "../selectors" import { withSession } from "../actions" +function contextButton(page: Page) { + return page + .locator('[data-component="button"]') + .filter({ has: page.locator('[data-component="progress-circle"]').first() }) + .first() +} + +async function seedContextSession(input: { sessionID: string; sdk: Parameters[0] }) { + await input.sdk.session.promptAsync({ + sessionID: input.sessionID, + noReply: true, + parts: [ + { + type: "text", + text: "seed context", + }, + ], + }) + + await expect + .poll(async () => { + const messages = await input.sdk.session + .messages({ sessionID: input.sessionID, limit: 1 }) + .then((r) => r.data ?? []) + return messages.length + }) + .toBeGreaterThan(0) +} + test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => { const title = `e2e smoke context ${Date.now()}` await withSession(sdk, title, async (session) => { - await sdk.session.promptAsync({ - sessionID: session.id, - noReply: true, - parts: [ - { - type: "text", - text: "seed context", - }, - ], - }) + await seedContextSession({ sessionID: session.id, sdk }) - await expect - .poll(async () => { - const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? []) - return messages.length - }) - .toBeGreaterThan(0) + await gotoSession(session.id) + + const trigger = contextButton(page) + await expect(trigger).toBeVisible() + await trigger.click() + + const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') + await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() + }) +}) +test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => { + await seedContextSession({ sessionID: session.id, sdk }) await gotoSession(session.id) - const contextButton = page - .locator('[data-component="button"]') - .filter({ has: page.locator('[data-component="progress-circle"]').first() }) - .first() + await page.locator(promptSelector).click() - await expect(contextButton).toBeVisible() - await contextButton.click() + const trigger = contextButton(page) + await expect(trigger).toBeVisible() + await trigger.click() const tabs = page.locator('[data-component="tabs"][data-variant="normal"]') - await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible() + const context = tabs.getByRole("tab", { name: "Context" }) + await expect(context).toBeVisible() + + await page.getByRole("button", { name: "Close tab" }).first().click() + await expect(context).toHaveCount(0) + }) +}) + +test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => { + await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => { + await seedContextSession({ sessionID: session.id, sdk }) + await gotoSession(session.id) + + await page.locator(promptSelector).click() + + const trigger = contextButton(page) + await expect(trigger).toBeVisible() + await trigger.click() + + await expect(page.getByRole("tab", { name: "Context" })).toBeVisible() + await page.getByRole("button", { name: "Open file" }).first().click() + + const dialog = page + .getByRole("dialog") + .filter({ has: page.getByPlaceholder(/search files/i) }) + .first() + await expect(dialog).toBeVisible() + + await page.keyboard.press("Escape") + await expect(dialog).toHaveCount(0) }) }) diff --git a/packages/app/e2e/prompt/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts index 07d242c6342b..ff9f5daf0d49 100644 --- a/packages/app/e2e/prompt/prompt.spec.ts +++ b/packages/app/e2e/prompt/prompt.spec.ts @@ -44,9 +44,6 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) ) .toContain(token) - - const reply = page.locator('[data-slot="session-turn-summary-section"]').filter({ hasText: token }).first() - await expect(reply).toBeVisible({ timeout: 90_000 }) } finally { page.off("pageerror", onPageError) await sdk.session.delete({ sessionID }).catch(() => undefined) diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts index 2a250dd866ab..c6ea2aea0aca 100644 --- a/packages/app/e2e/session/session-undo-redo.spec.ts +++ b/packages/app/e2e/session/session-undo-redo.spec.ts @@ -10,21 +10,26 @@ async function seedConversation(input: { sessionID: string token: string }) { + const messages = async () => + await input.sdk.session.messages({ sessionID: input.sessionID, limit: 100 }).then((r) => r.data ?? []) + const seeded = await messages() + const userIDs = new Set(seeded.filter((m) => m.info.role === "user").map((m) => m.info.id)) + const prompt = input.page.locator(promptSelector) await expect(prompt).toBeVisible() - await prompt.click() - await input.page.keyboard.type(`Reply with exactly: ${input.token}`) - await input.page.keyboard.press("Enter") + await input.sdk.session.promptAsync({ + sessionID: input.sessionID, + noReply: true, + parts: [{ type: "text", text: input.token }], + }) let userMessageID: string | undefined await expect .poll( async () => { - const messages = await input.sdk.session - .messages({ sessionID: input.sessionID, limit: 50 }) - .then((r) => r.data ?? []) - const users = messages.filter( + const users = (await messages()).filter( (m) => + !userIDs.has(m.info.id) && m.info.role === "user" && m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)), ) @@ -33,21 +38,14 @@ async function seedConversation(input: { const user = users[users.length - 1] if (!user) return false userMessageID = user.info.id - - const assistantText = messages - .filter((m) => m.info.role === "assistant") - .flatMap((m) => m.parts) - .filter((p) => p.type === "text") - .map((p) => p.text) - .join("\n") - - return assistantText.includes(input.token) + return true }, - { timeout: 90_000 }, + { timeout: 90_000, intervals: [250, 500, 1_000] }, ) .toBe(true) if (!userMessageID) throw new Error("Expected a user message id") + await expect(input.page.locator(`[data-message-id="${userMessageID}"]`).first()).toBeVisible({ timeout: 30_000 }) return { prompt, userMessageID } } diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 4610fb33152c..93eaee5cb0bf 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -34,21 +34,34 @@ async function seedMessage(sdk: Sdk, sessionID: string) { test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => { const stamp = Date.now() const originalTitle = `e2e rename test ${stamp}` - const newTitle = `e2e renamed ${stamp}` + const renamedTitle = `e2e renamed ${stamp}` await withSession(sdk, originalTitle, async (session) => { await seedMessage(sdk, session.id) await gotoSession(session.id) + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) const menu = await openSessionMoreMenu(page, session.id) await clickMenuItem(menu, /rename/i) const input = page.locator(".session-scroller").locator(inlineInputSelector).first() await expect(input).toBeVisible() - await input.fill(newTitle) + await expect(input).toBeFocused() + await input.fill(renamedTitle) + await expect(input).toHaveValue(renamedTitle) await input.press("Enter") - await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle) + await expect + .poll( + async () => { + const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data) + return data?.title + }, + { timeout: 30_000 }, + ) + .toBe(renamedTitle) + + await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle) }) }) @@ -116,8 +129,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk, await seedMessage(sdk, session.id) await gotoSession(session.id) - const { rightSection, popoverBody } = await openSharePopover(page) - await popoverBody.getByRole("button", { name: "Publish" }).first().click() + const shared = await openSharePopover(page) + const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first() + await expect(publish).toBeVisible({ timeout: 30_000 }) + await publish.click() + + await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({ + timeout: 30_000, + }) await expect .poll( @@ -129,14 +148,14 @@ test("session can be shared and unshared via header button", async ({ page, sdk, ) .not.toBeUndefined() - const copyButton = rightSection.locator('button[aria-label="Copy link"]').first() - await expect(copyButton).toBeVisible({ timeout: 30_000 }) - - const sharedPopover = await openSharePopover(page) - const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first() + const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first() await expect(unpublish).toBeVisible({ timeout: 30_000 }) await unpublish.click() + await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + timeout: 30_000, + }) + await expect .poll( async () => { @@ -147,10 +166,8 @@ test("session can be shared and unshared via header button", async ({ page, sdk, ) .toBeUndefined() - await expect(copyButton).not.toBeVisible({ timeout: 30_000 }) - - const unsharedPopover = await openSharePopover(page) - await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ + const unshared = await openSharePopover(page) + await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({ timeout: 30_000, }) }) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e49b725a1975..3032a795f8cd 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js" +import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -30,12 +30,26 @@ import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { Suspense, JSX } from "solid-js" - const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const Loading = () =>
+const HomeRoute = () => ( + }> + + +) + +const SessionRoute = () => ( + + }> + + + +) + +const SessionIndexRoute = () => + function UiI18nBridge(props: ParentProps) { const language = useLanguage() return {props.children} @@ -52,6 +66,71 @@ function MarkedProviderWithNativeParser(props: ParentProps) { return {props.children} } +function AppShellProviders(props: ParentProps) { + return ( + + + + + + + + {props.children} + + + + + + + + ) +} + +function SessionProviders(props: ParentProps) { + return ( + + + + {props.children} + + + + ) +} + +function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { + return ( + + {props.appChildren} + {props.children} + + ) +} + +const getStoredDefaultServerUrl = (platform: ReturnType) => { + if (platform.platform !== "web") return + const result = platform.getDefaultServerUrl?.() + if (result instanceof Promise) return + if (!result) return + return normalizeServerUrl(result) +} + +const resolveDefaultServerUrl = (props: { + defaultUrl?: string + storedDefaultServerUrl?: string + hostname: string + origin: string + isDev: boolean + devHost?: string + devPort?: string +}) => { + if (props.defaultUrl) return props.defaultUrl + if (props.storedDefaultServerUrl) return props.storedDefaultServerUrl + if (props.hostname.includes("opencode.ai")) return "http://localhost:4096" + if (props.isDev) return `http://${props.devHost ?? "localhost"}:${props.devPort ?? "4096"}` + return props.origin +} + export function AppBaseProviders(props: ParentProps) { return ( @@ -77,89 +156,35 @@ export function AppBaseProviders(props: ParentProps) { function ServerKey(props: ParentProps) { const server = useServer() - return ( - - {props.children} - - ) + if (!server.url) return null + return props.children } export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { const platform = usePlatform() - - const stored = (() => { - if (platform.platform !== "web") return - const result = platform.getDefaultServerUrl?.() - if (result instanceof Promise) return - if (!result) return - return normalizeServerUrl(result) - })() - - const defaultServerUrl = () => { - if (props.defaultUrl) return props.defaultUrl - if (stored) return stored - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - - return window.location.origin - } + const storedDefaultServerUrl = getStoredDefaultServerUrl(platform) + const defaultServerUrl = resolveDefaultServerUrl({ + defaultUrl: props.defaultUrl, + storedDefaultServerUrl, + hostname: location.hostname, + origin: window.location.origin, + isDev: import.meta.env.DEV, + devHost: import.meta.env.VITE_OPENCODE_SERVER_HOST, + devPort: import.meta.env.VITE_OPENCODE_SERVER_PORT, + }) return ( - + ( - - - - - - - - - {props.children} - {routerProps.children} - - - - - - - - - )} + root={(routerProps) => {routerProps.children}} > - ( - }> - - - )} - /> + - } /> - ( - - - - - - }> - - - - - - - - )} - /> + + diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 65e322b43451..4d24b23158f5 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -10,7 +10,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { iife } from "@opencode-ai/util/iife" import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" @@ -55,6 +54,47 @@ export function DialogConnectProvider(props: { provider: string }) { error: undefined as string | undefined, }) + type Action = + | { type: "method.select"; index: number } + | { type: "method.reset" } + | { type: "auth.pending" } + | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.error"; error: string } + + function dispatch(action: Action) { + setStore( + produce((draft) => { + if (action.type === "method.select") { + draft.methodIndex = action.index + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "method.reset") { + draft.methodIndex = undefined + draft.authorization = undefined + draft.state = undefined + draft.error = undefined + return + } + if (action.type === "auth.pending") { + draft.state = "pending" + draft.error = undefined + return + } + if (action.type === "auth.complete") { + draft.state = "complete" + draft.authorization = action.authorization + draft.error = undefined + return + } + draft.state = "error" + draft.error = action.error + }), + ) + } + const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined)) const methodLabel = (value?: { type?: string; label?: string }) => { @@ -70,17 +110,10 @@ export function DialogConnectProvider(props: { provider: string }) { } const method = methods()[index] - setStore( - produce((draft) => { - draft.methodIndex = index - draft.authorization = undefined - draft.state = undefined - draft.error = undefined - }), - ) + dispatch({ type: "method.select", index }) if (method.type === "oauth") { - setStore("state", "pending") + dispatch({ type: "auth.pending" }) const start = Date.now() await globalSDK.client.provider.oauth .authorize( @@ -100,18 +133,15 @@ export function DialogConnectProvider(props: { provider: string }) { timer.current = setTimeout(() => { timer.current = undefined if (!alive.value) return - setStore("state", "complete") - setStore("authorization", x.data!) + dispatch({ type: "auth.complete", authorization: x.data! }) }, delay) return } - setStore("state", "complete") - setStore("authorization", x.data!) + dispatch({ type: "auth.complete", authorization: x.data! }) }) .catch((e) => { if (!alive.value) return - setStore("state", "error") - setStore("error", String(e)) + dispatch({ type: "auth.error", error: String(e) }) }) } } @@ -129,10 +159,6 @@ export function DialogConnectProvider(props: { provider: string }) { if (methods().length === 1) { selectMethod(0) } - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) }) async function complete() { @@ -152,17 +178,244 @@ export function DialogConnectProvider(props: { provider: string }) { return } if (store.authorization) { - setStore("authorization", undefined) - setStore("methodIndex", undefined) + dispatch({ type: "method.reset" }) return } - if (store.methodIndex) { - setStore("methodIndex", undefined) + if (store.methodIndex !== undefined) { + dispatch({ type: "method.reset" }) return } dialog.show(() => ) } + function MethodSelection() { + return ( + <> +
+ {language.t("provider.connect.selectMethod", { provider: provider().name })} +
+
+ { + listRef = ref + }} + items={methods} + key={(m) => m?.label} + onSelect={async (selected, index) => { + if (!selected) return + selectMethod(index) + }} + > + {(i) => ( +
+
+ + {methodLabel(i)} +
+ )} + +
+ + ) + } + + function ApiAuthView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", language.t("provider.connect.apiKey.required")) + return + } + + setFormStore("error", undefined) + await globalSDK.client.auth.set({ + providerID: props.provider, + auth: { + type: "api", + key: apiKey, + }, + }) + await complete() + } + + return ( +
+ + +
+
{language.t("provider.connect.opencodeZen.line1")}
+
{language.t("provider.connect.opencodeZen.line2")}
+
+ {language.t("provider.connect.opencodeZen.visit.prefix")} + + {language.t("provider.connect.opencodeZen.visit.link")} + + {language.t("provider.connect.opencodeZen.visit.suffix")} +
+
+
+ +
+ {language.t("provider.connect.apiKey.description", { provider: provider().name })} +
+
+
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) + } + + function OAuthCodeView() { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + onMount(() => { + if (store.authorization?.method === "code" && store.authorization?.url) { + platform.openLink(store.authorization.url) + } + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const code = formData.get("code") as string + + if (!code?.trim()) { + setFormStore("error", language.t("provider.connect.oauth.code.required")) + return + } + + setFormStore("error", undefined) + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + code, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + if (result.ok) { + await complete() + return + } + const message = result.error instanceof Error ? result.error.message : String(result.error) + setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) + } + + return ( +
+
+ {language.t("provider.connect.oauth.code.visit.prefix")} + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} +
+
+ setFormStore("value", v)} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + + +
+ ) + } + + function OAuthAutoView() { + const code = createMemo(() => { + const instructions = store.authorization?.instructions + if (instructions?.includes(":")) { + return instructions.split(":")[1]?.trim() + } + return instructions + }) + + onMount(() => { + void (async () => { + if (store.authorization?.url) { + platform.openLink(store.authorization.url) + } + + const result = await globalSDK.client.provider.oauth + .callback({ + providerID: props.provider, + method: store.methodIndex, + }) + .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .catch((error) => ({ ok: false as const, error })) + + if (!alive.value) return + + if (!result.ok) { + const message = result.error instanceof Error ? result.error.message : String(result.error) + dispatch({ type: "auth.error", error: message }) + return + } + + await complete() + })() + }) + + return ( +
+
+ {language.t("provider.connect.oauth.auto.visit.prefix")} + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} +
+ +
+ + {language.t("provider.connect.status.waiting")} +
+
+ ) + } + return (
- - -
- {language.t("provider.connect.selectMethod", { provider: provider().name })} -
-
- { - listRef = ref - }} - items={methods} - key={(m) => m?.label} - onSelect={async (method, index) => { - if (!method) return - selectMethod(index) - }} - > - {(i) => ( -
-
- - {methodLabel(i)} -
- )} - -
- - -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
- -
-
- - {language.t("provider.connect.status.failed", { error: store.error ?? "" })} +
+ + + + + +
+
+ + {language.t("provider.connect.status.inProgress")} +
-
- - - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const apiKey = formData.get("apiKey") as string - - if (!apiKey?.trim()) { - setFormStore("error", language.t("provider.connect.apiKey.required")) - return - } - - setFormStore("error", undefined) - await globalSDK.client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - }, - }) - await complete() - } - - return ( -
- - -
-
- {language.t("provider.connect.opencodeZen.line1")} -
-
- {language.t("provider.connect.opencodeZen.line2")} -
-
- {language.t("provider.connect.opencodeZen.visit.prefix")} - - {language.t("provider.connect.opencodeZen.visit.link")} - - {language.t("provider.connect.opencodeZen.visit.suffix")} -
-
-
- -
- {language.t("provider.connect.apiKey.description", { provider: provider().name })} -
-
-
-
- - - + + +
+
+ + {language.t("provider.connect.status.failed", { error: store.error ?? "" })}
- ) - })} - - - - - {iife(() => { - const [formStore, setFormStore] = createStore({ - value: "", - error: undefined as string | undefined, - }) - - onMount(() => { - if (store.authorization?.method === "code" && store.authorization?.url) { - platform.openLink(store.authorization.url) - } - }) - - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - - const form = e.currentTarget as HTMLFormElement - const formData = new FormData(form) - const code = formData.get("code") as string - - if (!code?.trim()) { - setFormStore("error", language.t("provider.connect.oauth.code.required")) - return - } - - setFormStore("error", undefined) - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - code, - }) - .then((value) => - value.error ? { ok: false as const, error: value.error } : { ok: true as const }, - ) - .catch((error) => ({ ok: false as const, error })) - if (result.ok) { - await complete() - return - } - const message = result.error instanceof Error ? result.error.message : String(result.error) - setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) - } - - return ( -
-
- {language.t("provider.connect.oauth.code.visit.prefix")} - - {language.t("provider.connect.oauth.code.visit.link")} - - {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })} -
-
- - - -
- ) - })} -
- - {iife(() => { - const code = createMemo(() => { - const instructions = store.authorization?.instructions - if (instructions?.includes(":")) { - return instructions?.split(":")[1]?.trim() - } - return instructions - }) - - onMount(() => { - void (async () => { - if (store.authorization?.url) { - platform.openLink(store.authorization.url) - } - - const result = await globalSDK.client.provider.oauth - .callback({ - providerID: props.provider, - method: store.methodIndex, - }) - .then((value) => - value.error ? { ok: false as const, error: value.error } : { ok: true as const }, - ) - .catch((error) => ({ ok: false as const, error })) - - if (!alive.value) return - - if (!result.ok) { - const message = result.error instanceof Error ? result.error.message : String(result.error) - setStore("state", "error") - setStore("error", message) - return - } - - await complete() - })() - }) - - return ( -
-
- {language.t("provider.connect.oauth.auto.visit.prefix")} - - {language.t("provider.connect.oauth.auto.visit.link")} - - {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })} -
- -
- - {language.t("provider.connect.status.waiting")} -
-
- ) - })} -
-
-
- +
+
+ + + + + + + + + + + + + + +
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 53773ed9eabe..017b85a2c997 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -6,7 +6,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { For } from "solid-js" -import { createStore, produce } from "solid-js/store" +import { createStore } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -16,6 +16,147 @@ import { DialogSelectProvider } from "./dialog-select-provider" const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" +type Translator = ReturnType["t"] + +type ModelRow = { + id: string + name: string +} + +type HeaderRow = { + key: string + value: string +} + +type FormState = { + providerID: string + name: string + baseURL: string + apiKey: string + models: ModelRow[] + headers: HeaderRow[] + saving: boolean +} + +type FormErrors = { + providerID: string | undefined + name: string | undefined + baseURL: string | undefined + models: Array<{ id?: string; name?: string }> + headers: Array<{ key?: string; value?: string }> +} + +type ValidateArgs = { + form: FormState + t: Translator + disabledProviders: string[] + existingProviderIDs: Set +} + +function validateCustomProvider(input: ValidateArgs) { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + const idError = !providerID + ? input.t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(providerID) + ? input.t("provider.custom.error.providerID.format") + : undefined + + const nameError = !name ? input.t("provider.custom.error.name.required") : undefined + const urlError = !baseURL + ? input.t("provider.custom.error.baseURL.required") + : !/^https?:\/\//.test(baseURL) + ? input.t("provider.custom.error.baseURL.format") + : undefined + + const disabled = input.disabledProviders.includes(providerID) + const existsError = idError + ? undefined + : input.existingProviderIDs.has(providerID) && !disabled + ? input.t("provider.custom.error.providerID.exists") + : undefined + + const seenModels = new Set() + const modelErrors = input.form.models.map((m) => { + const id = m.id.trim() + const modelIdError = !id + ? input.t("provider.custom.error.required") + : seenModels.has(id) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenModels.add(id) + return undefined + })() + const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined + return { id: modelIdError, name: modelNameError } + }) + const modelsValid = modelErrors.every((m) => !m.id && !m.name) + const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + + const seenHeaders = new Set() + const headerErrors = input.form.headers.map((h) => { + const key = h.key.trim() + const value = h.value.trim() + + if (!key && !value) return {} + const keyError = !key + ? input.t("provider.custom.error.required") + : seenHeaders.has(key.toLowerCase()) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenHeaders.add(key.toLowerCase()) + return undefined + })() + const valueError = !value ? input.t("provider.custom.error.required") : undefined + return { key: keyError, value: valueError } + }) + const headersValid = headerErrors.every((h) => !h.key && !h.value) + const headers = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + + const errors: FormErrors = { + providerID: idError ?? existsError, + name: nameError, + baseURL: urlError, + models: modelErrors, + headers: headerErrors, + } + + const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + if (!ok) return { errors } + + const options = { + baseURL, + ...(Object.keys(headers).length ? { headers } : {}), + } + + return { + errors, + result: { + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options, + models, + }, + }, + } +} + type Props = { back?: "providers" | "close" } @@ -26,7 +167,7 @@ export function DialogCustomProvider(props: Props) { const globalSDK = useGlobalSDK() const language = useLanguage() - const [form, setForm] = createStore({ + const [form, setForm] = createStore({ providerID: "", name: "", baseURL: "", @@ -36,12 +177,12 @@ export function DialogCustomProvider(props: Props) { saving: false, }) - const [errors, setErrors] = createStore({ - providerID: undefined as string | undefined, - name: undefined as string | undefined, - baseURL: undefined as string | undefined, - models: [{} as { id?: string; name?: string }], - headers: [{} as { key?: string; value?: string }], + const [errors, setErrors] = createStore({ + providerID: undefined, + name: undefined, + baseURL: undefined, + models: [{}], + headers: [{}], }) const goBack = () => { @@ -53,169 +194,36 @@ export function DialogCustomProvider(props: Props) { } const addModel = () => { - setForm( - "models", - produce((draft) => { - draft.push({ id: "", name: "" }) - }), - ) - setErrors( - "models", - produce((draft) => { - draft.push({}) - }), - ) + setForm("models", (v) => [...v, { id: "", name: "" }]) + setErrors("models", (v) => [...v, {}]) } const removeModel = (index: number) => { if (form.models.length <= 1) return - setForm( - "models", - produce((draft) => { - draft.splice(index, 1) - }), - ) - setErrors( - "models", - produce((draft) => { - draft.splice(index, 1) - }), - ) + setForm("models", (v) => v.filter((_, i) => i !== index)) + setErrors("models", (v) => v.filter((_, i) => i !== index)) } const addHeader = () => { - setForm( - "headers", - produce((draft) => { - draft.push({ key: "", value: "" }) - }), - ) - setErrors( - "headers", - produce((draft) => { - draft.push({}) - }), - ) + setForm("headers", (v) => [...v, { key: "", value: "" }]) + setErrors("headers", (v) => [...v, {}]) } const removeHeader = (index: number) => { if (form.headers.length <= 1) return - setForm( - "headers", - produce((draft) => { - draft.splice(index, 1) - }), - ) - setErrors( - "headers", - produce((draft) => { - draft.splice(index, 1) - }), - ) + setForm("headers", (v) => v.filter((_, i) => i !== index)) + setErrors("headers", (v) => v.filter((_, i) => i !== index)) } const validate = () => { - const providerID = form.providerID.trim() - const name = form.name.trim() - const baseURL = form.baseURL.trim() - const apiKey = form.apiKey.trim() - - const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() - const key = apiKey && !env ? apiKey : undefined - - const idError = !providerID - ? language.t("provider.custom.error.providerID.required") - : !PROVIDER_ID.test(providerID) - ? language.t("provider.custom.error.providerID.format") - : undefined - - const nameError = !name ? language.t("provider.custom.error.name.required") : undefined - const urlError = !baseURL - ? language.t("provider.custom.error.baseURL.required") - : !/^https?:\/\//.test(baseURL) - ? language.t("provider.custom.error.baseURL.format") - : undefined - - const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID) - const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID) - const existsError = idError - ? undefined - : existingProvider && !disabled - ? language.t("provider.custom.error.providerID.exists") - : undefined - - const seenModels = new Set() - const modelErrors = form.models.map((m) => { - const id = m.id.trim() - const modelIdError = !id - ? language.t("provider.custom.error.required") - : seenModels.has(id) - ? language.t("provider.custom.error.duplicate") - : (() => { - seenModels.add(id) - return undefined - })() - const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined - return { id: modelIdError, name: modelNameError } + const output = validateCustomProvider({ + form, + t: language.t, + disabledProviders: globalSync.data.config.disabled_providers ?? [], + existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), }) - const modelsValid = modelErrors.every((m) => !m.id && !m.name) - const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) - - const seenHeaders = new Set() - const headerErrors = form.headers.map((h) => { - const key = h.key.trim() - const value = h.value.trim() - - if (!key && !value) return {} - const keyError = !key - ? language.t("provider.custom.error.required") - : seenHeaders.has(key.toLowerCase()) - ? language.t("provider.custom.error.duplicate") - : (() => { - seenHeaders.add(key.toLowerCase()) - return undefined - })() - const valueError = !value ? language.t("provider.custom.error.required") : undefined - return { key: keyError, value: valueError } - }) - const headersValid = headerErrors.every((h) => !h.key && !h.value) - const headers = Object.fromEntries( - form.headers - .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) - .filter((h) => !!h.key && !!h.value) - .map((h) => [h.key, h.value]), - ) - - setErrors( - produce((draft) => { - draft.providerID = idError ?? existsError - draft.name = nameError - draft.baseURL = urlError - draft.models = modelErrors - draft.headers = headerErrors - }), - ) - - const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid - if (!ok) return - - const options = { - baseURL, - ...(Object.keys(headers).length ? { headers } : {}), - } - - return { - providerID, - name, - key, - config: { - npm: OPENAI_COMPATIBLE, - name, - ...(env ? { env: [env] } : {}), - options, - models, - }, - } + setErrors(output.errors) + return output.result } const save = async (e: SubmitEvent) => { @@ -297,7 +305,7 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.providerID.placeholder")} description={language.t("provider.custom.field.providerID.description")} value={form.providerID} - onChange={setForm.bind(null, "providerID")} + onChange={(v) => setForm("providerID", v)} validationState={errors.providerID ? "invalid" : undefined} error={errors.providerID} /> @@ -305,7 +313,7 @@ export function DialogCustomProvider(props: Props) { label={language.t("provider.custom.field.name.label")} placeholder={language.t("provider.custom.field.name.placeholder")} value={form.name} - onChange={setForm.bind(null, "name")} + onChange={(v) => setForm("name", v)} validationState={errors.name ? "invalid" : undefined} error={errors.name} /> @@ -313,7 +321,7 @@ export function DialogCustomProvider(props: Props) { label={language.t("provider.custom.field.baseURL.label")} placeholder={language.t("provider.custom.field.baseURL.placeholder")} value={form.baseURL} - onChange={setForm.bind(null, "baseURL")} + onChange={(v) => setForm("baseURL", v)} validationState={errors.baseURL ? "invalid" : undefined} error={errors.baseURL} /> @@ -322,7 +330,7 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.apiKey.placeholder")} description={language.t("provider.custom.field.apiKey.description")} value={form.apiKey} - onChange={setForm.bind(null, "apiKey")} + onChange={(v) => setForm("apiKey", v)} />
diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index dbad81798f08..ec0793c540ee 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -33,6 +33,8 @@ export function DialogEditProject(props: { project: LocalProject }) { iconHover: false, }) + let iconInput: HTMLInputElement | undefined + function handleFileSelect(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() @@ -72,31 +74,35 @@ export function DialogEditProject(props: { project: LocalProject }) { async function handleSubmit(e: SubmitEvent) { e.preventDefault() - setStore("saving", true) - const name = store.name.trim() === folderName() ? "" : store.name.trim() - const start = store.startup.trim() - - if (props.project.id && props.project.id !== "global") { - await globalSDK.client.project.update({ - projectID: props.project.id, - directory: props.project.worktree, - name, - icon: { color: store.color, override: store.iconUrl }, - commands: { start }, - }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) - setStore("saving", false) - dialog.close() - return - } + await Promise.resolve() + .then(async () => { + setStore("saving", true) + const name = store.name.trim() === folderName() ? "" : store.name.trim() + const start = store.startup.trim() - globalSync.project.meta(props.project.worktree, { - name, - icon: { color: store.color, override: store.iconUrl || undefined }, - commands: { start: start || undefined }, - }) - setStore("saving", false) - dialog.close() + if (props.project.id && props.project.id !== "global") { + await globalSDK.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color, override: store.iconUrl }, + commands: { start }, + }) + globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + dialog.close() + return + } + + globalSync.project.meta(props.project.worktree, { + name, + icon: { color: store.color, override: store.iconUrl || undefined }, + commands: { start: start || undefined }, + }) + dialog.close() + }) + .finally(() => { + setStore("saving", false) + }) } return ( @@ -134,7 +140,7 @@ export function DialogEditProject(props: { project: LocalProject }) { if (store.iconUrl && store.iconHover) { clearIcon() } else { - document.getElementById("icon-upload")?.click() + iconInput?.click() } }} > @@ -176,7 +182,16 @@ export function DialogEditProject(props: { project: LocalProject }) {
- + { + iconInput = el + }} + type="file" + accept="image/*" + class="hidden" + onChange={handleInputChange} + />
{language.t("dialog.project.edit.icon.hint")} {language.t("dialog.project.edit.icon.recommended")} diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 09d62021f21c..8810955cc655 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -6,6 +6,7 @@ import { usePrompt } from "@/context/prompt" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" +import { showToast } from "@opencode-ai/ui/toast" import { extractPromptFromParts } from "@/utils/prompt" import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client" import { base64Encode } from "@opencode-ai/util/encode" @@ -66,15 +67,23 @@ export const DialogFork: Component = () => { attachmentName: language.t("common.attachment"), }) - dialog.close() - - sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => { - if (!forked.data) return - navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) - requestAnimationFrame(() => { - prompt.set(restored) + sdk.client.session + .fork({ sessionID, messageID: item.id }) + .then((forked) => { + if (!forked.data) { + showToast({ title: language.t("common.requestFailed") }) + return + } + dialog.close() + navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`) + requestAnimationFrame(() => { + prompt.set(restored) + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) }) - }) } return ( diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index 9ee48736ca06..d4d4af0f10dd 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -17,6 +17,7 @@ export const DialogManageModels: Component = () => { const handleConnectProvider = () => { dialog.show(() => ) } + const providerRank = (id: string) => popularProviders.indexOf(id) return ( { sortBy={(a, b) => a.name.localeCompare(b.name)} groupBy={(x) => x.provider.name} sortGroupsBy={(a, b) => { - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + const aRank = providerRank(a.items[0].provider.id) + const bRank = providerRank(b.items[0].provider.id) + const aPopular = aRank >= 0 + const bPopular = bRank >= 0 + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + return aRank - bRank }} onSelect={(x) => { if (!x) return - const visible = local.model.visible({ - modelID: x.id, - providerID: x.provider.id, - }) - local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible) + const key = { modelID: x.id, providerID: x.provider.id } + local.model.setVisibility(key, !local.model.visible(key)) }} > {(i) => ( @@ -57,12 +57,7 @@ export const DialogManageModels: Component = () => { {i.name}
e.stopPropagation()}> { local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) }} diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index c6f2f3930e2d..2040009a8c3c 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -1,4 +1,4 @@ -import { createSignal, createEffect, onMount, onCleanup } from "solid-js" +import { createSignal } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -40,8 +40,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { handleClose() } - let focusTrap: HTMLDivElement | undefined - function handleKeyDown(e: KeyboardEvent) { if (e.key === "Escape") { e.preventDefault() @@ -60,27 +58,13 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { } } - onMount(() => { - focusTrap?.focus() - document.addEventListener("keydown", handleKeyDown) - onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) - }) - - // Refocus the trap when index changes to ensure escape always works - createEffect(() => { - index() // track index - focusTrap?.focus() - }) - return ( - {/* Hidden element to capture initial focus and handle escape */} -
-
+
{/* Left side - Text content */}
{/* Top section - feature content (fixed position from top) */} diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 6e7af3d902d8..515e640c9fab 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -2,13 +2,13 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" +import type { ListRef } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" import { createMemo, createResource, createSignal } from "solid-js" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" -import type { ListRef } from "@opencode-ai/ui/list" interface DialogSelectDirectoryProps { title?: string @@ -21,157 +21,131 @@ type Row = { search: string } -export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { - const sync = useGlobalSync() - const sdk = useGlobalSDK() - const dialog = useDialog() - const language = useLanguage() - - const [filter, setFilter] = createSignal("") - - let list: ListRef | undefined - - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) - - const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), - async () => { - return sdk.client.path - .get() - .then((x) => x.data) - .catch(() => undefined) - }, - { initialValue: undefined }, - ) - - const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") - - const start = createMemo( - () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, - ) - - const cache = new Map>>() +function cleanInput(value: string) { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() +} - const clean = (value: string) => { - const first = (value ?? "").split(/\r?\n/)[0] ?? "" - return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() - } +function normalizePath(input: string) { + const v = input.replaceAll("\\", "/") + if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") + return v.replace(/\/+/g, "/") +} - function normalize(input: string) { - const v = input.replaceAll("\\", "/") - if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") - return v.replace(/\/+/g, "/") - } +function normalizeDriveRoot(input: string) { + const v = normalizePath(input) + if (/^[A-Za-z]:$/.test(v)) return v + "/" + return v +} - function normalizeDriveRoot(input: string) { - const v = normalize(input) - if (/^[A-Za-z]:$/.test(v)) return v + "/" - return v - } +function trimTrailing(input: string) { + const v = normalizeDriveRoot(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v + return v.replace(/\/+$/, "") +} - function trimTrailing(input: string) { - const v = normalizeDriveRoot(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - return v.replace(/\/+$/, "") - } +function joinPath(base: string | undefined, rel: string) { + const b = trimTrailing(base ?? "") + const r = trimTrailing(rel).replace(/^\/+/, "") + if (!b) return r + if (!r) return b + if (b.endsWith("/")) return b + r + return b + "/" + r +} - function join(base: string | undefined, rel: string) { - const b = trimTrailing(base ?? "") - const r = trimTrailing(rel).replace(/^\/+/, "") - if (!b) return r - if (!r) return b - if (b.endsWith("/")) return b + r - return b + "/" + r - } +function rootOf(input: string) { + const v = normalizeDriveRoot(input) + if (v.startsWith("//")) return "//" + if (v.startsWith("/")) return "/" + if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) + return "" +} - function rootOf(input: string) { - const v = normalizeDriveRoot(input) - if (v.startsWith("//")) return "//" - if (v.startsWith("/")) return "/" - if (/^[A-Za-z]:\//.test(v)) return v.slice(0, 3) - return "" - } +function parentOf(input: string) { + const v = trimTrailing(input) + if (v === "/") return v + if (v === "//") return v + if (/^[A-Za-z]:\/$/.test(v)) return v - function parentOf(input: string) { - const v = trimTrailing(input) - if (v === "/") return v - if (v === "//") return v - if (/^[A-Za-z]:\/$/.test(v)) return v + const i = v.lastIndexOf("/") + if (i <= 0) return "/" + if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) + return v.slice(0, i) +} - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3) - return v.slice(0, i) - } +function modeOf(input: string) { + const raw = normalizeDriveRoot(input.trim()) + if (!raw) return "relative" as const + if (raw.startsWith("~")) return "tilde" as const + if (rootOf(raw)) return "absolute" as const + return "relative" as const +} - function modeOf(input: string) { - const raw = normalizeDriveRoot(input.trim()) - if (!raw) return "relative" as const - if (raw.startsWith("~")) return "tilde" as const - if (rootOf(raw)) return "absolute" as const - return "relative" as const - } +function tildeOf(absolute: string, home: string) { + const full = trimTrailing(absolute) + if (!home) return "" - function display(path: string, input: string) { - const full = trimTrailing(path) - if (modeOf(input) === "absolute") return full + const hn = trimTrailing(home) + const lc = full.toLowerCase() + const hc = hn.toLowerCase() + if (lc === hc) return "~" + if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) + return "" +} - return tildeOf(full) || full - } +function displayPath(path: string, input: string, home: string) { + const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + return tildeOf(full, home) || full +} - function tildeOf(absolute: string) { - const full = trimTrailing(absolute) - const h = home() - if (!h) return "" - - const hn = trimTrailing(h) - const lc = full.toLowerCase() - const hc = hn.toLowerCase() - if (lc === hc) return "~" - if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length) - return "" +function toRow(absolute: string, home: string): Row { + const full = trimTrailing(absolute) + const tilde = tildeOf(full, home) + const withSlash = (value: string) => { + if (!value) return "" + if (value.endsWith("/")) return value + return value + "/" } - function row(absolute: string): Row { - const full = trimTrailing(absolute) - const tilde = tildeOf(full) - - const withSlash = (value: string) => { - if (!value) return "" - if (value.endsWith("/")) return value - return value + "/" - } + const search = Array.from( + new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), + ).join("\n") + return { absolute: full, search } +} - const search = Array.from( - new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)), - ).join("\n") - return { absolute: full, search } - } +function useDirectorySearch(args: { + sdk: ReturnType + start: () => string | undefined + home: () => string +}) { + const cache = new Map>>() + let current = 0 - function scoped(value: string) { - const base = start() + const scoped = (value: string) => { + const base = args.start() if (!base) return const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } - const h = home() - if (raw === "~") return { directory: trimTrailing(h ?? base), path: "" } - if (raw.startsWith("~/")) return { directory: trimTrailing(h ?? base), path: raw.slice(2) } + const h = args.home() + if (raw === "~") return { directory: trimTrailing(h || base), path: "" } + if (raw.startsWith("~/")) return { directory: trimTrailing(h || base), path: raw.slice(2) } const root = rootOf(raw) if (root) return { directory: trimTrailing(root), path: raw.slice(root.length) } return { directory: trimTrailing(base), path: raw } } - async function dirs(dir: string) { + const dirs = async (dir: string) => { const key = trimTrailing(dir) const existing = cache.get(key) if (existing) return existing - const request = sdk.client.file + const request = args.sdk.client.file .list({ directory: key, path: "" }) .then((x) => x.data ?? []) .catch(() => []) @@ -188,32 +162,34 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return request } - async function match(dir: string, query: string, limit: number) { + const match = async (dir: string, query: string, limit: number) => { const items = await dirs(dir) if (!query) return items.slice(0, limit).map((x) => x.absolute) return fuzzysort.go(query, items, { key: "name", limit }).map((x) => x.obj.absolute) } - const directories = async (filter: string) => { - const value = clean(filter) + return async (filter: string) => { + const token = ++current + const active = () => token === current + + const value = cleanInput(filter) const scopedInput = scoped(value) if (!scopedInput) return [] as string[] const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(scopedInput.path) const find = () => - sdk.client.find + args.sdk.client.find .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) if (!isPath) { const results = await find() - - return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) + if (!active()) return [] + return results.map((rel) => joinPath(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -224,17 +200,20 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const branch = 4 let paths = [scopedInput.directory] for (const part of head) { + if (!active()) return [] if (part === "..") { paths = paths.map(parentOf) continue } const next = (await Promise.all(paths.map((p) => match(p, part, branch)))).flat() + if (!active()) return [] paths = Array.from(new Set(next)).slice(0, cap) if (paths.length === 0) return [] as string[] } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() + if (!active()) return [] const deduped = Array.from(new Set(out)) const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" const expand = !raw.endsWith("/") @@ -249,13 +228,47 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { if (!target) return deduped.slice(0, 50) const children = await match(target, "", 30) + if (!active()) return [] const items = Array.from(new Set([...deduped, ...children])) return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) } +} + +export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { + const sync = useGlobalSync() + const sdk = useGlobalSDK() + const dialog = useDialog() + const language = useLanguage() + + const [filter, setFilter] = createSignal("") + let list: ListRef | undefined + + const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const [fallbackPath] = createResource( + () => (missingBase() ? true : undefined), + async () => { + return sdk.client.path + .get() + .then((x) => x.data) + .catch(() => undefined) + }, + { initialValue: undefined }, + ) + + const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) + + const directories = useDirectorySearch({ + sdk, + home, + start, + }) const items = async (value: string) => { const results = await directories(value) - return results.map(row) + return results.map((absolute) => toRow(absolute, home())) } function resolve(absolute: string) { @@ -273,7 +286,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { key={(x) => x.absolute} filterKeys={["search"]} ref={(r) => (list = r)} - onFilter={(value) => setFilter(clean(value))} + onFilter={(value) => setFilter(cleanInput(value))} onKeyEvent={(e, item) => { if (e.key !== "Tab") return if (e.shiftKey) return @@ -282,7 +295,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { e.preventDefault() e.stopPropagation() - const value = display(item.absolute, filter()) + const value = displayPath(item.absolute, filter(), home()) list?.setFilter(value.endsWith("/") ? value : value + "/") }} onSelect={(path) => { @@ -291,7 +304,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { }} > {(item) => { - const path = display(item.absolute, filter()) + const path = displayPath(item.absolute, filter(), home()) if (path === "~") { return (
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 8e221577b909..f35d0564ce18 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -36,197 +36,200 @@ type Entry = { type DialogSelectFileMode = "all" | "files" -export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { - const command = useCommand() - const language = useLanguage() - const layout = useLayout() - const file = useFile() - const dialog = useDialog() - const params = useParams() - const navigate = useNavigate() - const globalSDK = useGlobalSDK() - const globalSync = useGlobalSync() - const filesOnly = () => props.mode === "files" - const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) - const tabs = createMemo(() => layout.tabs(sessionKey)) - const view = createMemo(() => layout.view(sessionKey)) - const state = { cleanup: undefined as (() => void) | void, committed: false } - const [grouped, setGrouped] = createSignal(false) - const common = [ - "session.new", - "workspace.new", - "session.previous", - "session.next", - "terminal.toggle", - "review.toggle", - ] - const limit = 5 - - const allowed = createMemo(() => { - if (filesOnly()) return [] - return command.options.filter( - (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", - ) - }) - - const commandItem = (option: CommandOption): Entry => ({ - id: "command:" + option.id, - type: "command", - title: option.title, - description: option.description, - keybind: option.keybind, - category: language.t("palette.group.commands"), - option, - }) - - const fileItem = (path: string): Entry => ({ - id: "file:" + path, - type: "file", - title: path, - category: language.t("palette.group.files"), - path, - }) - - const projectDirectory = createMemo(() => decode64(params.dir) ?? "") - const project = createMemo(() => { - const directory = projectDirectory() - if (!directory) return - return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) - }) - const workspaces = createMemo(() => { - const directory = projectDirectory() - const current = project() - if (!current) return directory ? [directory] : [] - - const dirs = [current.worktree, ...(current.sandboxes ?? [])] - if (directory && !dirs.includes(directory)) return [...dirs, directory] - return dirs - }) - const homedir = createMemo(() => globalSync.data.path.home) - const label = (directory: string) => { - const current = project() - const kind = - current && directory === current.worktree - ? language.t("workspace.type.local") - : language.t("workspace.type.sandbox") - const [store] = globalSync.child(directory, { bootstrap: false }) - const home = homedir() - const path = home ? directory.replace(home, "~") : directory - const name = store.vcs?.branch ?? getFilename(directory) - return `${kind} : ${name || path}` +const ENTRY_LIMIT = 5 +const COMMON_COMMAND_IDS = [ + "session.new", + "workspace.new", + "session.previous", + "session.next", + "terminal.toggle", + "review.toggle", +] as const + +const uniqueEntries = (items: Entry[]) => { + const seen = new Set() + const out: Entry[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + out.push(item) } + return out +} - const sessionItem = (input: { +const createCommandEntry = (option: CommandOption, category: string): Entry => ({ + id: "command:" + option.id, + type: "command", + title: option.title, + description: option.description, + keybind: option.keybind, + category, + option, +}) + +const createFileEntry = (path: string, category: string): Entry => ({ + id: "file:" + path, + type: "file", + title: path, + category, + path, +}) + +const createSessionEntry = ( + input: { directory: string id: string title: string description: string archived?: number updated?: number - }): Entry => ({ - id: `session:${input.directory}:${input.id}`, - type: "session", - title: input.title, - description: input.description, - category: language.t("command.category.session"), - directory: input.directory, - sessionID: input.id, - archived: input.archived, - updated: input.updated, + }, + category: string, +): Entry => ({ + id: `session:${input.directory}:${input.id}`, + type: "session", + title: input.title, + description: input.description, + category, + directory: input.directory, + sessionID: input.id, + archived: input.archived, + updated: input.updated, +}) + +function createCommandEntries(props: { + filesOnly: () => boolean + command: ReturnType + language: ReturnType +}) { + const allowed = createMemo(() => { + if (props.filesOnly()) return [] + return props.command.options.filter( + (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", + ) }) - const list = createMemo(() => allowed().map(commandItem)) + const list = createMemo(() => { + const category = props.language.t("palette.group.commands") + return allowed().map((option) => createCommandEntry(option, category)) + }) const picks = createMemo(() => { const all = allowed() - const order = new Map(common.map((id, index) => [id, index])) + const order = new Map(COMMON_COMMAND_IDS.map((id, index) => [id, index])) const picked = all.filter((option) => order.has(option.id)) - const base = picked.length ? picked : all.slice(0, limit) + const base = picked.length ? picked : all.slice(0, ENTRY_LIMIT) const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base - return sorted.map(commandItem) + const category = props.language.t("palette.group.commands") + return sorted.map((option) => createCommandEntry(option, category)) }) + return { allowed, list, picks } +} + +function createFileEntries(props: { + file: ReturnType + tabs: () => ReturnType["tabs"]> + language: ReturnType +}) { const recent = createMemo(() => { - const all = tabs().all() - const active = tabs().active() + const all = props.tabs().all() + const active = props.tabs().active() const order = active ? [active, ...all.filter((item) => item !== active)] : all const seen = new Set() + const category = props.language.t("palette.group.files") const items: Entry[] = [] for (const item of order) { - const path = file.pathFromTab(item) + const path = props.file.pathFromTab(item) if (!path) continue if (seen.has(path)) continue seen.add(path) - items.push(fileItem(path)) + items.push(createFileEntry(path, category)) } - return items.slice(0, limit) + return items.slice(0, ENTRY_LIMIT) }) const root = createMemo(() => { - const nodes = file.tree.children("") + const category = props.language.t("palette.group.files") + const nodes = props.file.tree.children("") const paths = nodes .filter((node) => node.type === "file") .map((node) => node.path) .sort((a, b) => a.localeCompare(b)) - return paths.slice(0, limit).map(fileItem) + return paths.slice(0, ENTRY_LIMIT).map((path) => createFileEntry(path, category)) }) - const unique = (items: Entry[]) => { - const seen = new Set() - const out: Entry[] = [] - for (const item of items) { - if (seen.has(item.id)) continue - seen.add(item.id) - out.push(item) - } - return out - } + return { recent, root } +} - const sessionToken = { value: 0 } - let sessionInflight: Promise | undefined - let sessionAll: Entry[] | undefined +function createSessionEntries(props: { + workspaces: () => string[] + label: (directory: string) => string + globalSDK: ReturnType + language: ReturnType +}) { + const state: { + token: number + inflight: Promise | undefined + cached: Entry[] | undefined + } = { + token: 0, + inflight: undefined, + cached: undefined, + } const sessions = (text: string) => { const query = text.trim() if (!query) { - sessionToken.value += 1 - sessionInflight = undefined - sessionAll = undefined + state.token += 1 + state.inflight = undefined + state.cached = undefined return [] as Entry[] } - if (sessionAll) return sessionAll - if (sessionInflight) return sessionInflight + if (state.cached) return state.cached + if (state.inflight) return state.inflight - const current = sessionToken.value - const dirs = workspaces() + const current = state.token + const dirs = props.workspaces() if (dirs.length === 0) return [] as Entry[] - sessionInflight = Promise.all( + state.inflight = Promise.all( dirs.map((directory) => { - const description = label(directory) - return globalSDK.client.session + const description = props.label(directory) + return props.globalSDK.client.session .list({ directory, roots: true }) .then((x) => (x.data ?? []) .filter((s) => !!s?.id) .map((s) => ({ id: s.id, - title: s.title ?? language.t("command.session.new"), + title: s.title ?? props.language.t("command.session.new"), description, directory, archived: s.time?.archived, updated: s.time?.updated, })), ) - .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) + .catch( + () => + [] as { + id: string + title: string + description: string + directory: string + archived?: number + updated?: number + }[], + ) }), ) .then((results) => { - if (sessionToken.value !== current) return [] as Entry[] + if (state.token !== current) return [] as Entry[] const seen = new Set() + const category = props.language.t("command.category.session") const next = results .flat() .filter((item) => { @@ -235,18 +238,71 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil seen.add(key) return true }) - .map(sessionItem) - sessionAll = next + .map((item) => createSessionEntry(item, category)) + state.cached = next return next }) .catch(() => [] as Entry[]) .finally(() => { - sessionInflight = undefined + state.inflight = undefined }) - return sessionInflight + return state.inflight } + return { sessions } +} + +export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) { + const command = useCommand() + const language = useLanguage() + const layout = useLayout() + const file = useFile() + const dialog = useDialog() + const params = useParams() + const navigate = useNavigate() + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + const filesOnly = () => props.mode === "files" + const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) + const tabs = createMemo(() => layout.tabs(sessionKey)) + const view = createMemo(() => layout.view(sessionKey)) + const state = { cleanup: undefined as (() => void) | void, committed: false } + const [grouped, setGrouped] = createSignal(false) + const commandEntries = createCommandEntries({ filesOnly, command, language }) + const fileEntries = createFileEntries({ file, tabs, language }) + + const projectDirectory = createMemo(() => decode64(params.dir) ?? "") + const project = createMemo(() => { + const directory = projectDirectory() + if (!directory) return + return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory)) + }) + const workspaces = createMemo(() => { + const directory = projectDirectory() + const current = project() + if (!current) return directory ? [directory] : [] + + const dirs = [current.worktree, ...(current.sandboxes ?? [])] + if (directory && !dirs.includes(directory)) return [...dirs, directory] + return dirs + }) + const homedir = createMemo(() => globalSync.data.path.home) + const label = (directory: string) => { + const current = project() + const kind = + current && directory === current.worktree + ? language.t("workspace.type.local") + : language.t("workspace.type.sandbox") + const [store] = globalSync.child(directory, { bootstrap: false }) + const home = homedir() + const path = home ? directory.replace(home, "~") : directory + const name = store.vcs?.branch ?? getFilename(directory) + return `${kind} : ${name || path}` + } + + const { sessions } = createSessionEntries({ workspaces, label, globalSDK, language }) + const items = async (text: string) => { const query = text.trim() setGrouped(query.length > 0) @@ -254,7 +310,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil if (!query && filesOnly()) { const loaded = file.tree.state("")?.loaded const pending = loaded ? Promise.resolve() : file.tree.list("") - const next = unique([...recent(), ...root()]) + const next = uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) if (loaded || next.length > 0) { void pending @@ -262,19 +318,21 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil } await pending - return unique([...recent(), ...root()]) + return uniqueEntries([...fileEntries.recent(), ...fileEntries.root()]) } - if (!query) return [...picks(), ...recent()] + if (!query) return [...commandEntries.picks(), ...fileEntries.recent()] if (filesOnly()) { const files = await file.searchFiles(query) - return files.map(fileItem) + const category = language.t("palette.group.files") + return files.map((path) => createFileEntry(path, category)) } const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))]) - const entries = files.map(fileItem) - return [...list(), ...nextSessions, ...entries] + const category = language.t("palette.group.files") + const entries = files.map((path) => createFileEntry(path, category)) + return [...commandEntries.list(), ...nextSessions, ...entries] } const handleMove = (item: Entry | undefined) => { diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 8eb088789124..f8913eee4fbc 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -6,6 +6,13 @@ import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" import { useLanguage } from "@/context/language" +const statusLabels = { + connected: "mcp.status.connected", + failed: "mcp.status.failed", + needs_auth: "mcp.status.needs_auth", + disabled: "mcp.status.disabled", +} as const + export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() @@ -21,15 +28,19 @@ export const DialogSelectMcp: Component = () => { const toggle = async (name: string) => { if (loading()) return setLoading(name) - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) + try { + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + } finally { + setLoading(null) } - const result = await sdk.client.mcp.status() - if (result.data) sync.set("mcp", result.data) - setLoading(null) } const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) @@ -54,6 +65,11 @@ export const DialogSelectMcp: Component = () => { {(i) => { const mcpStatus = () => sync.data.mcp[i.name] const status = () => mcpStatus()?.status + const statusLabel = () => { + const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined + if (!key) return + return language.t(key) + } const error = () => { const s = mcpStatus() return s?.status === "failed" ? s.error : undefined @@ -64,17 +80,8 @@ export const DialogSelectMcp: Component = () => {
{i.name} - - {language.t("mcp.status.connected")} - - - {language.t("mcp.status.failed")} - - - {language.t("mcp.status.needs_auth")} - - - {language.t("mcp.status.disabled")} + + {statusLabel()} {language.t("common.loading.ellipsis")} diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 78c169777e0d..af788d05b03c 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -6,7 +6,7 @@ import { List, type ListRef } from "@opencode-ai/ui/list" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tag } from "@opencode-ai/ui/tag" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { type Component, onCleanup, onMount, Show } from "solid-js" +import { type Component, Show } from "solid-js" import { useLocal } from "@/context/local" import { popularProviders, useProviders } from "@/hooks/use-providers" import { DialogConnectProvider } from "./dialog-connect-provider" @@ -21,24 +21,17 @@ export const DialogSelectModelUnpaid: Component = () => { const language = useLanguage() let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") return listRef?.onKeyDown(e) } - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) - }) - }) - return ( -
+
{language.t("dialog.model.unpaid.freeModels.title")}
+ provider === "opencode" && (!cost || cost.input === 0) + const ModelList: Component<{ provider?: string class?: string @@ -54,13 +57,7 @@ const ModelList: Component<{ class="w-full" placement="right-start" gutter={12} - value={ - - } + value={} > {node} @@ -75,7 +72,7 @@ const ModelList: Component<{ {(i) => (
{i.name} - + {language.t("model.tag.free")} @@ -98,13 +95,9 @@ export function ModelSelectorPopover(props: { const [store, setStore] = createStore<{ open: boolean dismiss: "escape" | "outside" | null - trigger?: HTMLElement - content?: HTMLElement }>({ open: false, dismiss: null, - trigger: undefined, - content: undefined, }) const dialog = useDialog() @@ -119,54 +112,6 @@ export function ModelSelectorPopover(props: { } const language = useLanguage() - createEffect(() => { - if (!store.open) return - - const inside = (node: Node | null | undefined) => { - if (!node) return false - const el = store.content - if (el && el.contains(node)) return true - const anchor = store.trigger - if (anchor && anchor.contains(node)) return true - return false - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return - setStore("dismiss", "escape") - setStore("open", false) - event.preventDefault() - event.stopPropagation() - } - - const onPointerDown = (event: PointerEvent) => { - const target = event.target - if (!(target instanceof Node)) return - if (inside(target)) return - setStore("dismiss", "outside") - setStore("open", false) - } - - const onFocusIn = (event: FocusEvent) => { - if (!store.content) return - const target = event.target - if (!(target instanceof Node)) return - if (inside(target)) return - setStore("dismiss", "outside") - setStore("open", false) - } - - window.addEventListener("keydown", onKeyDown, true) - window.addEventListener("pointerdown", onPointerDown, true) - window.addEventListener("focusin", onFocusIn, true) - - onCleanup(() => { - window.removeEventListener("keydown", onKeyDown, true) - window.removeEventListener("pointerdown", onPointerDown, true) - window.removeEventListener("focusin", onFocusIn, true) - }) - }) - return ( - setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}> + {props.children} setStore("content", el)} class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden" onEscapeKeyDown={(event) => { setStore("dismiss", "escape") diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index f878e50e81a6..8bbd3054b9a2 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -24,6 +24,12 @@ export const DialogSelectProvider: Component = () => { const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") + const customLabel = () => language.t("settings.providers.tag.custom") + const note = (id: string) => { + if (id === "anthropic") return language.t("dialog.provider.anthropic.note") + if (id === "openai") return language.t("dialog.provider.openai.note") + if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note") + } return ( @@ -34,7 +40,7 @@ export const DialogSelectProvider: Component = () => { key={(x) => x?.id} items={() => { language.locale() - return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()] + return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all()] }} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())} @@ -70,15 +76,7 @@ export const DialogSelectProvider: Component = () => { {language.t("dialog.provider.tag.recommended")} - -
{language.t("dialog.provider.anthropic.note")}
-
- -
{language.t("dialog.provider.openai.note")}
-
- -
{language.t("dialog.provider.copilot.note")}
-
+ {(value) =>
{value()}
}
)}
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 65b679f70a11..4c37806365a2 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -38,6 +38,64 @@ interface EditRowProps { onBlur: () => void } +function showRequestError(language: ReturnType, err: unknown) { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) +} + +function useDefaultServer(platform: ReturnType, language: ReturnType) { + const [defaultUrl, defaultUrlActions] = createResource( + async () => { + try { + const url = await platform.getDefaultServerUrl?.() + if (!url) return null + return normalizeServerUrl(url) ?? null + } catch (err) { + showRequestError(language, err) + return null + } + }, + { initialValue: null }, + ) + + const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) + const setDefault = async (url: string | null) => { + try { + await platform.setDefaultServerUrl?.(url) + defaultUrlActions.mutate(url) + } catch (err) { + showRequestError(language, err) + } + } + + return { defaultUrl, canDefault, setDefault } +} + +function useServerPreview(fetcher: typeof fetch) { + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const result = await checkServerHealth(normalized, fetcher) + setStatus(result.healthy) + } + + return { previewStatus } +} + function AddRow(props: AddRowProps) { return (
@@ -115,6 +173,10 @@ export function DialogSelectServer() { const platform = usePlatform() const globalSDK = useGlobalSDK() const language = useLanguage() + const fetcher = platform.fetch ?? globalThis.fetch + const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) + const { previewStatus } = useServerPreview(fetcher) + let listRoot: HTMLDivElement | undefined const [store, setStore] = createStore({ status: {} as Record, addServer: { @@ -132,43 +194,6 @@ export function DialogSelectServer() { status: undefined as boolean | undefined, }, }) - const [defaultUrl, defaultUrlActions] = createResource( - async () => { - try { - const url = await platform.getDefaultServerUrl?.() - if (!url) return null - return normalizeServerUrl(url) ?? null - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - return null - } - }, - { initialValue: null }, - ) - const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl) - const fetcher = platform.fetch ?? globalThis.fetch - - const looksComplete = (value: string) => { - const normalized = normalizeServerUrl(value) - if (!normalized) return false - const host = normalized.replace(/^https?:\/\//, "").split("/")[0] - if (!host) return false - if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true - return host.includes(".") || host.includes(":") - } - - const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { - setStatus(undefined) - if (!looksComplete(value)) return - const normalized = normalizeServerUrl(value) - if (!normalized) return - const result = await checkServerHealth(normalized, fetcher) - setStatus(result.healthy) - } const resetAdd = () => { setStore("addServer", { @@ -263,7 +288,7 @@ export function DialogSelectServer() { } const scrollListToBottom = () => { - const scroll = document.querySelector('[data-component="list"] [data-slot="list-scroll"]') + const scroll = listRoot?.querySelector('[data-slot="list-scroll"]') if (!scroll) return requestAnimationFrame(() => { scroll.scrollTop = scroll.scrollHeight @@ -363,158 +388,134 @@ export function DialogSelectServer() { return (
- x} - onSelect={(x) => { - if (x) select(x) - }} - onFilter={(value) => { - if (value && store.addServer.showForm && !store.addServer.adding) { - resetAdd() +
(listRoot = el)}> + x} + onSelect={(x) => { + if (x) select(x) + }} + onFilter={(value) => { + if (value && store.addServer.showForm && !store.addServer.adding) { + resetAdd() + } + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0" + add={ + store.addServer.showForm + ? { + render: () => ( + + ), + } + : undefined } - }} - divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent [&_[data-slot=list-item-add]]:px-0" - add={ - store.addServer.showForm - ? { - render: () => ( - - ), - } - : undefined - } - > - {(i) => { - return ( -
- handleEditKey(event, i)} - onBlur={() => handleEdit(i, store.editServer.value)} + > + {(i) => { + return ( +
+ handleEditKey(event, i)} + onBlur={() => handleEdit(i, store.editServer.value)} + /> + } + > + + + {language.t("dialog.server.status.default")} + + + } /> - } - > - - - {language.t("dialog.server.status.default")} - + + +
+ +

{language.t("dialog.server.current")}

- } - /> - - -
- -

{language.t("dialog.server.current")}

-
- - - e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - - - { - setStore("editServer", { - id: i, - value: i, - error: "", - status: store.status[i]?.healthy, - }) - }} - > - {language.t("dialog.server.menu.edit")} - - + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + { - try { - await platform.setDefaultServerUrl?.(i) - defaultUrlActions.mutate(i) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } + onSelect={() => { + setStore("editServer", { + id: i, + value: i, + error: "", + status: store.status[i]?.healthy, + }) }} > - - {language.t("dialog.server.menu.default")} - + {language.t("dialog.server.menu.edit")} - - + + setDefault(i)}> + + {language.t("dialog.server.menu.default")} + + + + + setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + + { - try { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.mutate(null) - } catch (err) { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) - } - }} + onSelect={() => handleRemove(i)} + class="text-text-on-critical-base hover:bg-surface-critical-weak" > - - {language.t("dialog.server.menu.defaultRemove")} - + {language.t("dialog.server.menu.delete")} - - - handleRemove(i)} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - {language.t("dialog.server.menu.delete")} - - - - -
-
-
- ) - }} - + + + +
+
+
+ ) + }} +
+
) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d7b7299731c8..5552cc90b8e4 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -15,6 +15,7 @@ import { Switch, untrack, type ComponentProps, + type JSXElement, type ParentProps, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -59,6 +60,189 @@ export function dirsToExpand(input: { return [...input.filter.dirs].filter((dir) => !input.expanded(dir)) } +const kindLabel = (kind: Kind) => { + if (kind === "add") return "A" + if (kind === "del") return "D" + return "M" +} + +const kindTextColor = (kind: Kind) => { + if (kind === "add") return "color: var(--icon-diff-add-base)" + if (kind === "del") return "color: var(--icon-diff-delete-base)" + return "color: var(--icon-warning-active)" +} + +const kindDotColor = (kind: Kind) => { + if (kind === "add") return "background-color: var(--icon-diff-add-base)" + if (kind === "del") return "background-color: var(--icon-diff-delete-base)" + return "background-color: var(--icon-warning-active)" +} + +const visibleKind = (node: FileNode, kinds?: ReadonlyMap, marks?: Set) => { + const kind = kinds?.get(node.path) + if (!kind) return + if (!marks?.has(node.path)) return + return kind +} + +const buildDragImage = (target: HTMLElement) => { + const icon = target.querySelector('[data-component="file-icon"]') ?? target.querySelector("svg") + const text = target.querySelector("span") + if (!icon || !text) return + + const image = document.createElement("div") + image.className = + "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" + image.style.position = "absolute" + image.style.top = "-1000px" + image.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML + return image +} + +const withFileDragImage = (event: DragEvent) => { + const image = buildDragImage(event.currentTarget as HTMLElement) + if (!image) return + document.body.appendChild(image) + event.dataTransfer?.setDragImage(image, 0, 12) + setTimeout(() => document.body.removeChild(image), 0) +} + +const FileTreeNode = ( + p: ParentProps & + ComponentProps<"div"> & + ComponentProps<"button"> & { + node: FileNode + level: number + active?: string + nodeClass?: string + draggable: boolean + kinds?: ReadonlyMap + marks?: Set + as?: "div" | "button" + }, +) => { + const [local, rest] = splitProps(p, [ + "node", + "level", + "active", + "nodeClass", + "draggable", + "kinds", + "marks", + "as", + "children", + "class", + "classList", + ]) + const kind = () => visibleKind(local.node, local.kinds, local.marks) + const active = () => !!kind() && !local.node.ignored + const color = () => { + const value = kind() + if (!value) return + return kindTextColor(value) + } + + return ( + { + if (!local.draggable) return + event.dataTransfer?.setData("text/plain", `file:${local.node.path}`) + event.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy" + withFileDragImage(event) + }} + {...rest} + > + {local.children} + + {local.node.name} + + {(() => { + const value = kind() + if (!value) return null + if (local.node.type === "file") { + return ( + + {kindLabel(value)} + + ) + } + return
+ })()} + + ) +} + +const FileTreeNodeTooltip = (props: { enabled: boolean; node: FileNode; kind?: Kind; children: JSXElement }) => { + if (!props.enabled) return props.children + + const parts = props.node.path.split("/") + const leaf = parts[parts.length - 1] ?? props.node.path + const head = parts.slice(0, -1).join("/") + const prefix = head ? `${head}/` : "" + const label = + props.kind === "add" + ? "Additions" + : props.kind === "del" + ? "Deletions" + : props.kind === "mix" + ? "Modifications" + : undefined + + return ( + + + {prefix} + + {leaf} + + {(text) => ( + <> + + {text()} + + )} + + + <> + + Ignored + + +
+ } + > + {props.children} + + ) +} + export default function FileTree(props: { path: string class?: string @@ -230,178 +414,13 @@ export default function FileTree(props: { return out }) - const Node = ( - p: ParentProps & - ComponentProps<"div"> & - ComponentProps<"button"> & { - node: FileNode - as?: "div" | "button" - }, - ) => { - const [local, rest] = splitProps(p, ["node", "as", "children", "class", "classList"]) - return ( - { - if (!draggable()) return - e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) - e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path)) - if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" - - const dragImage = document.createElement("div") - dragImage.className = - "flex items-center gap-x-2 px-2 py-1 bg-surface-raised-base rounded-md border border-border-base text-12-regular text-text-strong" - dragImage.style.position = "absolute" - dragImage.style.top = "-1000px" - - const icon = - (e.currentTarget as HTMLElement).querySelector('[data-component="file-icon"]') ?? - (e.currentTarget as HTMLElement).querySelector("svg") - const text = (e.currentTarget as HTMLElement).querySelector("span") - if (icon && text) { - dragImage.innerHTML = (icon as SVGElement).outerHTML + (text as HTMLSpanElement).outerHTML - } - - document.body.appendChild(dragImage) - e.dataTransfer?.setDragImage(dragImage, 0, 12) - setTimeout(() => document.body.removeChild(dragImage), 0) - }} - {...rest} - > - {local.children} - {(() => { - const kind = kinds()?.get(local.node.path) - const marked = marks()?.has(local.node.path) ?? false - const active = !!kind && marked && !local.node.ignored - const color = - kind === "add" - ? "color: var(--icon-diff-add-base)" - : kind === "del" - ? "color: var(--icon-diff-delete-base)" - : kind === "mix" - ? "color: var(--icon-warning-active)" - : undefined - return ( - - {local.node.name} - - ) - })()} - {(() => { - const kind = kinds()?.get(local.node.path) - if (!kind) return null - if (!marks()?.has(local.node.path)) return null - - if (local.node.type === "file") { - const text = kind === "add" ? "A" : kind === "del" ? "D" : "M" - const color = - kind === "add" - ? "color: var(--icon-diff-add-base)" - : kind === "del" - ? "color: var(--icon-diff-delete-base)" - : "color: var(--icon-warning-active)" - - return ( - - {text} - - ) - } - - if (local.node.type === "directory") { - const color = - kind === "add" - ? "background-color: var(--icon-diff-add-base)" - : kind === "del" - ? "background-color: var(--icon-diff-delete-base)" - : "background-color: var(--icon-warning-active)" - - return
- } - - return null - })()} - - ) - } - return (
{(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false const deep = () => deeps().get(node.path) ?? -1 - const Wrapper = (p: ParentProps) => { - if (!tooltip()) return p.children - - const parts = node.path.split("/") - const leaf = parts[parts.length - 1] ?? node.path - const head = parts.slice(0, -1).join("/") - const prefix = head ? `${head}/` : "" - - const kind = () => kinds()?.get(node.path) - const label = () => { - const k = kind() - if (!k) return - if (k === "add") return "Additions" - if (k === "del") return "Deletions" - return "Modifications" - } - - const ignored = () => node.type === "directory" && node.ignored - - return ( - - - {prefix} - - {leaf} - - {(t: () => string) => ( - <> - - {t()} - - )} - - - <> - - Ignored - - -
- } - > - {p.children} - - ) - } + const kind = () => visibleKind(node, kinds(), marks()) return ( @@ -415,13 +434,21 @@ export default function FileTree(props: { onOpenChange={(open) => (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} > - - + +
-
-
+ +
- - props.onFileClick?.(node)}> + + props.onFileClick?.(node)} + >
- - + + ) diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx index e13c31330480..85f7efc539eb 100644 --- a/packages/app/src/components/link.tsx +++ b/packages/app/src/components/link.tsx @@ -1,17 +1,26 @@ import { ComponentProps, splitProps } from "solid-js" import { usePlatform } from "@/context/platform" -export interface LinkProps extends ComponentProps<"button"> { +export interface LinkProps extends Omit, "href"> { href: string } export function Link(props: LinkProps) { const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children"]) + const [local, rest] = splitProps(props, ["href", "children", "class"]) return ( - + ) } diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 4f495d27d135..d591b22c716b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -277,6 +277,47 @@ export const PromptInput: Component = (props) => { const isFocused = createFocusSignal(() => editorRef) + const closePopover = () => setStore("popover", null) + + const resetHistoryNavigation = (force = false) => { + if (!force && (store.historyIndex < 0 || store.applyingHistory)) return + setStore("historyIndex", -1) + setStore("savedPrompt", null) + } + + const clearEditor = () => { + editorRef.innerHTML = "" + } + + const setEditorText = (text: string) => { + clearEditor() + editorRef.textContent = text + } + + const focusEditorEnd = () => { + requestAnimationFrame(() => { + editorRef.focus() + const range = document.createRange() + const selection = window.getSelection() + range.selectNodeContents(editorRef) + range.collapse(false) + selection?.removeAllRanges() + selection?.addRange(range) + }) + } + + const currentCursor = () => { + const selection = window.getSelection() + if (!selection || selection.rangeCount === 0 || !editorRef.contains(selection.anchorNode)) return null + return getCursorPosition(editorRef) + } + + const renderEditorWithCursor = (parts: Prompt) => { + const cursor = currentCursor() + renderEditor(parts) + if (cursor !== null) setCursorPosition(editorRef, cursor) + } + createEffect(() => { params.id if (params.id) return @@ -290,7 +331,7 @@ export const PromptInput: Component = (props) => { const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229 createEffect(() => { - if (!isFocused()) setStore("popover", null) + if (!isFocused()) closePopover() }) // Safety: reset composing state on focus change to prevent stuck state @@ -381,26 +422,17 @@ export const PromptInput: Component = (props) => { const handleSlashSelect = (cmd: SlashCommand | undefined) => { if (!cmd) return - setStore("popover", null) + closePopover() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` - editorRef.innerHTML = "" - editorRef.textContent = text + setEditorText(text) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - requestAnimationFrame(() => { - editorRef.focus() - const range = document.createRange() - const sel = window.getSelection() - range.selectNodeContents(editorRef) - range.collapse(false) - sel?.removeAllRanges() - sel?.addRange(range) - }) + focusEditorEnd() return } - editorRef.innerHTML = "" + clearEditor() prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) command.trigger(cmd.id, "slash") } @@ -454,7 +486,7 @@ export const PromptInput: Component = (props) => { }) const renderEditor = (parts: Prompt) => { - editorRef.innerHTML = "" + clearEditor() for (const part of parts) { if (part.type === "text") { editorRef.appendChild(createTextFragment(part.content)) @@ -514,34 +546,14 @@ export const PromptInput: Component = (props) => { mirror.input = false if (isNormalizedEditor()) return - const selection = window.getSelection() - let cursorPosition: number | null = null - if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { - cursorPosition = getCursorPosition(editorRef) - } - - renderEditor(inputParts) - - if (cursorPosition !== null) { - setCursorPosition(editorRef, cursorPosition) - } + renderEditorWithCursor(inputParts) return } const domParts = parseFromDOM() if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return - const selection = window.getSelection() - let cursorPosition: number | null = null - if (selection && selection.rangeCount > 0 && editorRef.contains(selection.anchorNode)) { - cursorPosition = getCursorPosition(editorRef) - } - - renderEditor(inputParts) - - if (cursorPosition !== null) { - setCursorPosition(editorRef, cursorPosition) - } + renderEditorWithCursor(inputParts) }, ), ) @@ -636,11 +648,8 @@ export const PromptInput: Component = (props) => { const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0 if (shouldReset) { - setStore("popover", null) - if (store.historyIndex >= 0 && !store.applyingHistory) { - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } + closePopover() + resetHistoryNavigation() if (prompt.dirty()) { mirror.input = true prompt.set(DEFAULT_PROMPT, 0) @@ -662,16 +671,13 @@ export const PromptInput: Component = (props) => { slashOnInput(slashMatch[1]) setStore("popover", "slash") } else { - setStore("popover", null) + closePopover() } } else { - setStore("popover", null) + closePopover() } - if (store.historyIndex >= 0 && !store.applyingHistory) { - setStore("historyIndex", -1) - setStore("savedPrompt", null) - } + resetHistoryNavigation() mirror.input = true prompt.set([...rawParts, ...images], cursorPosition) @@ -732,7 +738,7 @@ export const PromptInput: Component = (props) => { } handleInput() - setStore("popover", null) + closePopover() } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -782,8 +788,7 @@ export const PromptInput: Component = (props) => { promptLength, addToHistory, resetHistoryNavigation: () => { - setStore("historyIndex", -1) - setStore("savedPrompt", null) + resetHistoryNavigation(true) }, setMode: (mode) => setStore("mode", mode), setPopover: (popover) => setStore("popover", popover), @@ -872,7 +877,7 @@ export const PromptInput: Component = (props) => { if (ctrl && event.code === "KeyG") { if (store.popover) { - setStore("popover", null) + closePopover() event.preventDefault() return } @@ -923,7 +928,7 @@ export const PromptInput: Component = (props) => { } if (event.key === "Escape") { if (store.popover) { - setStore("popover", null) + closePopover() } else if (working()) { abort() } diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx index a843e109d820..b575c3961110 100644 --- a/packages/app/src/components/prompt-input/context-items.tsx +++ b/packages/app/src/components/prompt-input/context-items.tsx @@ -20,61 +20,68 @@ export const PromptContextItems: Component = (props) => { 0}>
- {(item) => ( - - - {getDirectory(item.path)} + {(item) => { + const directory = getDirectory(item.path) + const filename = getFilename(item.path) + const label = getFilenameTruncated(item.path, 14) + const selected = props.active(item) + + return ( + + + {directory} + + {filename} - {getFilename(item.path)} - - } - placement="top" - openDelay={2000} - > -
props.openComment(item)} + } + placement="top" + openDelay={2000} > -
- -
- {getFilenameTruncated(item.path, 14)} - - {(sel) => ( - - {sel().startLine === sel().endLine - ? `:${sel().startLine}` - : `:${sel().startLine}-${sel().endLine}`} - - )} - +
props.openComment(item)} + > +
+ +
+ {label} + + {(sel) => ( + + {sel().startLine === sel().endLine + ? `:${sel().startLine}` + : `:${sel().startLine}-${sel().endLine}`} + + )} + +
+ { + e.stopPropagation() + props.remove(item) + }} + aria-label={props.t("prompt.context.removeFile")} + />
- { - e.stopPropagation() - props.remove(item) - }} - aria-label={props.t("prompt.context.removeFile")} - /> + + {(comment) =>
{comment()}
} +
- - {(comment) =>
{comment()}
} -
-
- - )} + + ) + }}
diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx index e05b47d7cf18..41962ce536e3 100644 --- a/packages/app/src/components/prompt-input/drag-overlay.tsx +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -6,12 +6,17 @@ type PromptDragOverlayProps = { label: string } +const kindToIcon = { + image: "photo", + "@mention": "link", +} as const + export const PromptDragOverlay: Component = (props) => { return (
- + {props.label}
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx index ba3addf0a162..835fddc30710 100644 --- a/packages/app/src/components/prompt-input/image-attachments.tsx +++ b/packages/app/src/components/prompt-input/image-attachments.tsx @@ -9,6 +9,13 @@ type PromptImageAttachmentsProps = { removeLabel: string } +const fallbackClass = "size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base" +const imageClass = + "size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors" +const removeClass = + "absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover" +const nameClass = "absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md" + export const PromptImageAttachments: Component = (props) => { return ( 0}> @@ -19,7 +26,7 @@ export const PromptImageAttachments: Component = (p +
} @@ -27,19 +34,19 @@ export const PromptImageAttachments: Component = (p {attachment.filename} props.onOpen(attachment)} />
-
+
{attachment.filename}
diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx index b97bb675223b..554a15bb7808 100644 --- a/packages/app/src/components/prompt-input/slash-popover.tsx +++ b/packages/app/src/components/prompt-input/slash-popover.tsx @@ -52,47 +52,46 @@ export const PromptPopover: Component = (props) => { fallback={
{props.t("prompt.popover.emptyResults")}
} > - {(item) => ( - + ) + } + + const isDirectory = item.path.endsWith("/") + const directory = isDirectory ? item.path : getDirectory(item.path) + const filename = isDirectory ? "" : getFilename(item.path) + + return ( + - )} + +
+ {directory} + + {filename} + +
+ + ) + }}
diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx index f626fcc9b27f..1ab184535d4c 100644 --- a/packages/app/src/components/question-dock.tsx +++ b/packages/app/src/components/question-dock.tsx @@ -7,6 +7,32 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" import { useSDK } from "@/context/sdk" +const writeAt = (list: T[], index: number, value: T) => { + const next = [...list] + next[index] = value + return next +} + +const pickAnswer = (list: QuestionAnswer[], index: number, value: string) => { + return writeAt(list, index, [value]) +} + +const toggleAnswer = (list: QuestionAnswer[], index: number, value: string) => { + const current = list[index] ?? [] + const next = current.includes(value) ? current.filter((item) => item !== value) : [...current, value] + return writeAt(list, index, next) +} + +const appendAnswer = (list: QuestionAnswer[], index: number, value: string) => { + const current = list[index] ?? [] + if (current.includes(value)) return list + return writeAt(list, index, [...current, value]) +} + +const writeCustom = (list: string[], index: number, value: string) => { + return writeAt(list, index, value) +} + export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => { const sdk = useSDK() const language = useLanguage() @@ -38,43 +64,45 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => showToast({ title: language.t("common.requestFailed"), description: message }) } - const reply = (answers: QuestionAnswer[]) => { + const reply = async (answers: QuestionAnswer[]) => { if (store.sending) return setStore("sending", true) - sdk.client.question - .reply({ requestID: props.request.id, answers }) - .catch(fail) - .finally(() => setStore("sending", false)) + try { + await sdk.client.question.reply({ requestID: props.request.id, answers }) + } catch (err) { + fail(err) + } finally { + setStore("sending", false) + } } - const reject = () => { + const reject = async () => { if (store.sending) return setStore("sending", true) - sdk.client.question - .reject({ requestID: props.request.id }) - .catch(fail) - .finally(() => setStore("sending", false)) + try { + await sdk.client.question.reject({ requestID: props.request.id }) + } catch (err) { + fail(err) + } finally { + setStore("sending", false) + } } const submit = () => { - reply(questions().map((_, i) => store.answers[i] ?? [])) + void reply(questions().map((_, i) => store.answers[i] ?? [])) } const pick = (answer: string, custom: boolean = false) => { - const answers = [...store.answers] - answers[store.tab] = [answer] - setStore("answers", answers) + setStore("answers", pickAnswer(store.answers, store.tab, answer)) if (custom) { - const inputs = [...store.custom] - inputs[store.tab] = answer - setStore("custom", inputs) + setStore("custom", writeCustom(store.custom, store.tab, answer)) } if (single()) { - reply([[answer]]) + void reply([[answer]]) return } @@ -82,15 +110,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } const toggle = (answer: string) => { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - const index = next.indexOf(answer) - if (index === -1) next.push(answer) - if (index !== -1) next.splice(index, 1) - - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + setStore("answers", toggleAnswer(store.answers, store.tab, answer)) } const selectTab = (index: number) => { @@ -126,13 +146,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => } if (multi()) { - const existing = store.answers[store.tab] ?? [] - const next = [...existing] - if (!next.includes(value)) next.push(value) - - const answers = [...store.answers] - answers[store.tab] = next - setStore("answers", answers) + setStore("answers", appendAnswer(store.answers, store.tab, value)) setStore("editing", false) return } @@ -225,9 +239,7 @@ export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => value={input()} disabled={store.sending} onInput={(e) => { - const inputs = [...store.custom] - inputs[store.tab] = e.currentTarget.value - setStore("custom", inputs) + setStore("custom", writeCustom(store.custom, store.tab, e.currentTarget.value)) }} /> @@ -467,7 +489,7 @@ export function SessionHeader() { >
- {state.unshare + {share.state.unshare ? language.t("session.share.action.unpublishing") : language.t("session.share.action.unpublish")} @@ -490,8 +512,8 @@ export function SessionHeader() { size="large" variant="primary" class="w-full" - onClick={viewShare} - disabled={state.unshare} + onClick={share.viewShare} + disabled={share.state.unshare} > {language.t("session.share.action.view")} @@ -500,10 +522,10 @@ export function SessionHeader() {
-