From e6d8315e29a8b7a34fd6639393e4dcc12bf9fa6c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 17:54:57 +0800 Subject: [PATCH 001/140] fix(desktop): throttle window state persistence (#11746) --- packages/desktop/src-tauri/src/lib.rs | 45 ++++++++++++++++--- .../src-tauri/src/window_customizer.rs | 4 +- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 2fe7c4aa176..a177e6ceedd 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -23,13 +23,18 @@ use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult}; use tauri_plugin_shell::process::{CommandChild, CommandEvent}; use tauri_plugin_store::StoreExt; -use tokio::sync::oneshot; +use tauri_plugin_window_state::{AppHandleExt, StateFlags}; +use tokio::sync::{mpsc, oneshot}; use crate::window_customizer::PinchZoomDisablePlugin; const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; +fn window_state_flags() -> StateFlags { + StateFlags::all() - StateFlags::DECORATIONS +} + #[derive(Clone, serde::Serialize, specta::Type)] struct ServerReadyData { url: String, @@ -293,10 +298,7 @@ pub fn run() { .plugin(tauri_plugin_os::init()) .plugin( tauri_plugin_window_state::Builder::new() - .with_state_flags( - tauri_plugin_window_state::StateFlags::all() - - tauri_plugin_window_state::StateFlags::DECORATIONS, - ) + .with_state_flags(window_state_flags()) .build(), ) .plugin(tauri_plugin_store::Builder::new().build()) @@ -365,6 +367,8 @@ pub fn run() { let window = window_builder.build().expect("Failed to create window"); + setup_window_state_listener(&app, &window); + #[cfg(windows)] let _ = window.create_overlay_titlebar(); @@ -560,3 +564,34 @@ async fn spawn_local_server( } } } + +fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) { + let (tx, mut rx) = mpsc::channel::<()>(1); + + window.on_window_event(move |event| { + if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) { + return; + } + let _ = tx.try_send(()); + }); + + tauri::async_runtime::spawn({ + let app = app.clone(); + + async move { + let save = || { + let handle = app.clone(); + let app = app.clone(); + let _ = handle.run_on_main_thread(move || { + let _ = app.save_window_state(window_state_flags()); + }); + }; + + while rx.recv().await.is_some() { + tokio::time::sleep(Duration::from_millis(200)).await; + + save(); + } + } + }); +} diff --git a/packages/desktop/src-tauri/src/window_customizer.rs b/packages/desktop/src-tauri/src/window_customizer.rs index 682f57f2471..d73662120ac 100644 --- a/packages/desktop/src-tauri/src/window_customizer.rs +++ b/packages/desktop/src-tauri/src/window_customizer.rs @@ -1,4 +1,4 @@ -use tauri::{plugin::Plugin, Manager, Runtime, Window}; +use tauri::{Manager, Runtime, Window, plugin::Plugin}; pub struct PinchZoomDisablePlugin; @@ -21,8 +21,8 @@ impl Plugin for PinchZoomDisablePlugin { let _ = webview_window.with_webview(|_webview| { #[cfg(target_os = "linux")] unsafe { - use gtk::glib::ObjectExt; use gtk::GestureZoom; + use gtk::glib::ObjectExt; use webkit2gtk::glib::gobject_ffi; if let Some(data) = _webview.inner().data::("wk-view-zoom-gesture") { From 1832eeffc97430edea9ab62818153bfedc6aea17 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 18:19:49 +0800 Subject: [PATCH 002/140] fix(desktop): remove unnecessary setTimeout --- packages/desktop/src/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index bb3265db88e..9ef680ed869 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -391,11 +391,7 @@ 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.ensureServerReady().then((v) => { - return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000)) - }), - ) + const [serverData] = createResource(() => commands.ensureServerReady()) const errorMessage = () => { const error = serverData.error From 9564c1d6be6a7d83abb6dd665b34a6572518fbab Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 18:52:57 +0800 Subject: [PATCH 003/140] desktop: fix rust build + bindings formatting --- .prettierignore | 3 ++- packages/desktop/src-tauri/src/lib.rs | 1 + packages/desktop/src/bindings.ts | 23 ++++++++++++----------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/.prettierignore b/.prettierignore index aa3a7ce2381..5f86f710fbf 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ -sst-env.d.ts \ No newline at end of file +sst-env.d.ts +desktop/src/bindings.ts diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index a177e6ceedd..d309b918b66 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -569,6 +569,7 @@ fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWi let (tx, mut rx) = mpsc::channel::<()>(1); window.on_window_event(move |event| { + use tauri::WindowEvent; if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) { return; } diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 440e138b4f9..eb5498fa68a 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,19 +1,20 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" +import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -} + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; /* Types */ export type ServerReadyData = { - url: string - password: string | null -} + url: string, + password: string | null, + }; + From 1cabeb00d0a391cf83495bf4e3544aa53f155ef4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 10:53:46 +0000 Subject: [PATCH 004/140] chore: generate --- packages/desktop/src/bindings.ts | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index eb5498fa68a..440e138b4f9 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,20 +1,19 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from '@tauri-apps/api/core'; +import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -}; + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +} /* Types */ export type ServerReadyData = { - url: string, - password: string | null, - }; - + url: string + password: string | null +} From 52eb8a7a8c2ceefa0de8a1a37d5f8754f08cfcff Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Mon, 2 Feb 2026 19:05:47 +0800 Subject: [PATCH 005/140] feat(app): unread session navigation keybinds (#11750) --- packages/app/src/i18n/ar.ts | 2 ++ packages/app/src/i18n/br.ts | 2 ++ packages/app/src/i18n/da.ts | 2 ++ packages/app/src/i18n/de.ts | 2 ++ packages/app/src/i18n/en.ts | 2 ++ packages/app/src/i18n/es.ts | 2 ++ packages/app/src/i18n/fr.ts | 2 ++ packages/app/src/i18n/ja.ts | 2 ++ packages/app/src/i18n/ko.ts | 2 ++ packages/app/src/i18n/no.ts | 2 ++ packages/app/src/i18n/pl.ts | 2 ++ packages/app/src/i18n/ru.ts | 2 ++ packages/app/src/i18n/th.ts | 2 ++ packages/app/src/i18n/zh.ts | 2 ++ packages/app/src/i18n/zht.ts | 2 ++ packages/app/src/pages/layout.tsx | 60 +++++++++++++++++++++++++++++++ 16 files changed, 90 insertions(+) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index e3831e23c40..3718303e5a1 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "فتح الإعدادات", "command.session.previous": "الجلسة السابقة", "command.session.next": "الجلسة التالية", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "أرشفة الجلسة", "command.palette": "لوحة الأوامر", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index f930a66aff3..43336f8d6fe 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir configurações", "command.session.previous": "Sessão anterior", "command.session.next": "Próxima sessão", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arquivar sessão", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 2b7d77456d5..69e8e8114f6 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Åbn indstillinger", "command.session.previous": "Forrige session", "command.session.next": "Næste session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkivér session", "command.palette": "Kommandopalette", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 4648ad9c412..1c28e4a16e4 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "Einstellungen öffnen", "command.session.previous": "Vorherige Sitzung", "command.session.next": "Nächste Sitzung", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Sitzung archivieren", "command.palette": "Befehlspalette", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 12ddcb4cd8e..5589337e563 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Open settings", "command.session.previous": "Previous session", "command.session.next": "Next session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archive session", "command.palette": "Command palette", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 5d396f0b4f2..6e3eac0dd35 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archivar sesión", "command.palette": "Paleta de comandos", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 4226d0c7e20..fa3dccd9afa 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Ouvrir les paramètres", "command.session.previous": "Session précédente", "command.session.next": "Session suivante", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Archiver la session", "command.palette": "Palette de commandes", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 28a925a0d32..4fccbd77e78 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "設定を開く", "command.session.previous": "前のセッション", "command.session.next": "次のセッション", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "セッションをアーカイブ", "command.palette": "コマンドパレット", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 1be4e1eb4b6..5b5d29c0e08 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "설정 열기", "command.session.previous": "이전 세션", "command.session.next": "다음 세션", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "세션 보관", "command.palette": "명령 팔레트", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0a3b398856a..89614ce853d 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -31,6 +31,8 @@ export const dict = { "command.settings.open": "Åpne innstillinger", "command.session.previous": "Forrige sesjon", "command.session.next": "Neste sesjon", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Arkiver sesjon", "command.palette": "Kommandopalett", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f4457c6acf8..b89921a9bc6 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Otwórz ustawienia", "command.session.previous": "Poprzednia sesja", "command.session.next": "Następna sesja", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Zarchiwizuj sesję", "command.palette": "Paleta poleceń", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d5a4014d36a..e99abbd0819 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "Открыть настройки", "command.session.previous": "Предыдущая сессия", "command.session.next": "Следующая сессия", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "Архивировать сессию", "command.palette": "Палитра команд", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 1914b8e5bdf..0da6f9acc75 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -28,6 +28,8 @@ export const dict = { "command.settings.open": "เปิดการตั้งค่า", "command.session.previous": "เซสชันก่อนหน้า", "command.session.next": "เซสชันถัดไป", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "จัดเก็บเซสชัน", "command.palette": "คำสั่งค้นหา", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index b9d53957302..a7e1659ec3e 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "打开设置", "command.session.previous": "上一个会话", "command.session.next": "下一个会话", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "归档会话", "command.palette": "命令面板", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 23d3d80e136..7b8849b9a0c 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -32,6 +32,8 @@ export const dict = { "command.settings.open": "開啟設定", "command.session.previous": "上一個工作階段", "command.session.next": "下一個工作階段", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.session.archive": "封存工作階段", "command.palette": "命令面板", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 845a4fc834d..a970bf667e4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -886,6 +886,52 @@ export default function Layout(props: ParentProps) { queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) } + function navigateSessionByUnseen(offset: number) { + const sessions = currentSessions() + if (sessions.length === 0) return + + const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + if (!hasUnseen) return + + const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 + const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex + + for (let i = 1; i <= sessions.length; i++) { + const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length + const session = sessions[index] + if (!session) continue + if (notification.session.unseen(session.id).length === 0) continue + + prefetchSession(session, "high") + + const next = sessions[(index + 1) % sessions.length] + const prev = sessions[(index - 1 + sessions.length) % sessions.length] + + if (offset > 0) { + if (next) prefetchSession(next, "high") + if (prev) prefetchSession(prev) + } + + if (offset < 0) { + if (prev) prefetchSession(prev, "high") + if (next) prefetchSession(next) + } + + if (import.meta.env.DEV) { + navStart({ + dir: base64Encode(session.directory), + from: params.id, + to: session.id, + trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup", + }) + } + + navigateToSession(session) + queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`)) + return + } + } + async function archiveSession(session: Session) { const [store, setStore] = globalSync.child(session.directory) const sessions = store.session ?? [] @@ -1024,6 +1070,20 @@ export default function Layout(props: ParentProps) { keybind: "alt+arrowdown", onSelect: () => navigateSessionByOffset(1), }, + { + id: "session.previous.unseen", + title: language.t("command.session.previous.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowup", + onSelect: () => navigateSessionByUnseen(-1), + }, + { + id: "session.next.unseen", + title: language.t("command.session.next.unseen"), + category: language.t("command.category.session"), + keybind: "shift+alt+arrowdown", + onSelect: () => navigateSessionByUnseen(1), + }, { id: "session.archive", title: language.t("command.session.archive"), From 985090ef3cf5b5cbda97c7d1f280371a28e50b3c Mon Sep 17 00:00:00 2001 From: Lucio Delelis Date: Mon, 2 Feb 2026 08:20:30 -0300 Subject: [PATCH 006/140] fix(ui): adjusts alignment of elements to prevent incomplete scroll (#11649) --- packages/ui/src/components/message-nav.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css index b1454ad4259..79bfdc0b34c 100644 --- a/packages/ui/src/components/message-nav.css +++ b/packages/ui/src/components/message-nav.css @@ -103,7 +103,7 @@ display: flex; padding: 4px 4px 6px 4px; justify-content: center; - align-items: center; + align-items: start; border-radius: var(--radius-md); background: var(--surface-raised-stronger-non-alpha); max-height: calc(100vh - 6rem); From 43bb389e354fe5b631036f658c30421d4a5f1f5a Mon Sep 17 00:00:00 2001 From: Sam Huckaby Date: Mon, 2 Feb 2026 06:30:44 -0500 Subject: [PATCH 007/140] Fix(app): the Vesper theme's light mode (#9892) --- packages/ui/src/theme/themes/vesper.json | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/theme/themes/vesper.json b/packages/ui/src/theme/themes/vesper.json index 3c5e44cbd51..040bdc049ba 100644 --- a/packages/ui/src/theme/themes/vesper.json +++ b/packages/ui/src/theme/themes/vesper.json @@ -18,8 +18,7 @@ "background-base": "#FFF", "background-weak": "#F8F8F8", "background-strong": "#F0F0F0", - "background-stronger": "#E8E8E8", - "border-weak-base": "#E8E8E8", + "background-stronger": "#FBFBFB", "border-weak-hover": "#E0E0E0", "border-weak-active": "#D8D8D8", "border-weak-selected": "#D0D0D0", @@ -41,14 +40,15 @@ "surface-diff-delete-base": "#f5e8e8", "surface-diff-hidden-base": "#F0F0F0", "text-base": "#101010", - "text-weak": "#A0A0A0", + "text-invert-strong": "var(--smoke-dark-alpha-12)", + "text-weak": "#606060", "text-strong": "#000000", - "syntax-string": "#99FFE4", - "syntax-primitive": "#FF8080", - "syntax-property": "#FFC799", - "syntax-type": "#FFC799", - "syntax-constant": "#A0A0A0", - "syntax-info": "#A0A0A0", + "syntax-string": "#0D5C4F", + "syntax-primitive": "#B30000", + "syntax-property": "#C66C00", + "syntax-type": "#9C5C12", + "syntax-constant": "#404040", + "syntax-info": "#606060", "markdown-heading": "#FFC799", "markdown-text": "#101010", "markdown-link": "#FFC799", From 26197ec95bac8560a637fb496ce34c14bde7bca5 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 05:42:55 -0600 Subject: [PATCH 008/140] chore: update website stats --- packages/console/app/src/config.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 1d99def1b9a..be53ad909b5 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "80K", - full: "80,000", + compact: "95K", + full: "95,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "600", - commits: "7,500", - monthlyUsers: "1.5M", + contributors: "650", + commits: "8,500", + monthlyUsers: "2.5M", }, } as const From 52006c2fd93b00c216b4fa9f47f0e85ab8a43753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BAkup?= Date: Mon, 2 Feb 2026 13:01:49 +0100 Subject: [PATCH 009/140] feat(opencode): ormolu code formatter for haskell (#10274) --- packages/opencode/src/format/formatter.ts | 9 +++++++++ packages/web/src/content/docs/formatters.mdx | 1 + packages/web/src/content/docs/lsp.mdx | 1 + 3 files changed, 11 insertions(+) diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts index 5f0624d6c9d..9e97fae9dfc 100644 --- a/packages/opencode/src/format/formatter.ts +++ b/packages/opencode/src/format/formatter.ts @@ -355,3 +355,12 @@ export const pint: Info = { return false }, } + +export const ormolu: Info = { + name: "ormolu", + command: ["ormolu", "-i", "$FILE"], + extensions: [".hs"], + async enabled() { + return Bun.which("ormolu") !== null + }, +} diff --git a/packages/web/src/content/docs/formatters.mdx b/packages/web/src/content/docs/formatters.mdx index 225875f6252..54f36e0cd0e 100644 --- a/packages/web/src/content/docs/formatters.mdx +++ b/packages/web/src/content/docs/formatters.mdx @@ -36,6 +36,7 @@ OpenCode comes with several built-in formatters for popular languages and framew | shfmt | .sh, .bash | `shfmt` command available | | pint | .php | `laravel/pint` dependency in `composer.json` | | oxfmt (Experimental) | .js, .jsx, .ts, .tsx | `oxfmt` dependency in `package.json` and an [experimental env variable flag](/docs/cli/#experimental) | +| ormolu | .hs | `ormolu` command available | So if your project has `prettier` in your `package.json`, OpenCode will automatically use it. diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index 707af84a01d..ac788fc6000 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -25,6 +25,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` installed | | gleam | .gleam | `gleam` command available | | gopls | .go | `go` command available | +| hls | .hs, .lhs | `haskell-language-server-wrapper` command available | jdtls | .java | `Java SDK (version 21+)` installed | | kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects | | lua-ls | .lua | Auto-installs for Lua projects | From 6b17645f2eadb3d66d9ecd94e04d0ba85ff5d335 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 12:02:35 +0000 Subject: [PATCH 010/140] chore: generate --- packages/web/src/content/docs/lsp.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/lsp.mdx b/packages/web/src/content/docs/lsp.mdx index ac788fc6000..95c306fcc0e 100644 --- a/packages/web/src/content/docs/lsp.mdx +++ b/packages/web/src/content/docs/lsp.mdx @@ -25,7 +25,7 @@ OpenCode comes with several built-in LSP servers for popular languages: | fsharp | .fs, .fsi, .fsx, .fsscript | `.NET SDK` installed | | gleam | .gleam | `gleam` command available | | gopls | .go | `go` command available | -| hls | .hs, .lhs | `haskell-language-server-wrapper` command available +| hls | .hs, .lhs | `haskell-language-server-wrapper` command available | | jdtls | .java | `Java SDK (version 21+)` installed | | kotlin-ls | .kt, .kts | Auto-installs for Kotlin projects | | lua-ls | .lua | Auto-installs for Lua projects | From 50b5168c16c44093d176cebb342c86d005ec14f5 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:03:55 +0100 Subject: [PATCH 011/140] fix(desktop): added inverted svg for steps expanded for nice UX (#10462) Co-authored-by: opencode Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/ui/src/components/session-turn.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 48d6337edba..29c5566a658 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -610,7 +610,7 @@ export function SessionTurn( - + + + + + + From 37979ea44fd3afb99f6c110aed55e93ffb877b59 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:06:45 +0100 Subject: [PATCH 012/140] feat(app): enhance responsive design with additional breakpoints for larger screen layout adjustments (#10459) Co-authored-by: opencode Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] --- packages/app/src/pages/session.tsx | 11 +++++++---- packages/ui/src/styles/tailwind/index.css | 3 +++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d3e74072a86..d03c4186c5d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1940,7 +1940,8 @@ export default function Page() { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), }} >
@@ -1968,7 +1969,8 @@ export default function Page() { class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto": centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": + centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2021,7 +2023,8 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200": centered(), + "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": + centered(), }} > diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index f7ce21ad966..d8b0b2a1a0c 100644 --- a/packages/ui/src/styles/tailwind/index.css +++ b/packages/ui/src/styles/tailwind/index.css @@ -17,6 +17,9 @@ --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; + --breakpoint-3xl: 112rem; + --breakpoint-4xl: 128rem; + --breakpoint-5xl: 144rem; --container-3xs: 16rem; --container-2xs: 18rem; From 34c58af796befb22cd557012ec70d3e520b393b9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 12:07:56 +0000 Subject: [PATCH 013/140] chore: generate --- packages/app/src/pages/session.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d03c4186c5d..da12be8f567 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1941,7 +1941,7 @@ export default function Page() { "w-full": true, "px-4 md:px-6": true, "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": - centered(), + centered(), }} >
@@ -1970,7 +1970,7 @@ export default function Page() { classList={{ "w-full": true, "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": - centered(), + centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2023,8 +2023,7 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), + "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), }} > Date: Mon, 2 Feb 2026 18:20:06 +0530 Subject: [PATCH 014/140] feat(app): add tab close keybind (#11780) --- .../src/components/session/session-sortable-tab.tsx | 12 +++++++++--- packages/app/src/i18n/en.ts | 1 + packages/app/src/pages/session.tsx | 12 ++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx index 06609fcfb83..516f3c8edeb 100644 --- a/packages/app/src/components/session/session-sortable-tab.tsx +++ b/packages/app/src/components/session/session-sortable-tab.tsx @@ -3,11 +3,12 @@ import type { JSX } from "solid-js" import { createSortable } from "@thisbeyond/solid-dnd" import { FileIcon } from "@opencode-ai/ui/file-icon" import { IconButton } from "@opencode-ai/ui/icon-button" -import { Tooltip } from "@opencode-ai/ui/tooltip" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" import { getFilename } from "@opencode-ai/util/path" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" export function FileVisual(props: { path: string; active?: boolean }): JSX.Element { return ( @@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element { const file = useFile() const language = useLanguage() + const command = useCommand() const sortable = createSortable(props.tab) const path = createMemo(() => file.pathFromTab(props.tab)) return ( @@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v + v onClick={() => props.onTabClose(props.tab)} aria-label={language.t("common.closeTab")} /> - + } hideCloseButton onMiddleClick={() => props.onTabClose(props.tab)} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5589337e563..169d09cd38c 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -45,6 +45,7 @@ export const dict = { "command.session.new": "New session", "command.file.open": "Open file", "command.file.open.description": "Search files and commands", + "command.tab.close": "Close tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", "command.terminal.toggle": "Toggle terminal", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index da12be8f567..772ad063ba7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -689,6 +689,18 @@ export default function Page() { slash: "open", onSelect: () => dialog.show(() => showAllFiles()} />), }, + { + id: "tab.close", + title: language.t("command.tab.close"), + category: language.t("command.category.file"), + keybind: "mod+w", + disabled: !tabs().active(), + onSelect: () => { + const active = tabs().active() + if (!active) return + tabs().close(active) + }, + }, { id: "context.addSelection", title: language.t("command.context.addSelection"), From 4369d796368b0681f93c0da28725e147a263f56b Mon Sep 17 00:00:00 2001 From: Dax Date: Mon, 2 Feb 2026 09:30:10 -0500 Subject: [PATCH 015/140] tui: truncate session title in exit banner (#11797) --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f7d83b05554..9a000f953c0 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -225,10 +225,11 @@ export function Session() { const exit = useExit() createEffect(() => { + const title = Locale.truncate(session()?.title ?? "", 50) return exit.message.set( [ ``, - ` █▀▀█ ${UI.Style.TEXT_DIM}${session()?.title}${UI.Style.TEXT_NORMAL}`, + ` █▀▀█ ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`, ` █ █ ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`, ` ▀▀▀▀ `, ].join("\n"), From d63ed3bbe3d45842c7dcac623b6fda9d1b8d7630 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:37:34 -0500 Subject: [PATCH 016/140] ci --- .github/workflows/publish.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a1b492258b7..d7cb86cdf73 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -148,6 +148,12 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf + - name: install cross toolchain (aarch64) + if: matrix.settings.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross + - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -192,6 +198,8 @@ jobs: releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} + PKG_CONFIG_ALLOW_CROSS: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && '1' || '' }} TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} From 8de9e47a5b50f3f1c7d51d3ce17da7bd7c72a500 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:37:39 -0500 Subject: [PATCH 017/140] ci From 423778c93a9a976f3755c31a0398766b2d0c1e3f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 09:44:19 -0500 Subject: [PATCH 018/140] ci: reduce aarch64 build runner to 4 vcpu to lower infrastructure costs --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d7cb86cdf73..c610e3957df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-8vcpu-ubuntu-2404-arm + - host: blacksmith-4vcpu-ubuntu-2404 target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: From 06d63ca54cacfce5af7fdab216ffe7f35d778642 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 2 Feb 2026 10:06:21 -0500 Subject: [PATCH 019/140] ci: use native ARM runner for faster Linux ARM builds Switch from cross-compilation on x86_64 to native ARM runner, which improves build speed and reliability for Linux ARM binary distribution. --- .github/workflows/publish.yml | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c610e3957df..a1b492258b7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: target: x86_64-pc-windows-msvc - host: blacksmith-4vcpu-ubuntu-2404 target: x86_64-unknown-linux-gnu - - host: blacksmith-4vcpu-ubuntu-2404 + - host: blacksmith-8vcpu-ubuntu-2404-arm target: aarch64-unknown-linux-gnu runs-on: ${{ matrix.settings.host }} steps: @@ -148,12 +148,6 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf - - name: install cross toolchain (aarch64) - if: matrix.settings.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libc6-dev-arm64-cross - - name: install Rust stable uses: dtolnay/rust-toolchain@stable with: @@ -198,8 +192,6 @@ jobs: releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext] env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && 'aarch64-linux-gnu-gcc' || '' }} - PKG_CONFIG_ALLOW_CROSS: ${{ matrix.settings.target == 'aarch64-unknown-linux-gnu' && '1' || '' }} TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} From 1bd5dc5382cfa8b57dc470970bcdfa6a3dbd8dfb Mon Sep 17 00:00:00 2001 From: Goni Zahavy Date: Mon, 2 Feb 2026 18:13:48 +0200 Subject: [PATCH 020/140] ci: add ratelimits handling for close-stale-prs.yml (#11578) --- .github/workflows/close-stale-prs.yml | 136 ++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 21 deletions(-) diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index e1ff4241c98..e0e571b4691 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -18,6 +18,7 @@ permissions: jobs: close-stale-prs: runs-on: ubuntu-latest + timeout-minutes: 15 steps: - name: Close inactive PRs uses: actions/github-script@v8 @@ -25,6 +26,15 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const DAYS_INACTIVE = 60 + const MAX_RETRIES = 3 + + // Adaptive delay: fast for small batches, slower for large to respect + // GitHub's 80 content-generating requests/minute limit + const SMALL_BATCH_THRESHOLD = 10 + const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs) + const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit + + const startTime = Date.now() const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo const dryRun = context.payload.inputs?.dryRun === "true" @@ -32,6 +42,42 @@ jobs: core.info(`Dry run mode: ${dryRun}`) core.info(`Cutoff date: ${cutoff.toISOString()}`) + function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) + } + + async function withRetry(fn, description = 'API call') { + let lastError + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const result = await fn() + return result + } catch (error) { + lastError = error + const isRateLimited = error.status === 403 && + (error.message?.includes('rate limit') || error.message?.includes('secondary')) + + if (!isRateLimited) { + throw error + } + + // Parse retry-after header, default to 60 seconds + const retryAfter = error.response?.headers?.['retry-after'] + ? parseInt(error.response.headers['retry-after']) + : 60 + + // Exponential backoff: retryAfter * 2^attempt + const backoffMs = retryAfter * 1000 * Math.pow(2, attempt) + + core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`) + + await sleep(backoffMs) + } + } + core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`) + throw lastError + } + const query = ` query($owner: String!, $repo: String!, $cursor: String) { repository(owner: $owner, name: $repo) { @@ -73,17 +119,27 @@ jobs: const allPrs = [] let cursor = null let hasNextPage = true + let pageCount = 0 while (hasNextPage) { - const result = await github.graphql(query, { - owner, - repo, - cursor, - }) + pageCount++ + core.info(`Fetching page ${pageCount} of open PRs...`) + + const result = await withRetry( + () => github.graphql(query, { owner, repo, cursor }), + `GraphQL page ${pageCount}` + ) allPrs.push(...result.repository.pullRequests.nodes) hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage cursor = result.repository.pullRequests.pageInfo.endCursor + + core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`) + + // Delay between pagination requests (use small batch delay for reads) + if (hasNextPage) { + await sleep(SMALL_BATCH_DELAY_MS) + } } core.info(`Found ${allPrs.length} open pull requests`) @@ -114,28 +170,66 @@ jobs: core.info(`Found ${stalePrs.length} stale pull requests`) + // ============================================ + // Close stale PRs + // ============================================ + const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD + ? LARGE_BATCH_DELAY_MS + : SMALL_BATCH_DELAY_MS + + core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`) + + let closedCount = 0 + let skippedCount = 0 + for (const pr of stalePrs) { const issue_number = pr.number const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` if (dryRun) { - core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) continue } - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }) - - await github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }) - - core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`) + try { + // Add comment + await withRetry( + () => github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }), + `Comment on PR #${issue_number}` + ) + + // Close PR + await withRetry( + () => github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }), + `Close PR #${issue_number}` + ) + + closedCount++ + core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`) + + // Delay before processing next PR + await sleep(requestDelayMs) + } catch (error) { + skippedCount++ + core.error(`Failed to close PR #${issue_number}: ${error.message}`) + } } + + const elapsed = Math.round((Date.now() - startTime) / 1000) + core.info(`\n========== Summary ==========`) + core.info(`Total open PRs found: ${allPrs.length}`) + core.info(`Stale PRs identified: ${stalePrs.length}`) + core.info(`PRs closed: ${closedCount}`) + core.info(`PRs skipped (errors): ${skippedCount}`) + core.info(`Elapsed time: ${elapsed}s`) + core.info(`=============================`) From cf8b033be1cbe9f20bc0921d9920a66c0d95c704 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Mon, 2 Feb 2026 17:41:02 +0100 Subject: [PATCH 021/140] feat(provider): add User-Agent header for GitLab AI Gateway requests (#11818) --- bun.lock | 4 ++-- packages/opencode/package.json | 2 +- packages/opencode/src/provider/provider.ts | 11 ++++++++++- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index c98144251c6..b1a34e5fa49 100644 --- a/bun.lock +++ b/bun.lock @@ -286,7 +286,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.3.1", + "@gitlab/gitlab-ai-provider": "3.4.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -925,7 +925,7 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], - "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="], "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index c86aa734d8f..cc0e84d9f28 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,7 +70,7 @@ "@ai-sdk/vercel": "1.0.33", "@ai-sdk/xai": "2.0.56", "@clack/prompts": "1.0.0-alpha.1", - "@gitlab/gitlab-ai-provider": "3.3.1", + "@gitlab/gitlab-ai-provider": "3.4.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index e01c583ff34..27a86a2fccb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,5 @@ import z from "zod" +import os from "os" import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" @@ -35,8 +36,9 @@ import { createGateway } from "@ai-sdk/gateway" import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" -import { createGitLab } from "@gitlab/gitlab-ai-provider" +import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" +import { Installation } from "../installation" export namespace Provider { const log = Log.create({ service: "provider" }) @@ -424,11 +426,17 @@ export namespace Provider { const config = await Config.get() const providerConfig = config.provider?.["gitlab"] + const aiGatewayHeaders = { + "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`, + ...(providerConfig?.options?.aiGatewayHeaders || {}), + } + return { autoload: !!apiKey, options: { instanceUrl, apiKey, + aiGatewayHeaders, featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, @@ -437,6 +445,7 @@ export namespace Provider { }, async getModel(sdk: ReturnType, modelID: string) { return sdk.agenticChat(modelID, { + aiGatewayHeaders, featureFlags: { duo_agent_platform_agentic_chat: true, duo_agent_platform: true, From cf828fff85b50baf8c57cc3811c8789d9adbcae2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 16:56:31 +0000 Subject: [PATCH 022/140] 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 51802195337..431148b1fd5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-4I0lpBnbAi7IZMURTMLysjrqdsNvXJf8802NrJnpdks=", - "aarch64-linux": "sha256-WOGKsPlcQVSbL8TDr1JYO/2ucPTV2Hy0TXJKWv8EoVw=", - "aarch64-darwin": "sha256-LuvjwGm1QsHoLxuvSSp4VsDIv02Z/rTONsU32arQMuw=", - "x86_64-darwin": "sha256-AbglfgCWj/r+wHfle+e+D3b/xPcwwg4IK7j5iwn9nzw=" + "x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=", + "aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=", + "aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=", + "x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM=" } } From 965f32ad634d208bbb34c5a9bb12e501a009378b Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Mon, 2 Feb 2026 18:36:47 +0100 Subject: [PATCH 023/140] fix(tui): respect terminal transparency in system theme (#8467) --- packages/opencode/src/cli/cmd/tui/context/theme.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 7cde1b9648e..41c5a4a831c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -41,7 +41,6 @@ import { useRenderer } from "@opentui/solid" import { createStore, produce } from "solid-js/store" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" -import { useSDK } from "./sdk" type ThemeColors = { primary: RGBA @@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) + const transparent = RGBA.fromInts(0, 0, 0, 0) const isDark = mode == "dark" const col = (i: number) => { @@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs textMuted, selectedListItemText: bg, - // Background colors - background: bg, + // Background colors - use transparent to respect terminal transparency + background: transparent, backgroundPanel: grays[2], backgroundElement: grays[3], backgroundMenu: grays[3], From b9aad20be651050880bf2bc3b4c857f16a970402 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:15:53 +0100 Subject: [PATCH 024/140] fix(app): open project search (#11783) --- .../components/dialog-select-directory.tsx | 182 +++++++++++++++--- packages/ui/src/components/list.tsx | 45 +++-- 2 files changed, 179 insertions(+), 48 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index b9a7d6ed9b1..6e7af3d902d 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { List } from "@opencode-ai/ui/list" import { getDirectory, getFilename } from "@opencode-ai/util/path" import fuzzysort from "fuzzysort" -import { createMemo } from "solid-js" +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 @@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps { onSelect: (result: string | string[] | null) => void } +type Row = { + absolute: string + search: string +} + export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const sync = useGlobalSync() const sdk = useGlobalSDK() const dialog = useDialog() const language = useLanguage() - const home = createMemo(() => sync.data.path.home) + 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) + const start = createMemo( + () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory, + ) const cache = new Map>>() + const clean = (value: string) => { + const first = (value ?? "").split(/\r?\n/)[0] ?? "" + return first.replace(/[\u0000-\u001F\u007F]/g, "").trim() + } + function normalize(input: string) { const v = input.replaceAll("\\", "/") if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/") @@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { return "" } - function display(path: string) { + 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) + } + + 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 display(path: string, input: string) { const full = trimTrailing(path) + if (modeOf(input) === "absolute") return full + + return tildeOf(full) || full + } + + function tildeOf(absolute: string) { + const full = trimTrailing(absolute) const h = home() - if (!h) return full + 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 full + return "" + } + + 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 } } - function scoped(filter: string) { + function scoped(value: string) { const base = start() if (!base) return - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) if (!raw) return { directory: trimTrailing(base), path: "" } const h = home() @@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const directories = async (filter: string) => { - const input = scoped(filter) - if (!input) return [] as string[] + const value = clean(filter) + const scopedInput = scoped(value) + if (!scopedInput) return [] as string[] - const raw = normalizeDriveRoot(filter.trim()) + const raw = normalizeDriveRoot(value) const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/") - const query = normalizeDriveRoot(input.path) + const query = normalizeDriveRoot(scopedInput.path) - if (!isPath) { - const results = await sdk.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) + const find = () => + sdk.client.find + .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 }) .then((x) => x.data ?? []) .catch(() => []) - return results.map((rel) => join(input.directory, rel)).slice(0, 50) + if (!isPath) { + const results = await find() + + return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50) } const segments = query.replace(/^\/+/, "").split("/") @@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const cap = 12 const branch = 4 - let paths = [input.directory] + let paths = [scopedInput.directory] for (const part of head) { if (part === "..") { - paths = paths.map((p) => { - const v = trimTrailing(p) - if (v === "/") return v - if (/^[A-Za-z]:\/$/.test(v)) return v - const i = v.lastIndexOf("/") - if (i <= 0) return "/" - return v.slice(0, i) - }) + paths = paths.map(parentOf) continue } @@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { } const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat() - return Array.from(new Set(out)).slice(0, 50) + const deduped = Array.from(new Set(out)) + const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : "" + const expand = !raw.endsWith("/") + if (!expand || !tail) { + const items = base ? Array.from(new Set([base, ...deduped])) : deduped + return items.slice(0, 50) + } + + const needle = tail.toLowerCase() + const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle) + const target = exact[0] + if (!target) return deduped.slice(0, 50) + + const children = await match(target, "", 30) + const items = Array.from(new Set([...deduped, ...children])) + return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50) + } + + const items = async (value: string) => { + const results = await directories(value) + return results.map(row) } function resolve(absolute: string) { @@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }} emptyMessage={language.t("dialog.directory.empty")} loadingMessage={language.t("common.loading")} - items={directories} - key={(x) => x} + items={items} + key={(x) => x.absolute} + filterKeys={["search"]} + ref={(r) => (list = r)} + onFilter={(value) => setFilter(clean(value))} + onKeyEvent={(e, item) => { + if (e.key !== "Tab") return + if (e.shiftKey) return + if (!item) return + + e.preventDefault() + e.stopPropagation() + + const value = display(item.absolute, filter()) + list?.setFilter(value.endsWith("/") ? value : value + "/") + }} onSelect={(path) => { if (!path) return - resolve(path) + resolve(path.absolute) }} > - {(absolute) => { - const path = display(absolute) + {(item) => { + const path = display(item.absolute, filter()) + if (path === "~") { + return ( +
+
+ +
+ ~ + / +
+
+
+ ) + } return (
- +
{getDirectory(path)} {getFilename(path)} + /
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 15854180e4b..886ac5e6c8e 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -51,6 +51,7 @@ export interface ListProps extends FilteredListProps { export interface ListRef { onKeyDown: (e: KeyboardEvent) => void setScrollRef: (el: HTMLDivElement | undefined) => void + setFilter: (value: string) => void } export function List(props: ListProps & { ref?: (ref: ListRef) => void }) { @@ -80,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) container.scrollTop = Math.max(0, Math.min(target, max)) } - const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props) + const { filter, grouped, flat, active, setActive, onKeyDown, onInput, refetch } = useFilteredList(props) const searchProps = () => (typeof props.search === "object" ? props.search : {}) const searchAction = () => searchProps().action @@ -89,21 +90,29 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0 - createEffect(() => { - if (props.filter !== undefined) { - onInput(props.filter) - } - }) + const applyFilter = (value: string, options?: { ref?: boolean }) => { + const prev = filter() + setInternalFilter(value) + onInput(value) + props.onFilter?.(value) - createEffect((prev) => { - if (!props.search) return - const current = internalFilter() - if (prev !== current) { - onInput(current) - props.onFilter?.(current) + if (!options?.ref) return + + // Force a refetch even if the value is unchanged. + // This is important for programmatic changes like Tab completion. + if (prev === value) { + refetch() + return } - return current - }, "") + queueMicrotask(() => refetch()) + } + + createEffect(() => { + if (props.filter === undefined) return + if (props.filter === internalFilter()) return + setInternalFilter(props.filter) + onInput(props.filter) + }) createEffect( on( @@ -163,6 +172,8 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const index = selected ? all.indexOf(selected) : -1 props.onKeyEvent?.(e, selected) + if (e.defaultPrevented) return + if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) @@ -174,6 +185,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) props.ref?.({ onKeyDown: handleKey, setScrollRef, + setFilter: (value) => applyFilter(value, { ref: true }), }) const renderAdd = () => { @@ -247,7 +259,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) data-slot="list-search-input" type="text" value={internalFilter()} - onChange={setInternalFilter} + onChange={(value) => applyFilter(value)} onKeyDown={handleKey} placeholder={searchProps().placeholder} spellcheck={false} @@ -260,7 +272,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setInternalFilter("")} + onClick={() => applyFilter("")} aria-label={i18n.t("ui.list.clearFilter")} /> @@ -295,6 +307,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) data-active={props.key(item) === active()} data-selected={item === props.current} onClick={() => handleSelect(item, i())} + onKeyDown={handleKey} type="button" onMouseMove={(event) => { if (!moved(event)) return From ea1aba4192fd356603e807144edf202328008ee6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 07:02:40 -0600 Subject: [PATCH 025/140] feat(app): project context menu on right-click --- packages/app/src/pages/layout.tsx | 107 +++++-- packages/ui/src/components/context-menu.css | 134 +++++++++ packages/ui/src/components/context-menu.tsx | 308 ++++++++++++++++++++ packages/ui/src/styles/index.css | 1 + 4 files changed, 521 insertions(+), 29 deletions(-) create mode 100644 packages/ui/src/components/context-menu.css create mode 100644 packages/ui/src/components/context-menu.tsx diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index a970bf667e4..5a8dc0f2eac 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -31,6 +31,7 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { ContextMenu } from "@opencode-ai/ui/context-menu" import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { Spinner } from "@opencode-ai/ui/spinner" @@ -2310,10 +2311,13 @@ export default function Layout(props: ParentProps) { () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(), ) const [open, setOpen] = createSignal(false) + const [menu, setMenu] = createSignal(false) const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree)) + const active = createMemo( + () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree), + ) createEffect(() => { if (preview()) return @@ -2352,35 +2356,79 @@ export default function Layout(props: ParentProps) { const projectName = () => props.project.name || getFilename(props.project.worktree) const trigger = ( - + { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onFocus={() => { + if (!overlay()) return + globalSync.child(props.project.worktree) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) + }} + onClick={() => navigateToProject(props.project.worktree)} + onBlur={() => setOpen(false)} + > + + + + + dialog.show(() => )}> + {language.t("common.edit")} + + { + const enabled = layout.sidebar.workspaces(props.project.worktree)() + if (enabled) { + layout.sidebar.toggleWorkspaces(props.project.worktree) + return + } + if (props.project.vcs !== "git") return + layout.sidebar.toggleWorkspaces(props.project.worktree) + }} + > + + {layout.sidebar.workspaces(props.project.worktree)() + ? language.t("sidebar.workspaces.disable") + : language.t("sidebar.workspaces.enable")} + + + + closeProject(props.project.worktree)} + > + {language.t("common.close")} + + + + ) return ( @@ -2388,13 +2436,14 @@ export default function Layout(props: ParentProps) {
{ + if (menu()) return setOpen(value) if (value) setState("hoverSession", undefined) }} diff --git a/packages/ui/src/components/context-menu.css b/packages/ui/src/components/context-menu.css new file mode 100644 index 00000000000..1e366dccd42 --- /dev/null +++ b/packages/ui/src/components/context-menu.css @@ -0,0 +1,134 @@ +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + min-width: 8rem; + overflow: hidden; + border: none; + border-radius: var(--radius-md); + box-shadow: var(--shadow-xs-border); + background-clip: padding-box; + background-color: var(--surface-raised-stronger-non-alpha); + padding: 4px; + z-index: 100; + transform-origin: var(--kb-menu-content-transform-origin); + + &:focus-within, + &:focus { + outline: none; + } + + animation: contextMenuContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } + + &[data-expanded] { + pointer-events: auto; + animation: contextMenuContentShow var(--transition-duration) var(--transition-easing) forwards; + } +} + +[data-component="context-menu-content"], +[data-component="context-menu-sub-content"] { + [data-slot="context-menu-item"], + [data-slot="context-menu-checkbox-item"], + [data-slot="context-menu-radio-item"], + [data-slot="context-menu-sub-trigger"] { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: default; + outline: none; + + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); + user-select: none; + + &:hover { + background-color: var(--surface-raised-base-hover); + } + + &[data-disabled] { + color: var(--text-weak); + pointer-events: none; + } + } + + [data-slot="context-menu-sub-trigger"] { + &[data-expanded] { + background: var(--surface-raised-base-hover); + outline: none; + border: none; + } + } + + [data-slot="context-menu-item-indicator"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + } + + [data-slot="context-menu-item-label"] { + flex: 1; + } + + [data-slot="context-menu-item-description"] { + font-size: var(--font-size-x-small); + color: var(--text-weak); + } + + [data-slot="context-menu-separator"] { + height: 1px; + margin: 4px -4px; + border-top-color: var(--border-weak-base); + } + + [data-slot="context-menu-group-label"] { + padding: 4px 8px; + font-family: var(--font-family-sans); + font-size: var(--font-size-x-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } + + [data-slot="context-menu-arrow"] { + fill: var(--surface-raised-stronger-non-alpha); + } +} + +@keyframes contextMenuContentShow { + from { + opacity: 0; + transform: scaleY(0.95); + } + to { + opacity: 1; + transform: scaleY(1); + } +} + +@keyframes contextMenuContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); + } +} diff --git a/packages/ui/src/components/context-menu.tsx b/packages/ui/src/components/context-menu.tsx new file mode 100644 index 00000000000..afdaff7b800 --- /dev/null +++ b/packages/ui/src/components/context-menu.tsx @@ -0,0 +1,308 @@ +import { ContextMenu as Kobalte } from "@kobalte/core/context-menu" +import { splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface ContextMenuProps extends ComponentProps {} +export interface ContextMenuTriggerProps extends ComponentProps {} +export interface ContextMenuIconProps extends ComponentProps {} +export interface ContextMenuPortalProps extends ComponentProps {} +export interface ContextMenuContentProps extends ComponentProps {} +export interface ContextMenuArrowProps extends ComponentProps {} +export interface ContextMenuSeparatorProps extends ComponentProps {} +export interface ContextMenuGroupProps extends ComponentProps {} +export interface ContextMenuGroupLabelProps extends ComponentProps {} +export interface ContextMenuItemProps extends ComponentProps {} +export interface ContextMenuItemLabelProps extends ComponentProps {} +export interface ContextMenuItemDescriptionProps extends ComponentProps {} +export interface ContextMenuItemIndicatorProps extends ComponentProps {} +export interface ContextMenuRadioGroupProps extends ComponentProps {} +export interface ContextMenuRadioItemProps extends ComponentProps {} +export interface ContextMenuCheckboxItemProps extends ComponentProps {} +export interface ContextMenuSubProps extends ComponentProps {} +export interface ContextMenuSubTriggerProps extends ComponentProps {} +export interface ContextMenuSubContentProps extends ComponentProps {} + +function ContextMenuRoot(props: ContextMenuProps) { + return +} + +function ContextMenuTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuIcon(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuPortal(props: ContextMenuPortalProps) { + return +} + +function ContextMenuContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuArrow(props: ContextMenuArrowProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuSeparator(props: ContextMenuSeparatorProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + + ) +} + +function ContextMenuGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuGroupLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemLabel(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemDescription(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuItemIndicator(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioGroup(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuRadioItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuCheckboxItem(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSub(props: ContextMenuSubProps) { + return +} + +function ContextMenuSubTrigger(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +function ContextMenuSubContent(props: ParentProps) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + {local.children} + + ) +} + +export const ContextMenu = Object.assign(ContextMenuRoot, { + Trigger: ContextMenuTrigger, + Icon: ContextMenuIcon, + Portal: ContextMenuPortal, + Content: ContextMenuContent, + Arrow: ContextMenuArrow, + Separator: ContextMenuSeparator, + Group: ContextMenuGroup, + GroupLabel: ContextMenuGroupLabel, + Item: ContextMenuItem, + ItemLabel: ContextMenuItemLabel, + ItemDescription: ContextMenuItemDescription, + ItemIndicator: ContextMenuItemIndicator, + RadioGroup: ContextMenuRadioGroup, + RadioItem: ContextMenuRadioItem, + CheckboxItem: ContextMenuCheckboxItem, + Sub: ContextMenuSub, + SubTrigger: ContextMenuSubTrigger, + SubContent: ContextMenuSubContent, +}) diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 2a8171f98c0..d5939b2b36a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -16,6 +16,7 @@ @import "../components/collapsible.css" layer(components); @import "../components/diff.css" layer(components); @import "../components/diff-changes.css" layer(components); +@import "../components/context-menu.css" layer(components); @import "../components/dropdown-menu.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/file-icon.css" layer(components); From 30a25e4edca0f3476ca63f83dbe95fcee75113e3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:21:08 -0600 Subject: [PATCH 026/140] fix(app): user messages not rendering consistently --- packages/app/src/components/prompt-input.tsx | 4 +- packages/app/src/context/global-sync.tsx | 16 +++--- packages/app/src/context/sync.tsx | 10 ++-- packages/app/src/pages/layout.tsx | 52 +++++++++++++++----- packages/ui/src/components/session-turn.tsx | 8 +-- 5 files changed, 61 insertions(+), 29 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2b63b6f5fd3..1c84c361049 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1463,7 +1463,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) return @@ -1481,7 +1481,7 @@ export const PromptInput: Component = (props) => { draft.part[messageID] = optimisticParts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }), ) } diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ad3d124b2c3..0facbdfff45 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -119,6 +119,8 @@ type ChildOptions = { bootstrap?: boolean } +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + function normalizeProviderList(input: ProviderListResponse): ProviderListResponse { return { ...input, @@ -297,7 +299,7 @@ function createGlobalSync() { const aUpdated = sessionUpdatedAt(a) const bUpdated = sessionUpdatedAt(b) if (aUpdated !== bUpdated) return bUpdated - aUpdated - return a.id.localeCompare(b.id) + return cmp(a.id, b.id) } function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { @@ -325,7 +327,7 @@ function createGlobalSync() { const all = input .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) const roots = all.filter((s) => !s.parentID) const children = all.filter((s) => !!s.parentID) @@ -342,7 +344,7 @@ function createGlobalSync() { return sessionUpdatedAt(s) > cutoff }) - return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id)) + return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id)) } function ensureChild(directory: string) { @@ -457,7 +459,7 @@ function createGlobalSync() { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) .filter((s) => !s.time?.archived) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) // Read the current limit at resolve-time so callers that bump the limit while // a request is in-flight still get the expanded result. @@ -559,7 +561,7 @@ function createGlobalSync() { "permission", sessionID, reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -588,7 +590,7 @@ function createGlobalSync() { "question", sessionID, reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)), + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -986,7 +988,7 @@ function createGlobalSync() { .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) setGlobalStore("project", projects) }), ), diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5c8e140c396..0c636524501 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client" const keyFor = (directory: string, id: string) => `${directory}\n${id}` +const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) + export const { use: useSync, provider: SyncProvider } = createSimpleContext({ name: "Sync", init: () => { @@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const next = items .map((x) => x.info) .filter((m) => !!m?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) batch(() => { input.setStore("message", input.sessionID, reconcile(next, { key: "id" })) @@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ "part", message.info.id, reconcile( - message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)), + message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), { key: "id" }, ), ) @@ -129,7 +131,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const result = Binary.search(messages, input.messageID, (m) => m.id) messages.splice(result.index, 0, message) } - draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)) + draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)) }), ) }, @@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ await client.session.list().then((x) => { const sessions = (x.data ?? []) .filter((s) => !!s?.id) - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) setStore("session", reconcile(sessions, { key: "id" })) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5a8dc0f2eac..202443ee705 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -499,7 +499,7 @@ export default function Layout(props: ParentProps) { const bUpdated = b.time.updated ?? b.time.created const aRecent = aUpdated > oneMinuteAgo const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id.localeCompare(b.id) + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 if (aRecent && !bRecent) return -1 if (!aRecent && bRecent) return 1 return bUpdated - aUpdated @@ -739,7 +739,7 @@ export default function Layout(props: ParentProps) { } async function prefetchMessages(directory: string, sessionID: string, token: number) { - const [, setStore] = globalSync.child(directory, { bootstrap: false }) + const [store, setStore] = globalSync.child(directory, { bootstrap: false }) return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) .then((messages) => { @@ -750,23 +750,49 @@ export default function Layout(props: ParentProps) { .map((x) => x.info) .filter((m) => !!m?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + + const current = store.message[sessionID] ?? [] + const merged = (() => { + if (current.length === 0) return next + + const map = new Map() + for (const item of current) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of next) { + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() batch(() => { - setStore("message", sessionID, reconcile(next, { key: "id" })) + setStore("message", sessionID, reconcile(merged, { key: "id" })) for (const message of items) { - setStore( - "part", - message.info.id, - reconcile( - message.parts + const currentParts = store.part[message.info.id] ?? [] + const mergedParts = (() => { + if (currentParts.length === 0) { + return message.parts .filter((p) => !!p?.id) .slice() - .sort((a, b) => a.id.localeCompare(b.id)), - { key: "id" }, - ), - ) + .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + } + + const map = new Map() + for (const item of currentParts) { + if (!item?.id) continue + map.set(item.id, item) + } + for (const item of message.parts) { + if (!item?.id) continue + map.set(item.id, item) + } + return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + })() + + setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) } }) }) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 29c5566a658..d878bd24569 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -161,12 +161,14 @@ export function SessionTurn( const messageIndex = createMemo(() => { const messages = allMessages() ?? emptyMessages const result = Binary.search(messages, props.messageID, (m) => m.id) - if (!result.found) return -1 - const msg = messages[result.index] + const index = result.found ? result.index : messages.findIndex((m) => m.id === props.messageID) + if (index < 0) return -1 + + const msg = messages[index] if (!msg || msg.role !== "user") return -1 - return result.index + return index }) const message = createMemo(() => { From f1e0c31b8f7c299d2bdc5f69dc30ed55f86918bb Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:29:06 -0600 Subject: [PATCH 027/140] fix(app): button heights --- packages/ui/src/components/button.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 3e5d21d1de8..afff0c476b5 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -114,6 +114,7 @@ } &[data-size="small"] { + height: 22px; padding: 4px 8px; &[data-icon] { padding: 4px 12px 4px 4px; @@ -130,6 +131,7 @@ } &[data-size="normal"] { + height: 24px; padding: 4px 6px; &[data-icon] { padding: 4px 12px 4px 4px; @@ -150,6 +152,7 @@ } &[data-size="large"] { + height: 32px; padding: 6px 12px; &[data-icon] { From 23631a93935a33fb8e44272ba1572e3475a223c2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:53:41 -0600 Subject: [PATCH 028/140] fix(app): navigate to last project on open --- packages/app/src/pages/layout.tsx | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 202443ee705..5d285c5ecca 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -109,7 +109,7 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() - const initialDir = params.dir + const initialDirectory = decode64(params.dir) const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { @@ -120,7 +120,7 @@ export default function Layout(props: ParentProps) { const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) const [state, setState] = createStore({ - autoselect: !params.dir, + autoselect: !initialDirectory, busyWorkspaces: new Set(), hoverSession: undefined as string | undefined, hoverProject: undefined as string | undefined, @@ -180,13 +180,21 @@ export default function Layout(props: ParentProps) { const autoselecting = createMemo(() => { if (params.dir) return false - if (initialDir) return false if (!state.autoselect) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() - if (list.length === 0) return false - return true + if (list.length > 0) return true + return !!server.projects.last() + }) + + createEffect(() => { + if (!state.autoselect) return + const dir = params.dir + if (!dir) return + const directory = decode64(dir) + if (!directory) return + setState("autoselect", false) }) const editorOpen = (id: string) => editor.active === id @@ -566,11 +574,18 @@ export default function Layout(props: ParentProps) { if (!value.ready) return if (!value.layoutReady) return if (!state.autoselect) return - if (initialDir) return if (value.dir) return - if (value.list.length === 0) return const last = server.projects.last() + + if (value.list.length === 0) { + if (!last) return + setState("autoselect", false) + openProject(last, false) + navigateToProject(last) + return + } + const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return setState("autoselect", false) From 3b93e8d95cfc30d1a85fbb76694bdb7f49dff1e9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 10:55:15 -0600 Subject: [PATCH 029/140] fix(app): added/deleted file status now correctly calculated --- packages/app/src/components/file-tree.tsx | 49 ++++++++++++++++++- packages/app/src/pages/session.tsx | 4 +- packages/opencode/src/snapshot/index.ts | 19 +++++++ .../opencode/test/snapshot/snapshot.test.ts | 46 +++++++++++++++++ packages/sdk/js/src/v2/gen/types.gen.ts | 1 + 5 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index d43310b195c..19f5e9a3b43 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -130,10 +130,57 @@ export default function FileTree(props: { const nodes = file.tree.children(props.path) const current = filter() if (!current) return nodes - return nodes.filter((node) => { + + const parent = (path: string) => { + const idx = path.lastIndexOf("/") + if (idx === -1) return "" + return path.slice(0, idx) + } + + const leaf = (path: string) => { + const idx = path.lastIndexOf("/") + return idx === -1 ? path : path.slice(idx + 1) + } + + const out = nodes.filter((node) => { if (node.type === "file") return current.files.has(node.path) return current.dirs.has(node.path) }) + + const seen = new Set(out.map((node) => node.path)) + + for (const dir of current.dirs) { + if (parent(dir) !== props.path) continue + if (seen.has(dir)) continue + out.push({ + name: leaf(dir), + path: dir, + absolute: dir, + type: "directory", + ignored: false, + }) + seen.add(dir) + } + + for (const item of current.files) { + if (parent(item) !== props.path) continue + if (seen.has(item)) continue + out.push({ + name: leaf(item), + path: item, + absolute: item, + type: "file", + ignored: false, + }) + seen.add(item) + } + + return out.toSorted((a, b) => { + if (a.type !== b.type) { + return a.type === "directory" ? -1 : 1 + } + return a.name.localeCompare(b.name) + }) }) const Node = ( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 772ad063ba7..540046c09b9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -500,9 +500,7 @@ export default function Page() { const out = new Map() for (const diff of diffs()) { const file = normalize(diff.file) - const add = diff.additions > 0 - const del = diff.deletions > 0 - const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix" + const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix" out.set(file, kind) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 1c153909054..b3c8a905c25 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -188,6 +188,7 @@ export namespace Snapshot { after: z.string(), additions: z.number(), deletions: z.number(), + status: z.enum(["added", "deleted", "modified"]).optional(), }) .meta({ ref: "FileDiff", @@ -196,6 +197,23 @@ export namespace Snapshot { export async function diffFull(from: string, to: string): Promise { const git = gitdir() const result: FileDiff[] = [] + const status = new Map() + + const statuses = + await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .` + .quiet() + .cwd(Instance.directory) + .nothrow() + .text() + + for (const line of statuses.trim().split("\n")) { + if (!line) continue + const [code, file] = line.split("\t") + if (!code || !file) continue + const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified" + status.set(file, kind) + } + for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .` .quiet() .cwd(Instance.directory) @@ -224,6 +242,7 @@ export namespace Snapshot { after, additions: Number.isFinite(added) ? added : 0, deletions: Number.isFinite(deleted) ? deleted : 0, + status: status.get(file) ?? "modified", }) } return result diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index ef6271ed5da..091469ec761 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -749,6 +749,52 @@ test("revert preserves file that existed in snapshot when deleted then recreated }) }) +test("diffFull sets status based on git change type", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Bun.write(`${tmp.path}/grow.txt`, "one\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n") + await Bun.write(`${tmp.path}/delete.txt`, "gone") + + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n") + await Bun.write(`${tmp.path}/trim.txt`, "line1\n") + await $`rm ${tmp.path}/delete.txt`.quiet() + await Bun.write(`${tmp.path}/added.txt`, "new") + + const after = await Snapshot.track() + expect(after).toBeTruthy() + + const diffs = await Snapshot.diffFull(before!, after!) + expect(diffs.length).toBe(4) + + const added = diffs.find((d) => d.file === "added.txt") + expect(added).toBeDefined() + expect(added!.status).toBe("added") + + const deleted = diffs.find((d) => d.file === "delete.txt") + expect(deleted).toBeDefined() + expect(deleted!.status).toBe("deleted") + + const grow = diffs.find((d) => d.file === "grow.txt") + expect(grow).toBeDefined() + expect(grow!.status).toBe("modified") + expect(grow!.additions).toBeGreaterThan(0) + expect(grow!.deletions).toBe(0) + + const trim = diffs.find((d) => d.file === "trim.txt") + expect(trim).toBeDefined() + expect(trim!.status).toBe("modified") + expect(trim!.additions).toBe(0) + expect(trim!.deletions).toBeGreaterThan(0) + }, + }) +}) + test("diffFull with new file additions", async () => { await using tmp = await bootstrap() await Instance.provide({ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 0556e1ad945..085c9d9c7ed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -96,6 +96,7 @@ export type FileDiff = { after: string additions: number deletions: number + status?: "added" | "deleted" | "modified" } export type UserMessage = { From dfd5f38408aa0a905a9cda40f1ce077777dce5e0 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:22:00 -0600 Subject: [PATCH 030/140] fix(app): icon sizes --- packages/app/src/components/prompt-input.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1c84c361049..d31d0b2a376 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1801,7 +1801,7 @@ export const PromptInput: Component = (props) => { }} >
- +
{getFilenameTruncated(item.path, 14)} @@ -1818,7 +1818,7 @@ export const PromptInput: Component = (props) => { type="button" icon="close-small" variant="ghost" - class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all" + class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all" onClick={(e) => { e.stopPropagation() if (item.commentID) comments.remove(item.path, item.commentID) From 2f76b49df3cfd316069a2b5c292fed369acadbde Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:37:50 -0600 Subject: [PATCH 031/140] Revert "feat(ui): Smooth fading out on scroll, style fixes (#11683)" This reverts commit e445dc07464d75c893756f6e256c1755d9e2285e. --- .../app/src/components/settings-general.tsx | 10 +- .../app/src/components/settings-keybinds.tsx | 10 +- .../app/src/components/settings-models.tsx | 10 +- .../app/src/components/settings-providers.tsx | 10 +- packages/ui/src/components/list.css | 50 +++-- packages/ui/src/components/list.tsx | 5 +- packages/ui/src/components/scroll-fade.css | 82 ------- packages/ui/src/components/scroll-fade.tsx | 206 ------------------ packages/ui/src/components/scroll-reveal.tsx | 141 ------------ packages/ui/src/styles/index.css | 1 - 10 files changed, 43 insertions(+), 482 deletions(-) delete mode 100644 packages/ui/src/components/scroll-fade.css delete mode 100644 packages/ui/src/components/scroll-fade.tsx delete mode 100644 packages/ui/src/components/scroll-reveal.tsx diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a0251ed41b6..94813871e4c 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,7 +5,6 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -131,12 +130,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( - +

{language.t("settings.tab.general")}

@@ -417,7 +411,7 @@ export const SettingsGeneral: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 8655bca34b2..a24db13f5c5 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,7 +5,6 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -353,12 +352,7 @@ export const SettingsKeybinds: Component = () => { }) return ( - +
@@ -436,6 +430,6 @@ export const SettingsKeybinds: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 0ee5caf73d9..1807d561eac 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,7 +9,6 @@ import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ModelItem = ReturnType["list"]>[number] @@ -40,12 +39,7 @@ export const SettingsModels: Component = () => { }) return ( - +

{language.t("settings.models.title")}

@@ -131,6 +125,6 @@ export const SettingsModels: Component = () => {
- +
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 2460534c05c..dcc597139e3 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,7 +12,6 @@ import { useGlobalSync } from "@/context/global-sync" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" -import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderMeta = { source?: ProviderSource } @@ -116,12 +115,7 @@ export const SettingsProviders: Component = () => { } return ( - +

{language.t("settings.providers.title")}

@@ -267,6 +261,6 @@ export const SettingsProviders: Component = () => {
- +
) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 7b365c288ad..b12d304151d 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -1,7 +1,25 @@ +@property --bottom-fade { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@keyframes scroll { + 0% { + --bottom-fade: 20px; + } + 90% { + --bottom-fade: 20px; + } + 100% { + --bottom-fade: 0; + } +} + [data-component="list"] { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; overflow: hidden; padding: 0 12px; @@ -19,9 +37,7 @@ flex-shrink: 0; background-color: transparent; opacity: 0.5; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -72,9 +88,7 @@ height: 20px; background-color: transparent; opacity: 0.5; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -117,6 +131,15 @@ gap: 12px; overflow-y: auto; overscroll-behavior: contain; + mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); + animation: scroll; + animation-timeline: --scroll; + scroll-timeline: --scroll y; + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } [data-slot="list-empty-state"] { display: flex; @@ -192,9 +215,7 @@ background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); pointer-events: none; opacity: 0; - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: opacity 0.15s ease; } &[data-stuck="true"]::after { @@ -230,22 +251,17 @@ align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1 / 1; + aspect-ratio: 1/1; [data-component="icon"] { color: var(--icon-strong-base); } } - - [name="check"] { - color: var(--icon-strong-base); - } - [data-slot="list-item-active-icon"] { display: none; align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1 / 1; + aspect-ratio: 1/1; [data-component="icon"] { color: var(--icon-strong-base); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 886ac5e6c8e..6c654cbb7d6 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -5,7 +5,6 @@ import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" -import { ScrollFade } from "./scroll-fade" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') @@ -280,7 +279,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
- +
0 || showAdd()} fallback={ @@ -353,7 +352,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
) } diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css deleted file mode 100644 index ede5fabec45..00000000000 --- a/packages/ui/src/components/scroll-fade.css +++ /dev/null @@ -1,82 +0,0 @@ -[data-component="scroll-fade"] { - overflow: auto; - overscroll-behavior: contain; - scrollbar-width: none; - box-sizing: border-box; - color: inherit; - font: inherit; - -ms-overflow-style: none; - - &::-webkit-scrollbar { - display: none; - } - - &[data-direction="horizontal"] { - overflow-x: auto; - overflow-y: hidden; - - /* Both fades */ - &[data-fade-start][data-fade-end] { - mask-image: linear-gradient( - to right, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - -webkit-mask-image: linear-gradient( - to right, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - } - - /* Only start fade */ - &[data-fade-start]:not([data-fade-end]) { - mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); - -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); - } - - /* Only end fade */ - &:not([data-fade-start])[data-fade-end] { - mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - } - } - - &[data-direction="vertical"] { - overflow-y: auto; - overflow-x: hidden; - - &[data-fade-start][data-fade-end] { - mask-image: linear-gradient( - to bottom, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - -webkit-mask-image: linear-gradient( - to bottom, - transparent, - black var(--scroll-fade-start), - black calc(100% - var(--scroll-fade-end)), - transparent - ); - } - - /* Only start fade */ - &[data-fade-start]:not([data-fade-end]) { - mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); - -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); - } - - /* Only end fade */ - &:not([data-fade-start])[data-fade-end] { - mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); - } - } -} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx deleted file mode 100644 index 97f0339e829..00000000000 --- a/packages/ui/src/components/scroll-fade.tsx +++ /dev/null @@ -1,206 +0,0 @@ -import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" - -export interface ScrollFadeProps extends JSX.HTMLAttributes { - direction?: "horizontal" | "vertical" - fadeStartSize?: number - fadeEndSize?: number - trackTransformSelector?: string - ref?: (el: HTMLDivElement) => void -} - -export function ScrollFade(props: ScrollFadeProps) { - const [local, others] = splitProps(props, [ - "children", - "direction", - "fadeStartSize", - "fadeEndSize", - "trackTransformSelector", - "class", - "style", - "ref", - ]) - - const direction = () => local.direction ?? "vertical" - const fadeStartSize = () => local.fadeStartSize ?? 20 - const fadeEndSize = () => local.fadeEndSize ?? 20 - - const getTransformOffset = (element: Element): number => { - const style = getComputedStyle(element) - const transform = style.transform - if (!transform || transform === "none") return 0 - - const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) - if (!match) return 0 - - const values = match[1].split(",").map((v) => parseFloat(v.trim())) - const isHorizontal = direction() === "horizontal" - - if (transform.startsWith("matrix3d")) { - return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) - } else { - return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) - } - } - - let containerRef: HTMLDivElement | undefined - - const [fadeStart, setFadeStart] = createSignal(0) - const [fadeEnd, setFadeEnd] = createSignal(0) - const [isScrollable, setIsScrollable] = createSignal(false) - - let lastScrollPos = 0 - let lastTransformPos = 0 - let lastScrollSize = 0 - let lastClientSize = 0 - - const updateFade = () => { - if (!containerRef) return - - const isHorizontal = direction() === "horizontal" - const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop - const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight - const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight - - let transformPos = 0 - if (local.trackTransformSelector) { - const transformElement = containerRef.querySelector(local.trackTransformSelector) - if (transformElement) { - transformPos = getTransformOffset(transformElement) - } - } - - const effectiveScrollPos = Math.max(scrollPos, transformPos) - - if ( - effectiveScrollPos === lastScrollPos && - transformPos === lastTransformPos && - scrollSize === lastScrollSize && - clientSize === lastClientSize - ) { - return - } - - lastScrollPos = effectiveScrollPos - lastTransformPos = transformPos - lastScrollSize = scrollSize - lastClientSize = clientSize - - const maxScroll = scrollSize - clientSize - const canScroll = maxScroll > 1 - - setIsScrollable(canScroll) - - if (!canScroll) { - setFadeStart(0) - setFadeEnd(0) - return - } - - const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 - - const startProgress = Math.min(progress / 0.1, 1) - setFadeStart(startProgress * fadeStartSize()) - - const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 - setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) - } - - onMount(() => { - if (!containerRef) return - - updateFade() - - let rafId: number | undefined - let isPolling = false - let pollTimeout: ReturnType | undefined - - const startPolling = () => { - if (isPolling) return - isPolling = true - - const pollScroll = () => { - updateFade() - rafId = requestAnimationFrame(pollScroll) - } - rafId = requestAnimationFrame(pollScroll) - } - - const stopPolling = () => { - if (!isPolling) return - isPolling = false - if (rafId !== undefined) { - cancelAnimationFrame(rafId) - rafId = undefined - } - } - - const schedulePollingStop = () => { - if (pollTimeout !== undefined) clearTimeout(pollTimeout) - pollTimeout = setTimeout(stopPolling, 1000) - } - - const onActivity = () => { - updateFade() - if (local.trackTransformSelector) { - startPolling() - schedulePollingStop() - } - } - - containerRef.addEventListener("scroll", onActivity, { passive: true }) - - const resizeObserver = new ResizeObserver(() => { - lastScrollSize = 0 - lastClientSize = 0 - onActivity() - }) - resizeObserver.observe(containerRef) - - const mutationObserver = new MutationObserver(() => { - lastScrollSize = 0 - lastClientSize = 0 - requestAnimationFrame(onActivity) - }) - mutationObserver.observe(containerRef, { - childList: true, - subtree: true, - characterData: true, - }) - - onCleanup(() => { - containerRef?.removeEventListener("scroll", onActivity) - resizeObserver.disconnect() - mutationObserver.disconnect() - stopPolling() - if (pollTimeout !== undefined) clearTimeout(pollTimeout) - }) - }) - - createEffect(() => { - local.children - requestAnimationFrame(updateFade) - }) - - return ( -
{ - containerRef = el - local.ref?.(el) - }} - data-component="scroll-fade" - data-direction={direction()} - data-scrollable={isScrollable() || undefined} - data-fade-start={fadeStart() > 0 || undefined} - data-fade-end={fadeEnd() > 0 || undefined} - class={local.class} - style={{ - ...(typeof local.style === "object" ? local.style : {}), - "--scroll-fade-start": `${fadeStart()}px`, - "--scroll-fade-end": `${fadeEnd()}px`, - }} - {...others} - > - {local.children} -
- ) -} diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx deleted file mode 100644 index 6e5072dc81e..00000000000 --- a/packages/ui/src/components/scroll-reveal.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import { type JSX, onCleanup, splitProps } from "solid-js" -import { ScrollFade, type ScrollFadeProps } from "./scroll-fade" - -const SCROLL_SPEED = 60 -const PAUSE_DURATION = 800 - -type ScrollAnimationState = { - rafId: number | null - startTime: number - running: boolean -} - -const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => { - containerEl.offsetHeight - - const extraWidth = containerEl.scrollWidth - containerEl.clientWidth - - if (extraWidth <= 0) { - return null - } - - const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000 - const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION - - const state: ScrollAnimationState = { - rafId: null, - startTime: performance.now(), - running: true, - } - - const animate = (currentTime: number) => { - if (!state.running) return - - const elapsed = currentTime - state.startTime - const progress = (elapsed % totalDuration) / totalDuration - - const pausePercent = PAUSE_DURATION / totalDuration - const scrollPercent = scrollDuration / totalDuration - - const pauseEnd1 = pausePercent - const scrollEnd1 = pauseEnd1 + scrollPercent - const pauseEnd2 = scrollEnd1 + pausePercent - const scrollEnd2 = pauseEnd2 + scrollPercent - - let scrollPos = 0 - - if (progress < pauseEnd1) { - scrollPos = 0 - } else if (progress < scrollEnd1) { - const scrollProgress = (progress - pauseEnd1) / scrollPercent - scrollPos = scrollProgress * extraWidth - } else if (progress < pauseEnd2) { - scrollPos = extraWidth - } else if (progress < scrollEnd2) { - const scrollProgress = (progress - pauseEnd2) / scrollPercent - scrollPos = extraWidth * (1 - scrollProgress) - } else { - scrollPos = 0 - } - - containerEl.scrollLeft = scrollPos - state.rafId = requestAnimationFrame(animate) - } - - state.rafId = requestAnimationFrame(animate) - return state -} - -const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => { - if (state) { - state.running = false - if (state.rafId !== null) { - cancelAnimationFrame(state.rafId) - } - } - if (containerEl) { - containerEl.scrollLeft = 0 - } -} - -export interface ScrollRevealProps extends Omit { - hoverDelay?: number -} - -export function ScrollReveal(props: ScrollRevealProps) { - const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"]) - - const hoverDelay = () => local.hoverDelay ?? 300 - - let containerRef: HTMLDivElement | undefined - let hoverTimeout: ReturnType | undefined - let scrollAnimationState: ScrollAnimationState | null = null - - const handleMouseEnter: JSX.EventHandler = () => { - hoverTimeout = setTimeout(() => { - if (!containerRef) return - - containerRef.offsetHeight - - const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1 - - if (isScrollable) { - stopScrollAnimation(scrollAnimationState, containerRef) - scrollAnimationState = startScrollAnimation(containerRef) - } - }, hoverDelay()) - } - - const handleMouseLeave: JSX.EventHandler = () => { - if (hoverTimeout) { - clearTimeout(hoverTimeout) - hoverTimeout = undefined - } - stopScrollAnimation(scrollAnimationState, containerRef) - scrollAnimationState = null - } - - onCleanup(() => { - if (hoverTimeout) { - clearTimeout(hoverTimeout) - } - stopScrollAnimation(scrollAnimationState, containerRef) - }) - - return ( - { - containerRef = el - local.ref?.(el) - }} - fadeStartSize={8} - fadeEndSize={8} - direction="horizontal" - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave} - {...others} - > - {local.children} - - ) -} diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index d5939b2b36a..55e1a16d18e 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -41,7 +41,6 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); -@import "../components/scroll-fade.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From 70cf609ce90a7534349c8dd5ed8441cbd32ebba7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:46:25 -0600 Subject: [PATCH 032/140] Revert "feat(ui): Select, dropdown, popover styles & transitions (#11675)" This reverts commit 377bf7ff21a4f05807c38675ac70cd08fe67b516. --- .../src/components/dialog-select-model.tsx | 7 +- packages/app/src/components/prompt-input.tsx | 125 ++++++---------- .../app/src/components/settings-general.tsx | 2 +- packages/ui/src/components/button.css | 26 ++-- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/cycle-label.css | 49 ------- packages/ui/src/components/cycle-label.tsx | 135 ------------------ packages/ui/src/components/dropdown-menu.css | 45 +++--- packages/ui/src/components/icon.tsx | 7 +- packages/ui/src/components/message-part.tsx | 6 +- packages/ui/src/components/morph-chevron.css | 10 -- packages/ui/src/components/morph-chevron.tsx | 73 ---------- packages/ui/src/components/popover.css | 58 ++------ packages/ui/src/components/reasoning-icon.css | 9 -- packages/ui/src/components/reasoning-icon.tsx | 46 ------ packages/ui/src/components/select.css | 87 +++++------ packages/ui/src/components/select.tsx | 18 +-- packages/ui/src/styles/index.css | 2 - packages/ui/src/styles/utilities.css | 42 ------ 19 files changed, 129 insertions(+), 620 deletions(-) delete mode 100644 packages/ui/src/components/cycle-label.css delete mode 100644 packages/ui/src/components/cycle-label.tsx delete mode 100644 packages/ui/src/components/morph-chevron.css delete mode 100644 packages/ui/src/components/morph-chevron.tsx delete mode 100644 packages/ui/src/components/reasoning-icon.css delete mode 100644 packages/ui/src/components/reasoning-icon.tsx diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 2135b1edf44..4f0dcc3ee65 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -90,10 +90,9 @@ const ModelList: Component<{ export function ModelSelectorPopover(props: { provider?: string - children?: JSX.Element | ((open: boolean) => JSX.Element) + children?: JSX.Element triggerAs?: T triggerProps?: ComponentProps - gutter?: number }) { const [store, setStore] = createStore<{ open: boolean @@ -176,14 +175,14 @@ export function ModelSelectorPopover(props: { }} modal={false} placement="top-start" - gutter={props.gutter ?? 8} + gutter={8} > setStore("trigger", el)} as={props.triggerAs ?? "div"} {...(props.triggerProps as any)} > - {typeof props.children === "function" ? props.children(store.open) : props.children} + {props.children} = (props) => { clearInput() client.session .shell({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, command: text, @@ -1280,7 +1277,7 @@ export const PromptInput: Component = (props) => { clearInput() client.session .command({ - sessionID: session?.id || "", + sessionID: session.id, command: commandName, arguments: args.join(" "), agent, @@ -1436,13 +1433,13 @@ export const PromptInput: Component = (props) => { const optimisticParts = requestParts.map((part) => ({ ...part, - sessionID: session?.id || "", + sessionID: session.id, messageID, })) as unknown as Part[] const optimisticMessage: Message = { id: messageID, - sessionID: session?.id || "", + sessionID: session.id, role: "user", time: { created: Date.now() }, agent, @@ -1453,9 +1450,9 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1471,9 +1468,9 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (!messages) { - draft.message[session?.id || ""] = [optimisticMessage] + draft.message[session.id] = [optimisticMessage] } else { const result = Binary.search(messages, messageID, (m) => m.id) messages.splice(result.index, 0, optimisticMessage) @@ -1490,7 +1487,7 @@ export const PromptInput: Component = (props) => { if (sessionDirectory === projectDirectory) { sync.set( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1503,7 +1500,7 @@ export const PromptInput: Component = (props) => { globalSync.child(sessionDirectory)[1]( produce((draft) => { - const messages = draft.message[session?.id || ""] + const messages = draft.message[session.id] if (messages) { const result = Binary.search(messages, messageID, (m) => m.id) if (result.found) messages.splice(result.index, 1) @@ -1524,15 +1521,15 @@ export const PromptInput: Component = (props) => { const worktree = WorktreeState.get(sessionDirectory) if (!worktree || worktree.status !== "pending") return true - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "busy" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "busy" }) } const controller = new AbortController() const cleanup = () => { - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } removeOptimisticMessage() for (const item of commentItems) { @@ -1549,7 +1546,7 @@ export const PromptInput: Component = (props) => { restoreInput() } - pending.set(session?.id || "", { abort: controller, cleanup }) + pending.set(session.id, { abort: controller, cleanup }) const abort = new Promise>>((resolve) => { if (controller.signal.aborted) { @@ -1577,7 +1574,7 @@ export const PromptInput: Component = (props) => { if (timer.id === undefined) return clearTimeout(timer.id) }) - pending.delete(session?.id || "") + pending.delete(session.id) if (controller.signal.aborted) return false if (result.status === "failed") throw new Error(result.message) return true @@ -1587,7 +1584,7 @@ export const PromptInput: Component = (props) => { const ok = await waitForWorktree() if (!ok) return await client.session.prompt({ - sessionID: session?.id || "", + sessionID: session.id, agent, model, messageID, @@ -1597,9 +1594,9 @@ export const PromptInput: Component = (props) => { } void send().catch((err) => { - pending.delete(session?.id || "") - if (sessionDirectory === projectDirectory && session?.id) { - sync.set("session_status", session?.id, { type: "idle" }) + pending.delete(session.id) + if (sessionDirectory === projectDirectory) { + sync.set("session_status", session.id, { type: "idle" }) } showToast({ title: language.t("prompt.toast.promptSendFailed.title"), @@ -1621,28 +1618,6 @@ export const PromptInput: Component = (props) => { }) } - const currrentModelVariant = createMemo(() => { - const modelVariant = local.model.variant.current() ?? "" - return modelVariant === "xhigh" - ? "xHigh" - : modelVariant.length > 0 - ? modelVariant[0].toUpperCase() + modelVariant.slice(1) - : "Default" - }) - - const reasoningPercentage = createMemo(() => { - const variants = local.model.variant.list() - const current = local.model.variant.current() - const totalEntries = variants.length + 1 - - if (totalEntries <= 2 || current === "Default") { - return 0 - } - - const currentIndex = current ? variants.indexOf(current) + 1 : 0 - return ((currentIndex + 1) / totalEntries) * 100 - }, [local.model.variant]) - return (
@@ -1695,7 +1670,7 @@ export const PromptInput: Component = (props) => { } > - + @{(item as { type: "agent"; name: string }).name} @@ -1760,9 +1735,9 @@ export const PromptInput: Component = (props) => { }} > -
+
- + {language.t("prompt.dropzone.label")}
@@ -1848,7 +1823,7 @@ export const PromptInput: Component = (props) => { when={attachment.mime.startsWith("image/")} fallback={
- +
} > @@ -1922,7 +1897,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1943,7 +1918,6 @@ export const PromptInput: Component = (props) => { onSelect={local.agent.set} class="capitalize" variant="ghost" - gutter={12} /> = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - } @@ -1976,16 +1943,12 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - {(open) => ( - <> - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - - - )} + + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + @@ -1998,13 +1961,10 @@ export const PromptInput: Component = (props) => { @@ -2018,7 +1978,7 @@ export const PromptInput: Component = (props) => { variant="ghost" onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)} classList={{ - "_hidden group-hover/prompt-input:flex items-center justify-center": true, + "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true, "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory), "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory), }} @@ -2040,7 +2000,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
@@ -2083,7 +2042,7 @@ export const PromptInput: Component = (props) => {
{language.t("prompt.action.send")} - +
@@ -2094,7 +2053,7 @@ export const PromptInput: Component = (props) => { disabled={!prompt.dirty() && !working()} icon={working() ? "stop" : "arrow-up"} variant="primary" - class="h-6 w-5.5" + class="h-6 w-4.5" aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")} /> diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 94813871e4c..b31cfb6cc79 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => { variant="secondary" size="small" triggerVariant="settings" - triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }} + triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }} > {(option) => ( diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index afff0c476b5..d9b34592304 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -9,13 +9,7 @@ user-select: none; cursor: default; outline: none; - padding: 4px 8px; white-space: nowrap; - transition-property: background-color, border-color, color, box-shadow, opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - outline: none; - line-height: 20px; &[data-variant="primary"] { background-color: var(--button-primary-base); @@ -100,6 +94,7 @@ &:active:not(:disabled) { background-color: var(--button-secondary-base); scale: 0.99; + transition: all 150ms ease-out; } &:disabled { border-color: var(--border-disabled); @@ -115,32 +110,33 @@ &[data-size="small"] { height: 22px; - padding: 4px 8px; + padding: 0 8px; &[data-icon] { - padding: 4px 12px 4px 4px; + padding: 0 12px 0 4px; } + font-size: var(--font-size-small); + line-height: var(--line-height-large); gap: 4px; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } &[data-size="normal"] { height: 24px; - padding: 4px 6px; + line-height: 24px; + padding: 0 6px; &[data-icon] { - padding: 4px 12px 4px 4px; - } - - &[aria-haspopup] { - padding: 4px 6px 4px 8px; + padding: 0 12px 0 4px; } + font-size: var(--font-size-small); gap: 6px; /* text-12-medium */ diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index b2d2004d3c8..7f974b2f76e 100644 --- a/packages/ui/src/components/button.tsx +++ b/packages/ui/src/components/button.tsx @@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon" export interface ButtonProps extends ComponentProps, - Pick, "class" | "classList" | "children" | "style"> { + Pick, "class" | "classList" | "children"> { size?: "small" | "normal" | "large" variant?: "primary" | "secondary" | "ghost" icon?: IconProps["name"] diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css deleted file mode 100644 index 3c98fcd261e..00000000000 --- a/packages/ui/src/components/cycle-label.css +++ /dev/null @@ -1,49 +0,0 @@ -.cycle-label { - --c-duration: 200ms; - --c-stagger: 30ms; - --c-opacity-start: 0; - --c-opacity-end: 1; - --c-blur-start: 0px; - --c-blur-end: 0px; - --c-skew: 10deg; - - display: inline-flex; - position: relative; - - transform-style: preserve-3d; - perspective: 500px; - transition: width var(--transition-duration) var(--transition-easing); - will-change: width; - overflow: hidden; - - .cycle-char { - display: inline-block; - transform-style: preserve-3d; - min-width: 0.25em; - backface-visibility: hidden; - - transition-property: transform, opacity, filter; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - transition-delay: calc(var(--i, 0) * var(--c-stagger)); - - &.enter { - opacity: var(--c-opacity-end); - filter: blur(var(--c-blur-end)); - transform: translateY(0) rotateX(0) skewX(0); - } - - &.exit { - opacity: var(--c-opacity-start); - filter: blur(var(--c-blur-start)); - transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew)); - } - - &.pre { - opacity: var(--c-opacity-start); - filter: blur(var(--c-blur-start)); - transition: none; - transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1)); - } - } -} diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx deleted file mode 100644 index dc12bd75c87..00000000000 --- a/packages/ui/src/components/cycle-label.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import "./cycle-label.css" -import { createEffect, createSignal, JSX, on } from "solid-js" - -export interface CycleLabelProps extends JSX.HTMLAttributes { - value: string - onValueChange?: (value: string) => void - duration?: number | ((value: string) => number) - stagger?: number - opacity?: [number, number] - blur?: [number, number] - skewX?: number - onAnimationStart?: () => void - onAnimationEnd?: () => void -} - -const segmenter = - typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null - -const getChars = (text: string): string[] => - segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("") - -const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) - -export function CycleLabel(props: CycleLabelProps) { - const getDuration = (text: string) => { - const d = - props.duration ?? - Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? - 200 - return typeof d === "function" ? d(text) : d - } - const stagger = () => props?.stagger ?? 30 - const opacity = () => props?.opacity ?? [0, 1] - const blur = () => props?.blur ?? [0, 0] - const skewX = () => props?.skewX ?? 10 - - let containerRef: HTMLSpanElement | undefined - let isAnimating = false - const [currentText, setCurrentText] = createSignal(props.value) - - const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => { - el.innerHTML = "" - const chars = getChars(text) - chars.forEach((char, i) => { - const span = document.createElement("span") - span.textContent = char === " " ? "\u00A0" : char - span.className = `cycle-char ${state}` - span.style.setProperty("--i", String(i)) - el.appendChild(span) - }) - } - - const animateToText = async (newText: string) => { - if (!containerRef || isAnimating) return - if (newText === currentText()) return - - isAnimating = true - props.onAnimationStart?.() - - const dur = getDuration(newText) - const stag = stagger() - - containerRef.style.width = containerRef.offsetWidth + "px" - - const oldChars = containerRef.querySelectorAll(".cycle-char") - oldChars.forEach((c) => c.classList.replace("enter", "exit")) - - const clone = containerRef.cloneNode(false) as HTMLElement - Object.assign(clone.style, { - position: "absolute", - visibility: "hidden", - width: "auto", - transition: "none", - }) - setChars(clone, newText) - document.body.appendChild(clone) - const nextWidth = clone.offsetWidth - clone.remove() - - const exitTime = oldChars.length * stag + dur - await wait(exitTime * 0.3) - - containerRef.style.width = nextWidth + "px" - - const widthDur = 200 - await wait(widthDur * 0.3) - - setChars(containerRef, newText, "pre") - containerRef.offsetWidth - - Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter")) - setCurrentText(newText) - props.onValueChange?.(newText) - - const enterTime = getChars(newText).length * stag + dur - await wait(enterTime) - - containerRef.style.width = "" - isAnimating = false - props.onAnimationEnd?.() - } - - createEffect( - on( - () => props.value, - (newValue) => { - if (newValue !== currentText()) { - animateToText(newValue) - } - }, - ), - ) - - const initRef = (el: HTMLSpanElement) => { - containerRef = el - setChars(el, props.value) - } - - return ( - - ) -} diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index 18266ac1a1c..cba041613ea 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -2,29 +2,26 @@ [data-component="dropdown-menu-sub-content"] { min-width: 8rem; overflow: hidden; - border: none; border-radius: var(--radius-md); - box-shadow: var(--shadow-xs-border); + border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent); background-clip: padding-box; background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; - z-index: 100; + box-shadow: var(--shadow-md); + z-index: 50; transform-origin: var(--kb-menu-content-transform-origin); - &:focus-within, - &:focus { + &:focus, + &:focus-visible { outline: none; } - animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; + &[data-closed] { + animation: dropdown-menu-close 0.15s ease-out; } &[data-expanded] { - pointer-events: auto; - animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards; + animation: dropdown-menu-open 0.15s ease-out; } } @@ -41,22 +38,18 @@ padding: 4px 8px; border-radius: var(--radius-sm); cursor: default; + user-select: none; outline: none; font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-weight: var(--font-weight-medium); line-height: var(--line-height-large); letter-spacing: var(--letter-spacing-normal); color: var(--text-strong); - transition-property: background-color, color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); - user-select: none; - - &:hover { - background-color: var(--surface-raised-base-hover); + &[data-highlighted] { + background: var(--surface-raised-base-hover); } &[data-disabled] { @@ -68,8 +61,6 @@ [data-slot="dropdown-menu-sub-trigger"] { &[data-expanded] { background: var(--surface-raised-base-hover); - outline: none; - border: none; } } @@ -111,24 +102,24 @@ } } -@keyframes dropdownMenuContentShow { +@keyframes dropdown-menu-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } to { opacity: 1; - transform: scaleY(1); + transform: scale(1); } } -@keyframes dropdownMenuContentHide { +@keyframes dropdown-menu-close { from { opacity: 1; - transform: scaleY(1); + transform: scale(1); } to { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 97488a42f0f..544c6abdd21 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -80,16 +80,13 @@ const icons = { export interface IconProps extends ComponentProps<"svg"> { name: keyof typeof icons - size?: "small" | "normal" | "medium" | "large" | number + size?: "small" | "normal" | "medium" | "large" } export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
- +
m.id === perm.tool!.messageID) + const message = findLast(messages, (m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css deleted file mode 100644 index f6edb3f649a..00000000000 --- a/packages/ui/src/components/morph-chevron.css +++ /dev/null @@ -1,10 +0,0 @@ -[data-slot="morph-chevron-svg"] { - width: 16px; - height: 16px; - display: block; - fill: none; - stroke-width: 1.5; - stroke: currentcolor; - stroke-linecap: round; - stroke-linejoin: round; -} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx deleted file mode 100644 index 280aeb7e34e..00000000000 --- a/packages/ui/src/components/morph-chevron.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { createEffect, createUniqueId, on } from "solid-js" - -export interface MorphChevronProps { - expanded: boolean - class?: string -} - -const COLLAPSED = "M4 6L8 10L12 6" -const EXPANDED = "M4 10L8 6L12 10" - -export function MorphChevron(props: MorphChevronProps) { - const id = createUniqueId() - let path: SVGPathElement | undefined - let expandAnim: SVGAnimateElement | undefined - let collapseAnim: SVGAnimateElement | undefined - - createEffect( - on( - () => props.expanded, - (expanded, prev) => { - if (prev === undefined) { - // Set initial state without animation - path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED) - return - } - if (expanded) { - expandAnim?.beginElement() - } else { - collapseAnim?.beginElement() - } - }, - ), - ) - - return ( - - ) -} diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index d200fe8b247..b49542afd9b 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,35 +15,16 @@ transform-origin: var(--kb-popover-content-transform-origin); - animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; - } - - &[data-expanded] { - pointer-events: auto; - animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; - } - - [data-origin-top-right] { - transform-origin: top right; - } - - [data-origin-top-left] { - transform-origin: top left; - } - - [data-origin-bottom-right] { - transform-origin: bottom right; + &:focus-within { + outline: none; } - [data-origin-bottom-left] { - transform-origin: bottom left; + &[data-closed] { + animation: popover-close 0.15s ease-out; } - &:focus-within { - outline: none; + &[data-expanded] { + animation: popover-open 0.15s ease-out; } [data-slot="popover-header"] { @@ -94,39 +75,24 @@ } } -@keyframes popoverContentShow { +@keyframes popover-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.96); } to { opacity: 1; - transform: scaleY(1); + transform: scale(1); } } -@keyframes popoverContentHide { +@keyframes popover-close { from { opacity: 1; - transform: scaleY(1); + transform: scale(1); } to { opacity: 0; - transform: scaleY(0.95); - } -} - -[data-component="model-popover-content"] { - transform-origin: var(--kb-popper-content-transform-origin); - pointer-events: none; - animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; - } - - &[data-expanded] { - pointer-events: auto; - animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; + transform: scale(0.96); } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css deleted file mode 100644 index 26fbc014489..00000000000 --- a/packages/ui/src/components/reasoning-icon.css +++ /dev/null @@ -1,9 +0,0 @@ -[data-component="reasoning-icon"] { - color: var(--icon-strong-base); - - [data-slot="reasoning-icon-percentage"] { - transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1); - clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0); - opacity: calc(var(--reasoning-icon-percentage) * 0.75); - } -} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx deleted file mode 100644 index 7bac49ffd2e..00000000000 --- a/packages/ui/src/components/reasoning-icon.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { type ComponentProps, splitProps } from "solid-js" - -export interface ReasoningIconProps extends Pick, "class" | "classList"> { - percentage: number - size?: number - strokeWidth?: number -} - -export function ReasoningIcon(props: ReasoningIconProps) { - const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) - - const size = () => split.size || 16 - const strokeWidth = () => split.strokeWidth || 1.25 - - return ( - - - - - ) -} diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index eaba6fd6d2a..25dd2eb40b6 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,13 +1,7 @@ [data-component="select"] { [data-slot="select-select-trigger"] { - display: flex; - padding: 4px 8px !important; - align-items: center; - justify-content: space-between; + padding: 0 4px 0 8px; box-shadow: none; - transition-property: background-color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -21,10 +15,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--icon-base); + color: var(--text-weak); + transition: transform 0.1s ease-in-out; } - &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -36,13 +30,13 @@ background-color: var(--icon-strong-active); } } - &:not([data-expanded]):focus, + &:not([data-expanded]):focus-visible { &[data-variant="secondary"] { background-color: var(--button-secondary-base); } &[data-variant="ghost"] { - background-color: transparent; + background-color: var(--surface-raised-base-hover); } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -52,10 +46,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 10px; + padding: 6px 6px 6px 12px; box-shadow: none; border-radius: 6px; - field-sizing: content; + min-width: 160px; height: 32px; justify-content: flex-end; gap: 12px; @@ -67,7 +61,6 @@ white-space: nowrap; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); - padding: 4px 8px 4px 4px; } [data-slot="select-select-trigger-icon"] { width: 16px; @@ -98,26 +91,17 @@ } [data-component="select-content"] { - min-width: 8rem; + min-width: 104px; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 50; - transform-origin: var(--kb-popper-content-transform-origin); - pointer-events: none; - - animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards; - - @starting-style { - animation: none; - } + z-index: 60; &[data-expanded] { - pointer-events: auto; - animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; + animation: select-open 0.15s ease-out; } [data-slot="select-select-content-list"] { @@ -127,38 +111,43 @@ overflow-x: hidden; display: flex; flex-direction: column; + &:focus { outline: none; } + > *:not([role="presentation"]) + *:not([role="presentation"]) { margin-top: 2px; } } + [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 4px 8px; + padding: 2px 8px; gap: 12px; - border-radius: var(--radius-sm); + border-radius: 4px; + cursor: default; /* text-12-medium */ font-family: var(--font-family-sans); - font-size: var(--font-size-base); + font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); - transition-property: background-color, color; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); + transition: + background-color 0.2s ease-in-out, + color 0.2s ease-in-out; outline: none; user-select: none; - &:hover { - background-color: var(--surface-raised-base-hover); + &[data-highlighted] { + background: var(--surface-raised-base-hover); } &[data-disabled] { background-color: var(--surface-raised-base); @@ -171,11 +160,6 @@ margin-left: auto; width: 16px; height: 16px; - color: var(--icon-strong-base); - - svg { - color: var(--icon-strong-base); - } } &:focus { outline: none; @@ -187,9 +171,13 @@ } [data-component="select-content"][data-trigger-style="settings"] { - field-sizing: content; + min-width: 160px; border-radius: 8px; - padding: 0 0 0 4px; + padding: 0; + + [data-slot="select-select-content-list"] { + padding: 4px; + } [data-slot="select-select-item"] { /* text-14-regular */ @@ -202,24 +190,13 @@ } } -@keyframes selectContentShow { +@keyframes select-open { from { opacity: 0; - transform: scaleY(0.95); + transform: scale(0.95); } to { opacity: 1; - transform: scaleY(1); - } -} - -@keyframes selectContentHide { - from { - opacity: 1; - transform: scaleY(1); - } - to { - opacity: 0; - transform: scaleY(0.95); + transform: scale(1); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index fef00500a71..0386c329ec4 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,10 +1,8 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" -import { Show } from "solid-js" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" -import { MorphChevron } from "./morph-chevron" export type SelectProps = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -40,8 +38,6 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) - const [isOpen, setIsOpen] = createSignal(false) - const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -89,7 +85,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={8} + gutter={4} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -119,7 +115,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -128,7 +124,6 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { - setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -154,12 +149,7 @@ export function Select(props: SelectProps & Omit) }} - - - - - - + diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 55e1a16d18e..c038f69f671 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -49,8 +49,6 @@ @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); -@import "../components/morph-chevron.css" layer(components); -@import "../components/reasoning-icon.css" layer(components); @import "./utilities.css" layer(utilities); @import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 82a913c8830..8c954f1fe4e 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,17 +1,6 @@ :root { interpolate-size: allow-keywords; - /* Transition tokens */ - --transition-duration: 200ms; - --transition-easing: cubic-bezier(0.25, 0, 0.5, 1); - --transition-fast: 150ms; - --transition-slow: 300ms; - - /* Allow height transitions from 0 to auto */ - @supports (interpolate-size: allow-keywords) { - interpolate-size: allow-keywords; - } - [data-popper-positioner] { pointer-events: none; } @@ -140,34 +129,3 @@ line-height: var(--line-height-x-large); /* 120% */ letter-spacing: var(--letter-spacing-tightest); } - -/* Transition utility classes */ -.transition-colors { - transition-property: background-color, border-color, color, fill, stroke; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-opacity { - transition-property: opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-transform { - transition-property: transform; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-shadow { - transition-property: box-shadow; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} - -.transition-interactive { - transition-property: background-color, border-color, color, box-shadow, opacity; - transition-duration: var(--transition-duration); - transition-timing-function: var(--transition-easing); -} From 0405b425f528ce9042ff0eeb511512e239cb1b5f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 09:40:18 -0600 Subject: [PATCH 033/140] feat(app): file search --- packages/ui/src/components/code.tsx | 462 +++++++++++++++++++++++++++- packages/ui/src/pierre/index.ts | 8 + 2 files changed, 467 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index dbf942dbb6c..38dfcd8380d 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,7 +1,8 @@ import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" -import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" +import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" import { createDefaultOptions, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" +import { Icon } from "./icon" type SelectionSide = "additions" | "deletions" @@ -46,8 +47,88 @@ function findSide(node: Node | null): SelectionSide | undefined { return "additions" } +type FindHost = { + element: () => HTMLElement | undefined + open: () => void + close: () => void + next: (dir: 1 | -1) => void + isOpen: () => boolean +} + +const findHosts = new Set() +let findTarget: FindHost | undefined +let findCurrent: FindHost | undefined +let findInstalled = false + +function isEditable(node: unknown): boolean { + if (!(node instanceof HTMLElement)) return false + if (node.closest("[data-prevent-autofocus]")) return true + if (node.isContentEditable) return true + return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName) +} + +function hostForNode(node: unknown): FindHost | undefined { + if (!(node instanceof Node)) return + for (const host of findHosts) { + const el = host.element() + if (el && el.isConnected && el.contains(node)) return host + } +} + +function installFindShortcuts() { + if (findInstalled) return + if (typeof window === "undefined") return + findInstalled = true + + window.addEventListener( + "keydown", + (event) => { + if (event.defaultPrevented) return + + const mod = event.metaKey || event.ctrlKey + if (!mod) return + + const key = event.key.toLowerCase() + + if (key === "g") { + const host = findCurrent + if (!host || !host.isOpen()) return + event.preventDefault() + event.stopPropagation() + host.next(event.shiftKey ? -1 : 1) + return + } + + if (key !== "f") return + + const current = findCurrent + if (current && current.isOpen()) { + event.preventDefault() + event.stopPropagation() + current.open() + return + } + + if (isEditable(event.target)) return + + const host = hostForNode(document.activeElement) ?? findTarget ?? Array.from(findHosts)[0] + if (!host) return + + event.preventDefault() + event.stopPropagation() + host.open() + }, + { capture: true }, + ) +} + export function Code(props: CodeProps) { + let wrapper!: HTMLDivElement let container!: HTMLDivElement + let findInput: HTMLInputElement | undefined + let findOverlay!: HTMLDivElement + let findOverlayFrame: number | undefined + let findOverlayScroll: HTMLElement[] = [] let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -70,6 +151,13 @@ export function Code(props: CodeProps) { const [rendered, setRendered] = createSignal(0) + const [findOpen, setFindOpen] = createSignal(false) + const [findQuery, setFindQuery] = createSignal("") + const [findIndex, setFindIndex] = createSignal(0) + const [findCount, setFindCount] = createSignal(0) + let findMode: "highlights" | "overlay" = "overlay" + let findHits: Range[] = [] + const file = createMemo( () => new File( @@ -104,6 +192,296 @@ export function Code(props: CodeProps) { host.removeAttribute("data-color-scheme") } + const supportsHighlights = () => { + const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown } + return typeof g.Highlight === "function" && g.CSS?.highlights != null + } + + const clearHighlightFind = () => { + const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights + if (!api) return + api.delete("opencode-find") + api.delete("opencode-find-current") + } + + const clearOverlayScroll = () => { + for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay) + findOverlayScroll = [] + } + + const clearOverlay = () => { + if (findOverlayFrame !== undefined) { + cancelAnimationFrame(findOverlayFrame) + findOverlayFrame = undefined + } + findOverlay.innerHTML = "" + } + + const renderOverlay = () => { + if (findMode !== "overlay") { + clearOverlay() + return + } + + clearOverlay() + if (findHits.length === 0) return + + const base = wrapper.getBoundingClientRect() + const current = findIndex() + + const frag = document.createDocumentFragment() + for (let i = 0; i < findHits.length; i++) { + const range = findHits[i] + const active = i === current + + for (const rect of Array.from(range.getClientRects())) { + if (!rect.width || !rect.height) continue + + const el = document.createElement("div") + el.style.position = "absolute" + el.style.left = `${Math.round(rect.left - base.left)}px` + el.style.top = `${Math.round(rect.top - base.top)}px` + el.style.width = `${Math.round(rect.width)}px` + el.style.height = `${Math.round(rect.height)}px` + el.style.borderRadius = "2px" + el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)" + el.style.opacity = active ? "0.55" : "0.35" + if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)" + frag.appendChild(el) + } + } + + findOverlay.appendChild(frag) + } + + function scheduleOverlay() { + if (findMode !== "overlay") return + if (!findOpen()) return + if (findOverlayFrame !== undefined) return + + findOverlayFrame = requestAnimationFrame(() => { + findOverlayFrame = undefined + renderOverlay() + }) + } + + const syncOverlayScroll = () => { + if (findMode !== "overlay") return + const root = getRoot() + + const next = root + ? Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + : [] + if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return + + clearOverlayScroll() + findOverlayScroll = next + for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true }) + } + + const clearFind = () => { + clearHighlightFind() + clearOverlay() + clearOverlayScroll() + findHits = [] + setFindCount(0) + setFindIndex(0) + } + + const scanFind = (root: ShadowRoot, query: string) => { + const needle = query.toLowerCase() + const out: Range[] = [] + + const cols = Array.from(root.querySelectorAll("[data-column-content]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + + for (const col of cols) { + const text = col.textContent + if (!text) continue + + const hay = text.toLowerCase() + let idx = hay.indexOf(needle) + if (idx === -1) continue + + const nodes: Text[] = [] + const ends: number[] = [] + const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() + let pos = 0 + + while (node) { + if (node instanceof Text) { + pos += node.data.length + nodes.push(node) + ends.push(pos) + } + node = walker.nextNode() + } + + if (nodes.length === 0) continue + + const locate = (at: number) => { + let lo = 0 + let hi = ends.length - 1 + while (lo < hi) { + const mid = (lo + hi) >> 1 + if (ends[mid] >= at) hi = mid + else lo = mid + 1 + } + const prev = lo === 0 ? 0 : ends[lo - 1] + return { node: nodes[lo], offset: at - prev } + } + + while (idx !== -1) { + const start = locate(idx) + const end = locate(idx + query.length) + const range = document.createRange() + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset) + out.push(range) + idx = hay.indexOf(needle, idx + query.length) + } + } + + return out + } + + const scrollToRange = (range: Range) => { + const start = range.startContainer + const el = start instanceof Element ? start : start.parentElement + el?.scrollIntoView({ block: "center", inline: "center" }) + } + + const setHighlights = (ranges: Range[], index: number) => { + const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights + const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight + if (!api || typeof Highlight !== "function") return false + + api.delete("opencode-find") + api.delete("opencode-find-current") + + const active = ranges[index] + if (active) api.set("opencode-find-current", new Highlight(active)) + + const rest = ranges.filter((_, i) => i !== index) + if (rest.length > 0) api.set("opencode-find", new Highlight(...rest)) + return true + } + + const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => { + if (!findOpen()) return + + const query = findQuery().trim() + if (!query) { + clearFind() + return + } + + const root = getRoot() + if (!root) return + + findMode = supportsHighlights() ? "highlights" : "overlay" + + const ranges = scanFind(root, query) + const total = ranges.length + const desired = opts?.reset ? 0 : findIndex() + const index = total ? Math.min(desired, total - 1) : 0 + + findHits = ranges + setFindCount(total) + setFindIndex(index) + + const active = ranges[index] + if (findMode === "highlights") { + clearOverlay() + clearOverlayScroll() + if (!setHighlights(ranges, index)) { + findMode = "overlay" + clearHighlightFind() + syncOverlayScroll() + scheduleOverlay() + } + if (opts?.scroll && active) scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + if (opts?.scroll && active) scrollToRange(active) + scheduleOverlay() + } + + const closeFind = () => { + setFindOpen(false) + clearFind() + if (findCurrent === host) findCurrent = undefined + } + + const stepFind = (dir: 1 | -1) => { + if (!findOpen()) return + const total = findCount() + if (total <= 0) return + + const index = (findIndex() + dir + total) % total + setFindIndex(index) + + const active = findHits[index] + if (!active) return + + if (findMode === "highlights") { + if (!setHighlights(findHits, index)) { + findMode = "overlay" + applyFind({ reset: true, scroll: true }) + return + } + scrollToRange(active) + return + } + + clearHighlightFind() + syncOverlayScroll() + scrollToRange(active) + scheduleOverlay() + } + + const host: FindHost = { + element: () => wrapper, + isOpen: () => findOpen(), + next: stepFind, + open: () => { + if (findCurrent && findCurrent !== host) findCurrent.close() + findCurrent = host + findTarget = host + + if (!findOpen()) setFindOpen(true) + requestAnimationFrame(() => { + findInput?.focus() + findInput?.select() + }) + applyFind({ scroll: true }) + }, + close: closeFind, + } + + onMount(() => { + findMode = supportsHighlights() ? "highlights" : "overlay" + installFindShortcuts() + findHosts.add(host) + if (!findTarget) findTarget = host + + onCleanup(() => { + findHosts.delete(host) + if (findCurrent === host) { + findCurrent = undefined + clearHighlightFind() + } + if (findTarget === host) findTarget = undefined + }) + }) + const applyCommentedLines = (ranges: SelectedLineRange[]) => { const root = getRoot() if (!root) return @@ -189,6 +567,7 @@ export function Code(props: CodeProps) { requestAnimationFrame(() => { if (token !== renderToken) return applySelection(lastSelection) + applyFind({ reset: true }) local.onRendered?.() }) } @@ -466,6 +845,13 @@ export function Code(props: CodeProps) { onCleanup(() => { observer?.disconnect() + clearOverlayScroll() + clearOverlay() + if (findCurrent === host) { + findCurrent = undefined + clearHighlightFind() + } + if (selectionFrame !== undefined) { cancelAnimationFrame(selectionFrame) selectionFrame = undefined @@ -487,11 +873,81 @@ export function Code(props: CodeProps) {
+ ref={wrapper} + tabIndex={0} + onPointerDown={() => { + findTarget = host + wrapper.focus({ preventScroll: true }) + }} + onFocus={() => { + findTarget = host + }} + > +
+
+ +
e.stopPropagation()} + > + + { + setFindQuery(e.currentTarget.value) + setFindIndex(0) + applyFind({ reset: true, scroll: true }) + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + closeFind() + return + } + if (e.key !== "Enter") return + e.preventDefault() + stepFind(e.shiftKey ? -1 : 1) + }} + /> +
+ {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} +
+ + + +
+
+
) } diff --git a/packages/ui/src/pierre/index.ts b/packages/ui/src/pierre/index.ts index f0da5197938..f6446f3cc85 100644 --- a/packages/ui/src/pierre/index.ts +++ b/packages/ui/src/pierre/index.ts @@ -57,6 +57,14 @@ const unsafeCSS = ` background-color: var(--diffs-bg-selection-text); } +::highlight(opencode-find) { + background-color: rgb(from var(--surface-warning-base) r g b / 0.35); +} + +::highlight(opencode-find-current) { + 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] { box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection); } From 69f5f657f2b3b98d213a7bedd46624cda0e78bcd Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:42:05 -0600 Subject: [PATCH 034/140] chore: cleanup --- packages/ui/src/components/code.tsx | 118 +++++++++++++++++++++------- 1 file changed, 91 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 38dfcd8380d..0b0646f0ee2 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -109,9 +109,8 @@ function installFindShortcuts() { return } - if (isEditable(event.target)) return - - const host = hostForNode(document.activeElement) ?? findTarget ?? Array.from(findHosts)[0] + const host = + hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0] if (!host) return event.preventDefault() @@ -126,9 +125,11 @@ export function Code(props: CodeProps) { let wrapper!: HTMLDivElement let container!: HTMLDivElement let findInput: HTMLInputElement | undefined + let findBar: HTMLDivElement | undefined let findOverlay!: HTMLDivElement let findOverlayFrame: number | undefined let findOverlayScroll: HTMLElement[] = [] + let findPositionFrame: number | undefined let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -290,6 +291,41 @@ export function Code(props: CodeProps) { setFindIndex(0) } + const getScrollParent = (el: HTMLElement): HTMLElement | null => { + let parent = el.parentElement + while (parent) { + const style = getComputedStyle(parent) + if (style.overflowY === "auto" || style.overflowY === "scroll") return parent + parent = parent.parentElement + } + return null + } + + const positionFindBar = () => { + if (!findBar || !wrapper) return + const scrollParent = getScrollParent(wrapper) + if (!scrollParent) { + findBar.style.position = "absolute" + findBar.style.top = "8px" + findBar.style.right = "8px" + findBar.style.left = "" + return + } + const scrollTop = scrollParent.scrollTop + findBar.style.position = "absolute" + findBar.style.top = `${scrollTop + 8}px` + findBar.style.right = "8px" + findBar.style.left = "" + } + + const scheduleFindPosition = () => { + if (findPositionFrame !== undefined) return + findPositionFrame = requestAnimationFrame(() => { + findPositionFrame = undefined + positionFindBar() + }) + } + const scanFind = (root: ShadowRoot, query: string) => { const needle = query.toLowerCase() const out: Range[] = [] @@ -458,6 +494,7 @@ export function Code(props: CodeProps) { if (!findOpen()) setFindOpen(true) requestAnimationFrame(() => { + positionFindBar() findInput?.focus() findInput?.select() }) @@ -482,6 +519,25 @@ export function Code(props: CodeProps) { }) }) + createEffect(() => { + if (!findOpen()) return + const scrollParent = getScrollParent(wrapper) + const target = scrollParent ?? window + + const handler = () => scheduleFindPosition() + target.addEventListener("scroll", handler, { passive: true }) + window.addEventListener("resize", handler, { passive: true }) + + onCleanup(() => { + target.removeEventListener("scroll", handler) + window.removeEventListener("resize", handler) + if (findPositionFrame !== undefined) { + cancelAnimationFrame(findPositionFrame) + findPositionFrame = undefined + } + }) + }) + const applyCommentedLines = (ranges: SelectedLineRange[]) => { const root = getRoot() if (!root) return @@ -862,6 +918,11 @@ export function Code(props: CodeProps) { dragFrame = undefined } + if (findPositionFrame !== undefined) { + cancelAnimationFrame(findPositionFrame) + findPositionFrame = undefined + } + dragStart = undefined dragEnd = undefined dragMoved = false @@ -888,19 +949,18 @@ export function Code(props: CodeProps) { findTarget = host }} > -
-
e.stopPropagation()} > - + { setFindQuery(e.currentTarget.value) setFindIndex(0) @@ -917,30 +977,32 @@ export function Code(props: CodeProps) { stepFind(e.shiftKey ? -1 : 1) }} /> -
+
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
+
+ + +
- -
+
+
) } From befb5d54fbfd8df9706c49159095b1ef7f2ec23d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 12:24:27 -0600 Subject: [PATCH 035/140] chore: cleanup --- packages/ui/src/components/code.tsx | 54 +++++++++++------------------ 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index 0b0646f0ee2..e3e1e565202 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -129,7 +129,7 @@ export function Code(props: CodeProps) { let findOverlay!: HTMLDivElement let findOverlayFrame: number | undefined let findOverlayScroll: HTMLElement[] = [] - let findPositionFrame: number | undefined + let findScroll: HTMLElement | undefined let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -303,29 +303,13 @@ export function Code(props: CodeProps) { const positionFindBar = () => { if (!findBar || !wrapper) return - const scrollParent = getScrollParent(wrapper) - if (!scrollParent) { - findBar.style.position = "absolute" - findBar.style.top = "8px" - findBar.style.right = "8px" - findBar.style.left = "" - return - } - const scrollTop = scrollParent.scrollTop + const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY findBar.style.position = "absolute" findBar.style.top = `${scrollTop + 8}px` findBar.style.right = "8px" findBar.style.left = "" } - const scheduleFindPosition = () => { - if (findPositionFrame !== undefined) return - findPositionFrame = requestAnimationFrame(() => { - findPositionFrame = undefined - positionFindBar() - }) - } - const scanFind = (root: ShadowRoot, query: string) => { const needle = query.toLowerCase() const out: Range[] = [] @@ -440,13 +424,19 @@ export function Code(props: CodeProps) { syncOverlayScroll() scheduleOverlay() } - if (opts?.scroll && active) scrollToRange(active) + if (opts?.scroll && active) { + scrollToRange(active) + positionFindBar() + } return } clearHighlightFind() syncOverlayScroll() - if (opts?.scroll && active) scrollToRange(active) + if (opts?.scroll && active) { + scrollToRange(active) + positionFindBar() + } scheduleOverlay() } @@ -474,12 +464,14 @@ export function Code(props: CodeProps) { return } scrollToRange(active) + positionFindBar() return } clearHighlightFind() syncOverlayScroll() scrollToRange(active) + positionFindBar() scheduleOverlay() } @@ -492,13 +484,14 @@ export function Code(props: CodeProps) { findCurrent = host findTarget = host + findScroll = getScrollParent(wrapper) ?? undefined if (!findOpen()) setFindOpen(true) requestAnimationFrame(() => { + applyFind({ scroll: true }) positionFindBar() findInput?.focus() findInput?.select() }) - applyFind({ scroll: true }) }, close: closeFind, } @@ -521,20 +514,18 @@ export function Code(props: CodeProps) { createEffect(() => { if (!findOpen()) return - const scrollParent = getScrollParent(wrapper) - const target = scrollParent ?? window + findScroll = getScrollParent(wrapper) ?? undefined + const target = findScroll ?? window - const handler = () => scheduleFindPosition() + const handler = () => positionFindBar() target.addEventListener("scroll", handler, { passive: true }) window.addEventListener("resize", handler, { passive: true }) + handler() onCleanup(() => { target.removeEventListener("scroll", handler) window.removeEventListener("resize", handler) - if (findPositionFrame !== undefined) { - cancelAnimationFrame(findPositionFrame) - findPositionFrame = undefined - } + findScroll = undefined }) }) @@ -918,11 +909,6 @@ export function Code(props: CodeProps) { dragFrame = undefined } - if (findPositionFrame !== undefined) { - cancelAnimationFrame(findPositionFrame) - findPositionFrame = undefined - } - dragStart = undefined dragEnd = undefined dragMoved = false @@ -977,7 +963,7 @@ export function Code(props: CodeProps) { stepFind(e.shiftKey ? -1 : 1) }} /> -
+
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
From c002ca03ba7a617090ab104c5d2a07f1c8be2958 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 14:22:10 -0600 Subject: [PATCH 036/140] feat(app): search through sessions --- packages/app/src/pages/layout.tsx | 410 +++++++++++++++++++++------- packages/ui/src/components/list.tsx | 33 ++- 2 files changed, 343 insertions(+), 100 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5d285c5ecca..fe8618b7391 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -27,6 +27,7 @@ import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { InlineInput } from "@opencode-ai/ui/inline-input" +import { List, type ListRef } from "@opencode-ai/ui/list" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { HoverCard } from "@opencode-ai/ui/hover-card" import { MessageNav } from "@opencode-ai/ui/message-nav" @@ -2682,6 +2683,14 @@ export default function Layout(props: ParentProps) { } const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { + type SearchItem = { + id: string + title: string + directory: string + label: string + archived?: number + } + const projectName = createMemo(() => { const project = panelProps.project if (!project) return "" @@ -2697,6 +2706,107 @@ export default function Layout(props: ParentProps) { }) const homedir = createMemo(() => globalSync.data.path.home) + const [search, setSearch] = createStore({ + value: "", + }) + const searching = createMemo(() => search.value.trim().length > 0) + let searchRef: HTMLInputElement | undefined + let listRef: ListRef | undefined + + const token = { value: 0 } + let inflight: Promise | undefined + let all: SearchItem[] | undefined + + const reset = () => { + token.value += 1 + inflight = undefined + all = undefined + setSearch({ value: "" }) + listRef = undefined + } + + const open = (item: SearchItem | undefined) => { + if (!item) return + + const href = `/${base64Encode(item.directory)}/session/${item.id}` + if (!layout.sidebar.opened()) { + setState("hoverSession", undefined) + setState("hoverProject", undefined) + } + reset() + navigate(href) + layout.mobileSidebar.hide() + } + + const items = (filter: string) => { + const query = filter.trim() + if (!query) { + token.value += 1 + inflight = undefined + all = undefined + return [] as SearchItem[] + } + + const project = panelProps.project + if (!project) return [] as SearchItem[] + if (all) return all + if (inflight) return inflight + + const current = token.value + const dirs = workspaceIds(project) + inflight = Promise.all( + dirs.map((input) => { + const directory = workspaceKey(input) + const [workspaceStore] = globalSync.child(directory, { bootstrap: false }) + const kind = + directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox") + const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id) + const label = `${kind} : ${name}` + return 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"), + directory, + label, + archived: s.time?.archived, + })), + ) + .catch(() => [] as SearchItem[]) + }), + ) + .then((results) => { + if (token.value !== current) return [] as SearchItem[] + + const seen = new Set() + const next = results.flat().filter((item) => { + const key = `${item.directory}:${item.id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) + all = next + return next + }) + .catch(() => [] as SearchItem[]) + .finally(() => { + inflight = undefined + }) + + return inflight + } + + createEffect( + on( + () => panelProps.project?.worktree, + () => reset(), + { defer: true }, + ), + ) + return (
- + {(p) => ( <>
@@ -2714,7 +2824,7 @@ export default function Layout(props: ParentProps) { renameProject(p, next)} + onSave={(next) => renameProject(p(), next)} class="text-16-medium text-text-strong truncate" displayClass="text-16-medium text-text-strong truncate" stopPropagation @@ -2723,7 +2833,7 @@ export default function Layout(props: ParentProps) { - {p.worktree.replace(homedir(), "~")} + {p().worktree.replace(homedir(), "~")}
@@ -2742,31 +2852,31 @@ export default function Layout(props: ParentProps) { icon="dot-grid" variant="ghost" data-action="project-menu" - data-project={base64Encode(p.worktree)} + data-project={base64Encode(p().worktree)} class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" aria-label={language.t("common.moreOptions")} /> - dialog.show(() => )}> + dialog.show(() => )}> {language.t("common.edit")} { - const enabled = layout.sidebar.workspaces(p.worktree)() + const enabled = layout.sidebar.workspaces(p().worktree)() if (enabled) { - layout.sidebar.toggleWorkspaces(p.worktree) + layout.sidebar.toggleWorkspaces(p().worktree) return } - if (p.vcs !== "git") return - layout.sidebar.toggleWorkspaces(p.worktree) + if (p().vcs !== "git") return + layout.sidebar.toggleWorkspaces(p().worktree) }} > - {layout.sidebar.workspaces(p.worktree)() + {layout.sidebar.workspaces(p().worktree)() ? language.t("sidebar.workspaces.disable") : language.t("sidebar.workspaces.enable")} @@ -2774,8 +2884,8 @@ export default function Layout(props: ParentProps) { closeProject(p.worktree)} + data-project={base64Encode(p().worktree)} + onSelect={() => closeProject(p().worktree)} > {language.t("common.close")} @@ -2785,103 +2895,207 @@ export default function Layout(props: ParentProps) {
- +
{ + const target = event.target + if (!(target instanceof Element)) return + if (target.closest("input, textarea, [contenteditable='true']")) return + searchRef?.focus() + }} + > + + { + searchRef = el + }} + class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak" + style={{ "box-shadow": "none" }} + value={search.value} + onInput={(event) => setSearch("value", event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault() + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + return + } + + if (!searching()) return + + if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + return + } + + if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) { + if (event.key === "n" || event.key === "p") { + const ref = listRef + if (!ref) return + event.stopPropagation() + ref.onKeyDown(event) + } + } + }} + placeholder={language.t("session.header.search.placeholder", { project: projectName() })} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> + + { + setSearch("value", "") + queueMicrotask(() => searchRef?.focus()) + }} + /> + +
+
+ + + `${item.directory}:${item.id}`} + onSelect={open} + ref={(ref) => { + listRef = ref + }} + > + {(item) => ( +
+ + {item.title} + + + {item.label} + +
+ )} +
+
+ +
+ +
+ + + +
+
+ +
+ + } + > <> -
+
-
-
- +
+ + + +
{ + if (!panelProps.mobile) scrollContainerRef = el + }} + class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" + > + + + {(directory) => ( + + )} + + +
+ + + +
- } - > - <> -
- - - -
-
- - - -
{ - if (!panelProps.mobile) scrollContainerRef = el - }} - class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]" - > - - - {(directory) => ( - - )} - - -
- - - -
-
- - + +
)} - 0 && providers.paid().length === 0}> -
-
-
-
{language.t("sidebar.gettingStarted.title")}
-
{language.t("sidebar.gettingStarted.line1")}
-
{language.t("sidebar.gettingStarted.line2")}
-
- + +
0 && providers.paid().length === 0), + }} + > +
+
+
{language.t("sidebar.gettingStarted.title")}
+
{language.t("sidebar.gettingStarted.line1")}
+
{language.t("sidebar.gettingStarted.line2")}
+
- +
) } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 6c654cbb7d6..abd5572207a 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -57,6 +57,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const i18n = useI18n() const [scrollRef, setScrollRef] = createSignal(undefined) const [internalFilter, setInternalFilter] = createSignal("") + let inputRef: HTMLInputElement | HTMLTextAreaElement | undefined const [store, setStore] = createStore({ mouseActive: false, }) @@ -176,6 +177,14 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (e.key === "Enter" && !e.isComposing) { e.preventDefault() if (selected) handleSelect(selected, index) + } else if (props.search) { + if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey && (e.key === "n" || e.key === "p")) { + onKeyDown(e) + return + } + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + onKeyDown(e) + } } else { onKeyDown(e) } @@ -247,7 +256,21 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
{ + const container = event.currentTarget + if (!(container instanceof HTMLElement)) return + + const node = container.querySelector("input, textarea") + const input = node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? node : inputRef + input?.focus() + + // Prevent global listeners (e.g. dnd sensors) from cancelling focus. + event.stopPropagation() + }} + >
@@ -257,6 +280,9 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) variant="ghost" data-slot="list-search-input" type="text" + ref={(el: HTMLInputElement | HTMLTextAreaElement) => { + inputRef = el + }} value={internalFilter()} onChange={(value) => applyFilter(value)} onKeyDown={handleKey} @@ -271,7 +297,10 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) applyFilter("")} + onClick={() => { + setInternalFilter("") + queueMicrotask(() => inputRef?.focus()) + }} aria-label={i18n.t("ui.list.clearFilter")} /> From 562c9d76d9becbd485af589ab7ddd64f9c9fd31d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 20:27:15 +0000 Subject: [PATCH 037/140] chore: generate --- packages/sdk/openapi.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 95bca32303a..72327a8d72a 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6087,6 +6087,10 @@ }, "deletions": { "type": "number" + }, + "status": { + "type": "string", + "enum": ["added", "deleted", "modified"] } }, "required": ["file", "before", "after", "additions", "deletions"] From 824165eb792edfd8600d44aac83e1f6bba2a9e62 Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:36:00 +0800 Subject: [PATCH 038/140] feat(app): add workspace toggle command to command palette and prompt input (#11810) --- packages/app/src/i18n/ar.ts | 6 ++++++ packages/app/src/i18n/br.ts | 6 ++++++ packages/app/src/i18n/da.ts | 6 ++++++ packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 7 +++++++ packages/app/src/i18n/es.ts | 6 ++++++ packages/app/src/i18n/fr.ts | 6 ++++++ packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 6 ++++++ packages/app/src/i18n/no.ts | 6 ++++++ packages/app/src/i18n/pl.ts | 6 ++++++ packages/app/src/i18n/ru.ts | 6 ++++++ packages/app/src/i18n/th.ts | 8 +++++++- packages/app/src/i18n/zh.ts | 8 +++++++- packages/app/src/i18n/zht.ts | 8 +++++++- packages/app/src/pages/layout.tsx | 23 +++++++++++++++++++++++ 16 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 3718303e5a1..f816c9aca0e 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي", "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا", "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا", + "command.workspace.toggle": "تبديل مساحات العمل", "command.session.undo": "تراجع", "command.session.undo.description": "تراجع عن الرسالة الأخيرة", "command.session.redo": "إعادة", @@ -348,6 +349,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا", "toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة", + "toast.workspace.enabled.title": "تم تمكين مساحات العمل", + "toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي", + "toast.workspace.disabled.title": "تم تعطيل مساحات العمل", + "toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي", + "toast.model.none.title": "لم يتم تحديد نموذج", "toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 43336f8d6fe..4bb66e11c91 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Mudar para o próximo nível de esforço", "command.permissions.autoaccept.enable": "Aceitar edições automaticamente", "command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente", + "command.workspace.toggle": "Alternar espaços de trabalho", "command.session.undo": "Desfazer", "command.session.undo.description": "Desfazer a última mensagem", "command.session.redo": "Refazer", @@ -347,6 +348,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente", "toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação", + "toast.workspace.enabled.title": "Espaços de trabalho ativados", + "toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral", + "toast.workspace.disabled.title": "Espaços de trabalho desativados", + "toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral", + "toast.model.none.title": "Nenhum modelo selecionado", "toast.model.none.description": "Conecte um provedor para resumir esta sessão", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 69e8e8114f6..95d9f4a0fc2 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Skift til næste indsatsniveau", "command.permissions.autoaccept.enable": "Accepter ændringer automatisk", "command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer", + "command.workspace.toggle": "Skift arbejdsområder", "command.session.undo": "Fortryd", "command.session.undo.description": "Fortryd den sidste besked", "command.session.redo": "Omgør", @@ -349,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse", + "toast.workspace.enabled.title": "Arbejdsområder aktiveret", + "toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet", + "toast.workspace.disabled.title": "Arbejdsområder deaktiveret", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet", + "toast.model.none.title": "Ingen model valgt", "toast.model.none.description": "Forbind en udbyder for at opsummere denne session", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 1c28e4a16e4..3ead99427d1 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln", "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren", "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen", + "command.workspace.toggle": "Arbeitsbereiche umschalten", "command.session.undo": "Rückgängig", "command.session.undo.description": "Letzte Nachricht rückgängig machen", "command.session.redo": "Wiederherstellen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 169d09cd38c..780c19e21c0 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -71,6 +71,8 @@ export const dict = { "command.model.variant.cycle.description": "Switch to the next effort level", "command.permissions.autoaccept.enable": "Auto-accept edits", "command.permissions.autoaccept.disable": "Stop auto-accepting edits", + "command.workspace.toggle": "Toggle workspaces", + "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar", "command.session.undo": "Undo", "command.session.undo.description": "Undo the last message", "command.session.redo": "Redo", @@ -350,6 +352,11 @@ export const dict = { "toast.theme.title": "Theme switched", "toast.scheme.title": "Color scheme", + "toast.workspace.enabled.title": "Workspaces enabled", + "toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar", + "toast.workspace.disabled.title": "Workspaces disabled", + "toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar", + "toast.permissions.autoaccept.on.title": "Auto-accepting edits", "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved", "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 6e3eac0dd35..4c5fe30040f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo", "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente", "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente", + "command.workspace.toggle": "Alternar espacios de trabajo", "command.session.undo": "Deshacer", "command.session.undo.description": "Deshacer el último mensaje", "command.session.redo": "Rehacer", @@ -350,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente", "toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación", + "toast.workspace.enabled.title": "Espacios de trabajo habilitados", + "toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral", + "toast.workspace.disabled.title": "Espacios de trabajo deshabilitados", + "toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral", + "toast.model.none.title": "Ningún modelo seleccionado", "toast.model.none.description": "Conecta un proveedor para resumir esta sesión", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index fa3dccd9afa..41c8b45547e 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Passer au niveau d'effort suivant", "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications", "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications", + "command.workspace.toggle": "Basculer les espaces de travail", "command.session.undo": "Annuler", "command.session.undo.description": "Annuler le dernier message", "command.session.redo": "Rétablir", @@ -352,6 +353,11 @@ export const dict = { "toast.permissions.autoaccept.off.description": "Les permissions de modification et d'écriture nécessiteront une approbation", + "toast.workspace.enabled.title": "Espaces de travail activés", + "toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale", + "toast.workspace.disabled.title": "Espaces de travail désactivés", + "toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale", + "toast.model.none.title": "Aucun modèle sélectionné", "toast.model.none.description": "Connectez un fournisseur pour résumer cette session", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 4fccbd77e78..d2530f5e517 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "次の思考レベルに切り替え", "command.permissions.autoaccept.enable": "編集を自動承認", "command.permissions.autoaccept.disable": "編集の自動承認を停止", + "command.workspace.toggle": "ワークスペースを切り替え", "command.session.undo": "元に戻す", "command.session.undo.description": "最後のメッセージを元に戻す", "command.session.redo": "やり直す", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 5b5d29c0e08..f81164ce3b5 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "다음 생각 수준으로 전환", "command.permissions.autoaccept.enable": "편집 자동 수락", "command.permissions.autoaccept.disable": "편집 자동 수락 중지", + "command.workspace.toggle": "작업 공간 전환", "command.session.undo": "실행 취소", "command.session.undo.description": "마지막 메시지 실행 취소", "command.session.redo": "다시 실행", @@ -351,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨", "toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다", + "toast.workspace.enabled.title": "작업 공간 활성화됨", + "toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다", + "toast.workspace.disabled.title": "작업 공간 비활성화됨", + "toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다", + "toast.model.none.title": "선택된 모델 없음", "toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 89614ce853d..d1f2bc7fdc5 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -73,6 +73,7 @@ export const dict = { "command.model.variant.cycle.description": "Bytt til neste innsatsnivå", "command.permissions.autoaccept.enable": "Godta endringer automatisk", "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk", + "command.workspace.toggle": "Veksle arbeidsområder", "command.session.undo": "Angre", "command.session.undo.description": "Angre siste melding", "command.session.redo": "Gjør om", @@ -351,6 +352,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk", "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning", + "toast.workspace.enabled.title": "Arbeidsområder aktivert", + "toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet", + "toast.workspace.disabled.title": "Arbeidsområder deaktivert", + "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet", + "toast.model.none.title": "Ingen modell valgt", "toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index b89921a9bc6..f1211c45993 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku", "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji", "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji", + "command.workspace.toggle": "Przełącz przestrzenie robocze", "command.session.undo": "Cofnij", "command.session.undo.description": "Cofnij ostatnią wiadomość", "command.session.redo": "Ponów", @@ -349,6 +350,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji", "toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia", + "toast.workspace.enabled.title": "Przestrzenie robocze włączone", + "toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym", + "toast.workspace.disabled.title": "Przestrzenie robocze wyłączone", + "toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym", + "toast.model.none.title": "Nie wybrano modelu", "toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index e99abbd0819..e0efffa41bc 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "Переключиться к следующему уровню усилий", "command.permissions.autoaccept.enable": "Авто-принятие изменений", "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений", + "command.workspace.toggle": "Переключить рабочие пространства", "command.session.undo": "Отменить", "command.session.undo.description": "Отменить последнее сообщение", "command.session.redo": "Повторить", @@ -350,6 +351,11 @@ export const dict = { "toast.permissions.autoaccept.off.title": "Авто-принятие остановлено", "toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения", + "toast.workspace.enabled.title": "Рабочие пространства включены", + "toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев", + "toast.workspace.disabled.title": "Рабочие пространства отключены", + "toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево", + "toast.model.none.title": "Модель не выбрана", "toast.model.none.description": "Подключите провайдера для суммаризации сессии", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0da6f9acc75..cfe439d510c 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -70,6 +70,7 @@ export const dict = { "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป", "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ", "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", + "command.workspace.toggle": "สลับพื้นที่ทำงาน", "command.session.undo": "ยกเลิก", "command.session.undo.description": "ยกเลิกข้อความล่าสุด", "command.session.redo": "ทำซ้ำ", @@ -349,10 +350,15 @@ export const dict = { "toast.scheme.title": "โทนสี", "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ", - "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ", + "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ", "toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ", "toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ", + "toast.workspace.enabled.title": "เปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.enabled.description": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง", + "toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว", + "toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง", + "toast.model.none.title": "ไม่ได้เลือกโมเดล", "toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a7e1659ec3e..81bb23db9d8 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切换到下一个强度等级", "command.permissions.autoaccept.enable": "自动接受编辑", "command.permissions.autoaccept.disable": "停止自动接受编辑", + "command.workspace.toggle": "切换工作区", "command.session.undo": "撤销", "command.session.undo.description": "撤销上一条消息", "command.session.redo": "重做", @@ -344,7 +345,12 @@ export const dict = { "toast.language.description": "已切换到{{language}}", "toast.theme.title": "主题已切换", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "颜色方案", + + "toast.workspace.enabled.title": "工作区已启用", + "toast.workspace.enabled.description": "侧边栏现在显示多个工作树", + "toast.workspace.disabled.title": "工作区已禁用", + "toast.workspace.disabled.description": "侧边栏只显示主工作树", "toast.permissions.autoaccept.on.title": "自动接受编辑", "toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 7b8849b9a0c..f01c1ce0b14 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -74,6 +74,7 @@ export const dict = { "command.model.variant.cycle.description": "切換到下一個強度等級", "command.permissions.autoaccept.enable": "自動接受編輯", "command.permissions.autoaccept.disable": "停止自動接受編輯", + "command.workspace.toggle": "切換工作區", "command.session.undo": "復原", "command.session.undo.description": "復原上一則訊息", "command.session.redo": "重做", @@ -341,7 +342,12 @@ export const dict = { "toast.language.description": "已切換到 {{language}}", "toast.theme.title": "主題已切換", - "toast.scheme.title": "配色方案", + "toast.scheme.title": "顏色方案", + + "toast.workspace.enabled.title": "工作區已啟用", + "toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹", + "toast.workspace.disabled.title": "工作區已停用", + "toast.workspace.disabled.description": "側邊欄只顯示主工作樹", "toast.permissions.autoaccept.on.title": "自動接受編輯", "toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fe8618b7391..ba888a2805d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1138,6 +1138,29 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.toggle", + title: language.t("command.workspace.toggle"), + description: language.t("command.workspace.toggle.description"), + category: language.t("command.category.workspace"), + slash: "workspace", + disabled: !currentProject() || currentProject()?.vcs !== "git", + onSelect: () => { + const project = currentProject() + if (!project) return + if (project.vcs !== "git") return + const wasEnabled = layout.sidebar.workspaces(project.worktree)() + layout.sidebar.toggleWorkspaces(project.worktree) + showToast({ + title: wasEnabled + ? language.t("toast.workspace.disabled.title") + : language.t("toast.workspace.enabled.title"), + description: wasEnabled + ? language.t("toast.workspace.disabled.description") + : language.t("toast.workspace.enabled.description"), + }) + }, + }, { id: "theme.cycle", title: language.t("command.theme.cycle"), From a9fca05d8b8f0e87dc9774f6d660fe65831b6da5 Mon Sep 17 00:00:00 2001 From: Luiz Guilherme D'Abruzzo Pereira <707366+luiz290788@users.noreply.github.com> Date: Mon, 2 Feb 2026 17:52:32 -0300 Subject: [PATCH 039/140] feat(server): add --mdns-domain flag to customize mDNS hostname (#11796) --- packages/opencode/src/cli/cmd/web.ts | 2 +- packages/opencode/src/cli/network.ts | 9 ++++++++- packages/opencode/src/config/config.ts | 1 + packages/opencode/src/server/mdns.ts | 5 +++-- packages/opencode/src/server/server.ts | 10 ++++++++-- 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 5fa2bb42640..0fe056f21f2 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -63,7 +63,7 @@ export const WebCommand = cmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `opencode.local:${server.port}`, + `${opts.mdnsDomain}:${server.port}`, ) } diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index fe5731d0713..dd09e1689f5 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -17,6 +17,11 @@ const options = { describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)", default: false, }, + "mdns-domain": { + type: "string" as const, + describe: "custom domain name for mDNS service (default: opencode.local)", + default: "opencode.local", + }, cors: { type: "string" as const, array: true, @@ -36,9 +41,11 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const portExplicitlySet = process.argv.includes("--port") const hostnameExplicitlySet = process.argv.includes("--hostname") const mdnsExplicitlySet = process.argv.includes("--mdns") + const mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain") const corsExplicitlySet = process.argv.includes("--cors") const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns) + const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"]) const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port) const hostname = hostnameExplicitlySet ? args.hostname @@ -49,5 +56,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) { const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] - return { hostname, port, mdns, cors } + return { hostname, port, mdns, mdnsDomain, cors } } diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b0164e8aa86..54ca94ae4d3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -860,6 +860,7 @@ export namespace Config { port: z.number().int().positive().optional().describe("Port to listen on"), hostname: z.string().optional().describe("Hostname to listen on"), mdns: z.boolean().optional().describe("Enable mDNS service discovery"), + mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"), cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"), }) .strict() diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 953269de444..778afa26ac7 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,17 +7,18 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number) { + export function publish(port: number, domain?: string) { if (currentPort === port) return if (bonjour) unpublish() try { + const host = domain ?? "opencode.local" const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", - host: "opencode.local", + host, port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f6dd0d122f8..015553802a4 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -563,7 +563,13 @@ export namespace Server { return result } - export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) { + export function listen(opts: { + port: number + hostname: string + mdns?: boolean + mdnsDomain?: string + cors?: string[] + }) { _corsWhitelist = opts.cors ?? [] const args = { @@ -591,7 +597,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!) + MDNS.publish(server.port!, opts.mdnsDomain) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } From a3f1918489942eb2d99c1ef4e3b8628d55d0dfc7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 20:53:36 +0000 Subject: [PATCH 040/140] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 085c9d9c7ed..0cf70241ef6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1332,6 +1332,10 @@ export type ServerConfig = { * Enable mDNS service discovery */ mdns?: boolean + /** + * Custom domain name for mDNS service (default: opencode.local) + */ + mdnsDomain?: string /** * Additional domains to allow for CORS */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 72327a8d72a..d179ed8b8c4 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -8910,6 +8910,10 @@ "description": "Enable mDNS service discovery", "type": "boolean" }, + "mdnsDomain": { + "description": "Custom domain name for mDNS service (default: opencode.local)", + "type": "string" + }, "cors": { "description": "Additional domains to allow for CORS", "type": "array", From aa6b552c39fce24d35097de4feb6d1aa0598b1c5 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:28:02 -0600 Subject: [PATCH 041/140] Revert pr that was mistakenly merged (#11844) --- packages/opencode/src/session/processor.ts | 10 +-- packages/opencode/src/session/prompt.ts | 69 +++++++------------ packages/opencode/src/tool/batch.ts | 8 +-- packages/opencode/src/tool/read.ts | 4 ++ packages/opencode/src/tool/tool.ts | 2 +- packages/opencode/test/session/prompt.test.ts | 62 ----------------- 6 files changed, 33 insertions(+), 122 deletions(-) delete mode 100644 packages/opencode/test/session/prompt.test.ts diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 24b4a4f9fbc..b5289e903a1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -172,14 +172,6 @@ export namespace SessionProcessor { case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { - const attachments = value.output.attachments?.map( - (attachment: Omit) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: match.messageID, - sessionID: match.sessionID, - }), - ) await Session.updatePart({ ...match, state: { @@ -192,7 +184,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments, + attachments: value.output.attachments, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 98dce97ba90..e0861c4df52 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -187,17 +187,13 @@ export namespace SessionPrompt { text: template, }, ] - const matches = ConfigMarkdown.files(template) + const files = ConfigMarkdown.files(template) const seen = new Set() - const names = matches - .map((match) => match[1]) - .filter((name) => { - if (seen.has(name)) return false + await Promise.all( + files.map(async (match) => { + const name = match[1] + if (seen.has(name)) return seen.add(name) - return true - }) - const resolved = await Promise.all( - names.map(async (name) => { const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -205,34 +201,33 @@ export namespace SessionPrompt { const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { const agent = await Agent.get(name) - if (!agent) return undefined - return { - type: "agent", - name: agent.name, - } satisfies PromptInput["parts"][number] + if (agent) { + parts.push({ + type: "agent", + name: agent.name, + }) + } + return } if (stats.isDirectory()) { - return { + parts.push({ type: "file", url: `file://${filepath}`, filename: name, mime: "application/x-directory", - } satisfies PromptInput["parts"][number] + }) + return } - return { + parts.push({ type: "file", url: `file://${filepath}`, filename: name, mime: "text/plain", - } satisfies PromptInput["parts"][number] + }) }), ) - for (const item of resolved) { - if (!item) continue - parts.push(item) - } return parts } @@ -432,12 +427,6 @@ export namespace SessionPrompt { assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { - const attachments = result.attachments?.map((attachment) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: assistantMessage.id, - sessionID: assistantMessage.sessionID, - })) await Session.updatePart({ ...part, state: { @@ -446,7 +435,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments, + attachments: result.attachments, time: { ...part.state.time, end: Date.now(), @@ -785,13 +774,16 @@ export namespace SessionPrompt { ) const textParts: string[] = [] - const attachments: Omit[] = [] + const attachments: MessageV2.FilePart[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { attachments.push({ + id: Identifier.ascending("part"), + sessionID: input.session.id, + messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, url: `data:${contentItem.mimeType};base64,${contentItem.data}`, @@ -803,6 +795,9 @@ export namespace SessionPrompt { } if (resource.blob) { attachments.push({ + id: Identifier.ascending("part"), + sessionID: input.session.id, + messageID: input.processor.message.id, type: "file", mime: resource.mimeType ?? "application/octet-stream", url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, @@ -1051,7 +1046,6 @@ export namespace SessionPrompt { pieces.push( ...result.attachments.map((attachment) => ({ ...attachment, - id: Identifier.ascending("part"), synthetic: true, filename: attachment.filename ?? part.filename, messageID: info.id, @@ -1189,18 +1183,7 @@ export namespace SessionPrompt { }, ] }), - ) - .then((x) => x.flat()) - .then((drafts) => - drafts.map( - (part): MessageV2.Part => ({ - ...part, - id: Identifier.ascending("part"), - messageID: info.id, - sessionID: input.sessionID, - }), - ), - ) + ).then((x) => x.flat()) await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index b5c3ad0a12b..ba34eb48f5c 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -77,12 +77,6 @@ export const BatchTool = Tool.define("batch", async () => { }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) - const attachments = result.attachments?.map((attachment) => ({ - ...attachment, - id: Identifier.ascending("part"), - messageID: ctx.messageID, - sessionID: ctx.sessionID, - })) await Session.updatePart({ id: partID, @@ -97,7 +91,7 @@ export const BatchTool = Tool.define("batch", async () => { output: result.output, title: result.title, metadata: result.metadata, - attachments, + attachments: result.attachments, time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 13236d44dd4..f230cdf44cb 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,6 +6,7 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" +import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" @@ -78,6 +79,9 @@ export const ReadTool = Tool.define("read", { }, attachments: [ { + id: Identifier.ascending("part"), + sessionID: ctx.sessionID, + messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 0e78ba665cf..3d17ea192d3 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -36,7 +36,7 @@ export namespace Tool { title: string metadata: M output: string - attachments?: Omit[] + attachments?: MessageV2.FilePart[] }> formatValidationError?(error: z.ZodError): string }> diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts deleted file mode 100644 index e778bfe5146..00000000000 --- a/packages/opencode/test/session/prompt.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import path from "path" -import { describe, expect, test } from "bun:test" -import { Session } from "../../src/session" -import { SessionPrompt } from "../../src/session/prompt" -import { MessageV2 } from "../../src/session/message-v2" -import { Instance } from "../../src/project/instance" -import { Log } from "../../src/util/log" -import { tmpdir } from "../fixture/fixture" - -Log.init({ print: false }) - -describe("SessionPrompt ordering", () => { - test("keeps @file order with read output parts", async () => { - await using tmp = await tmpdir({ - git: true, - init: async (dir) => { - await Bun.write(path.join(dir, "a.txt"), "28\n") - await Bun.write(path.join(dir, "b.txt"), "42\n") - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) - const template = "What numbers are written in files @a.txt and @b.txt ?" - const parts = await SessionPrompt.resolvePromptParts(template) - const fileParts = parts.filter((part) => part.type === "file") - - expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) - - const message = await SessionPrompt.prompt({ - sessionID: session.id, - parts, - noReply: true, - }) - const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) - const items = stored.parts - const aPath = path.join(tmp.path, "a.txt") - const bPath = path.join(tmp.path, "b.txt") - const sequence = items.flatMap((part) => { - if (part.type === "text") { - if (part.text.includes(aPath)) return ["input:a"] - if (part.text.includes(bPath)) return ["input:b"] - if (part.text.includes("00001| 28")) return ["output:a"] - if (part.text.includes("00001| 42")) return ["output:b"] - return [] - } - if (part.type === "file") { - if (part.filename === "a.txt") return ["file:a"] - if (part.filename === "b.txt") return ["file:b"] - } - return [] - }) - - expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) - - await Session.remove(session.id) - }, - }) - }) -}) From 531357b40c22be2ac0ff020962f85d393163e015 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 2 Feb 2026 15:53:59 -0600 Subject: [PATCH 042/140] fix(app): sidebar losing projects on collapse --- packages/app/src/pages/layout.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index ba888a2805d..2f963ae28d8 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2420,7 +2420,7 @@ export default function Layout(props: ParentProps) { } const projectName = () => props.project.name || getFilename(props.project.worktree) - const trigger = ( + const Trigger = () => ( { @@ -2499,14 +2499,14 @@ export default function Layout(props: ParentProps) { return ( // @ts-ignore
- + }> } onOpenChange={(value) => { if (menu()) return setOpen(value) From aadd2e13d785dc9c4e78cbb1812d6a0eefc2f4d1 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Mon, 2 Feb 2026 23:02:56 +0100 Subject: [PATCH 043/140] fix(app): prompt input overflow issue (#11840) --- packages/app/src/components/prompt-input.tsx | 42 +++++++++++++------ .../src/components/session-context-usage.tsx | 2 +- packages/ui/src/components/select.tsx | 4 +- 3 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5162c0b0806..619d4e5d92e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1896,8 +1896,8 @@ export const PromptInput: Component = (props) => {
-
-
+
+
@@ -1909,6 +1909,7 @@ export const PromptInput: Component = (props) => { @@ -1916,7 +1917,8 @@ export const PromptInput: Component = (props) => { options={local.agent.list().map((agent) => agent.name)} current={local.agent.current()?.name ?? ""} onSelect={local.agent.set} - class="capitalize" + class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`} + valueClass="truncate" variant="ghost" /> @@ -1925,36 +1927,51 @@ export const PromptInput: Component = (props) => { fallback={ - } > - + - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + 0}> @@ -1971,6 +1988,7 @@ export const PromptInput: Component = (props) => { @@ -2000,7 +2018,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => { e.currentTarget.value = "" }} /> -
+
+ +
+ + + ) + } + + function DialogDeleteSession(props: { sessionID: string }) { + const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) + const handleDelete = async () => { + await deleteSession(props.sessionID) + dialog.close() + } + + return ( + +
+
+ + {language.t("session.delete.confirm", { name: title() })} + +
+
+ + +
+
+
+ ) + } + const emptyUserMessages: UserMessage[] = [] const userMessages = createMemo( () => messages().filter((m) => m.role === "user") as UserMessage[], @@ -1992,20 +2207,63 @@ export default function Page() { centered(), }} > -
- - { - navigate(`/${params.dir}/session/${info()?.parentID}`) - }} - aria-label={language.t("common.goBack")} - /> - - -

{info()?.title}

+
+
+ + { + navigate(`/${params.dir}/session/${info()?.parentID}`) + }} + aria-label={language.t("common.goBack")} + /> + + +

{info()?.title}

+
+
+ + {(id) => ( +
+ + + + + + + dialog.show(() => )} + > + + {language.t("common.rename")} + + + void archiveSession(id())}> + + {language.t("common.archive")} + + + + dialog.show(() => )} + > + + {language.t("common.delete")} + + + + + +
+ )}
From c277ee8cbf7ff3ca5a86947d974c2b72f88398d4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:12:12 -0600 Subject: [PATCH 113/140] fix(app): move session options to the session page --- packages/app/src/pages/layout.tsx | 170 +++----------------------- packages/app/src/pages/session.tsx | 190 +++++++++++++++++------------ 2 files changed, 134 insertions(+), 226 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 46c9c9154ff..c565d197f0d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1000,69 +1000,6 @@ export default function Layout(props: ParentProps) { } } - async function deleteSession(session: Session) { - const [store, setStore] = globalSync.child(session.directory) - const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived) - const index = sessions.findIndex((s) => s.id === session.id) - const nextSession = sessions[index + 1] ?? sessions[index - 1] - - const result = await globalSDK.client.session - .delete({ directory: session.directory, sessionID: session.id }) - .then((x) => x.data) - .catch((err) => { - showToast({ - title: language.t("session.delete.failed.title"), - description: errorMessage(err), - }) - return false - }) - - if (!result) return - - setStore( - produce((draft) => { - const removed = new Set([session.id]) - - const byParent = new Map() - for (const item of draft.session) { - const parentID = item.parentID - if (!parentID) continue - const existing = byParent.get(parentID) - if (existing) { - existing.push(item.id) - continue - } - byParent.set(parentID, [item.id]) - } - - const stack = [session.id] - while (stack.length) { - const parentID = stack.pop() - if (!parentID) continue - - const children = byParent.get(parentID) - if (!children) continue - - for (const child of children) { - if (removed.has(child)) continue - removed.add(child) - stack.push(child) - } - } - - draft.session = draft.session.filter((s) => !removed.has(s.id)) - }), - ) - - if (session.id === params.id) { - if (nextSession) { - navigate(`/${params.dir}/session/${nextSession.id}`) - } else { - navigate(`/${params.dir}/session`) - } - } - } - command.register(() => { const commands: CommandOption[] = [ { @@ -1316,15 +1253,6 @@ export default function Layout(props: ParentProps) { globalSync.project.meta(project.worktree, { name }) } - async function renameSession(session: Session, next: string) { - if (next === session.title) return - await globalSDK.client.session.update({ - directory: session.directory, - sessionID: session.id, - title: next, - }) - } - const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) if (current === next) return @@ -1475,33 +1403,6 @@ export default function Layout(props: ParentProps) { }) } - function DialogDeleteSession(props: { session: Session }) { - const handleDelete = async () => { - await deleteSession(props.session) - dialog.close() - } - - return ( - -
-
- - {language.t("session.delete.confirm", { name: props.session.title })} - -
-
- - -
-
-
- ) - } - function DialogDeleteWorkspace(props: { root: string; directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ @@ -1855,10 +1756,6 @@ export default function Layout(props: ParentProps) { const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) - const [menu, setMenu] = createStore({ - open: false, - pendingRename: false, - }) const hoverPrefetch = { current: undefined as ReturnType | undefined } const cancelHoverPrefetch = () => { @@ -1885,7 +1782,7 @@ export default function Layout(props: ParentProps) { const item = (
- props.session.title} - onSave={(next) => renameSession(props.session, next)} - class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate" - stopPropagation - /> + + {props.session.title} + {(summary) => (
@@ -1989,49 +1881,25 @@ export default function Layout(props: ParentProps) {
- setMenu("open", open)}> - - - - - { - if (!menu.pendingRename) return - event.preventDefault() - setMenu("pendingRename", false) - openEditor(`session:${props.session.id}`, props.session.title) - }} - > - { - setMenu("pendingRename", true) - setMenu("open", false) - }} - > - {language.t("common.rename")} - - archiveSession(props.session)}> - {language.t("common.archive")} - - - dialog.show(() => )}> - {language.t("common.delete")} - - - - + + { + event.preventDefault() + event.stopPropagation() + void archiveSession(props.session) + }} + /> +
) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 644fa66b3b0..2143cd34b60 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -25,7 +25,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" -import { TextField } from "@opencode-ai/ui/text-field" +import { InlineInput } from "@opencode-ai/ui/inline-input" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" @@ -440,6 +440,15 @@ export default function Page() { return sync.session.history.loading(id) }) + const [title, setTitle] = createStore({ + draft: "", + editing: false, + saving: false, + menuOpen: false, + pendingRename: false, + }) + let titleRef: HTMLInputElement | undefined + const errorMessage = (err: unknown) => { if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data @@ -449,6 +458,60 @@ export default function Page() { return language.t("common.requestFailed") } + createEffect( + on( + () => params.id, + () => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }), + { defer: true }, + ), + ) + + const openTitleEditor = () => { + if (!params.id) return + setTitle({ editing: true, draft: info()?.title ?? "" }) + requestAnimationFrame(() => { + titleRef?.focus() + titleRef?.select() + }) + } + + const closeTitleEditor = () => { + if (title.saving) return + setTitle({ editing: false, saving: false }) + } + + const saveTitleEditor = async () => { + const sessionID = params.id + if (!sessionID) return + if (title.saving) return + + const next = title.draft.trim() + if (!next || next === (info()?.title ?? "")) { + setTitle({ editing: false, saving: false }) + return + } + + setTitle("saving", true) + await sdk.client.session + .update({ sessionID, title: next }) + .then(() => { + sync.set( + produce((draft) => { + const index = draft.session.findIndex((s) => s.id === sessionID) + if (index !== -1) draft.session[index].title = next + }), + ) + setTitle({ editing: false, saving: false }) + }) + .catch((err) => { + setTitle("saving", false) + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(err), + }) + }) + } + async function archiveSession(sessionID: string) { const session = sync.session.get(sessionID) if (!session) return @@ -555,74 +618,6 @@ export default function Page() { return true } - function DialogRenameSession(props: { sessionID: string }) { - const [data, setData] = createStore({ - title: sync.session.get(props.sessionID)?.title ?? "", - saving: false, - }) - - const submit = (event: Event) => { - event.preventDefault() - if (data.saving) return - - const title = data.title.trim() - if (!title) { - dialog.close() - return - } - - const current = sync.session.get(props.sessionID)?.title ?? "" - if (title === current) { - dialog.close() - return - } - - setData("saving", true) - void sdk.client.session - .update({ sessionID: props.sessionID, title }) - .then(() => { - sync.set( - produce((draft) => { - const index = draft.session.findIndex((s) => s.id === props.sessionID) - if (index !== -1) draft.session[index].title = title - }), - ) - dialog.close() - }) - .catch((err) => { - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(err), - }) - }) - .finally(() => { - setData("saving", false) - }) - } - - return ( - -
- setData("title", value)} - /> -
- - -
- -
- ) - } - function DialogDeleteSession(props: { sessionID: string }) { const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new")) const handleDelete = async () => { @@ -2208,7 +2203,7 @@ export default function Page() { }} >
-
+
- -

{info()?.title}

+ + + {info()?.title} + + } + > + { + titleRef = el + }} + value={title.draft} + disabled={title.saving} + class="text-16-medium text-text-strong grow-1 min-w-0" + onInput={(event) => setTitle("draft", event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void saveTitleEditor() + return + } + if (event.key === "Escape") { + event.preventDefault() + closeTitleEditor() + } + }} + onBlur={() => closeTitleEditor()} + /> +
{(id) => (
- + setTitle("menuOpen", open)} + > - + { + if (!title.pendingRename) return + event.preventDefault() + setTitle("pendingRename", false) + openTitleEditor() + }} + > dialog.show(() => )} + onSelect={() => { + setTitle({ pendingRename: true, menuOpen: false }) + }} > {language.t("common.rename")} From c8622df762b953bfea4ba0dbc7097b123f29a288 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 07:59:42 -0600 Subject: [PATCH 114/140] fix(app): file tree not staying in sync across projects/sessions --- packages/app/src/context/layout.tsx | 46 ++++++++ packages/app/src/pages/layout.tsx | 5 +- packages/app/src/pages/session.tsx | 159 ++++++++++++++++++---------- 3 files changed, 154 insertions(+), 56 deletions(-) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 71f3f6cfff1..e2fd0a7f45e 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -33,6 +33,8 @@ type SessionTabs = { type SessionView = { scroll: Record reviewOpen?: string[] + pendingMessage?: string + pendingMessageAt?: number } type TabHandoff = { @@ -128,6 +130,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( ) const MAX_SESSION_KEYS = 50 + const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000 const meta = { active: undefined as string | undefined, pruned: false } const used = new Map() @@ -555,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( setStore("mobileSidebar", "opened", (x) => !x) }, }, + pendingMessage: { + set(sessionKey: string, messageID: string) { + const at = Date.now() + touch(sessionKey) + const current = store.sessionView[sessionKey] + if (!current) { + setStore("sessionView", sessionKey, { + scroll: {}, + pendingMessage: messageID, + pendingMessageAt: at, + }) + prune(meta.active ?? sessionKey) + return + } + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + draft.pendingMessage = messageID + draft.pendingMessageAt = at + }), + ) + }, + consume(sessionKey: string) { + const current = store.sessionView[sessionKey] + const message = current?.pendingMessage + const at = current?.pendingMessageAt + if (!message || !at) return + + setStore( + "sessionView", + sessionKey, + produce((draft) => { + delete draft.pendingMessage + delete draft.pendingMessageAt + }), + ) + + if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return + return message + }, + }, view(sessionKey: string | Accessor) { const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c565d197f0d..1c5edbf2b44 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1864,7 +1864,10 @@ export default function Layout(props: ParentProps) { getLabel={messageLabel} onMessageSelect={(message) => { if (!isActive()) { - sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) + layout.pendingMessage.set( + `${base64Encode(props.session.directory)}/${props.session.id}`, + message.id, + ) navigate(`${props.slug}/session/${props.session.id}`) return } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2143cd34b60..7ff4bebb4d9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -76,10 +76,31 @@ import { same } from "@/utils/same" type DiffStyle = "unified" | "split" +type HandoffSession = { + prompt: string + files: Record +} + +const HANDOFF_MAX = 40 + const handoff = { - prompt: "", - terminals: [] as string[], - files: {} as Record, + session: new Map(), + terminal: new Map(), +} + +const touch = (map: Map, key: K, value: V) => { + map.delete(key) + map.set(key, value) + while (map.size > HANDOFF_MAX) { + const first = map.keys().next().value + if (first === undefined) return + map.delete(first) + } +} + +const setSessionHandoff = (key: string, patch: Partial) => { + const prev = handoff.session.get(key) ?? { prompt: "", files: {} } + touch(handoff.session, key, { ...prev, ...patch }) } interface SessionReviewTabProps { @@ -793,8 +814,10 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { - if (!params.id) return - sync.session.sync(params.id) + sdk.directory + const id = params.id + if (!id) return + sync.session.sync(id) }) createEffect(() => { @@ -862,10 +885,22 @@ export default function Page() { createEffect( on( - () => params.id, + sessionKey, () => { setStore("messageId", undefined) setStore("expanded", {}) + setUi("autoCreated", false) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => params.dir, + (dir) => { + if (!dir) return + setStore("newSessionWorktree", "main") }, { defer: true }, ), @@ -1373,12 +1408,15 @@ export default function Page() { activeDiff: undefined as string | undefined, }) - const reviewScroll = () => tree.reviewScroll - const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) - const pendingDiff = () => tree.pendingDiff - const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) - const activeDiff = () => tree.activeDiff - const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value) + createEffect( + on( + sessionKey, + () => { + setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined }) + }, + { defer: true }, + ), + ) const showAllFiles = () => { if (fileTreeTab() !== "changes") return @@ -1399,8 +1437,8 @@ export default function Page() { view={view} diffStyle={layout.review.diffStyle()} onDiffStyleChange={layout.review.setDiffStyle} - onScrollRef={setReviewScroll} - focusedFile={activeDiff()} + onScrollRef={(el) => setTree("reviewScroll", el)} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -1450,7 +1488,7 @@ export default function Page() { } const reviewDiffTop = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return const id = reviewDiffId(path) @@ -1466,7 +1504,7 @@ export default function Page() { } const scrollToReviewDiff = (path: string) => { - const root = reviewScroll() + const root = tree.reviewScroll if (!root) return false const top = reviewDiffTop(path) @@ -1480,24 +1518,23 @@ export default function Page() { const focusReviewDiff = (path: string) => { const current = view().review.open() ?? [] if (!current.includes(path)) view().review.setOpen([...current, path]) - setActiveDiff(path) - setPendingDiff(path) + setTree({ activeDiff: path, pendingDiff: path }) } createEffect(() => { - const pending = pendingDiff() + const pending = tree.pendingDiff if (!pending) return - if (!reviewScroll()) return + if (!tree.reviewScroll) return if (!diffsReady()) return const attempt = (count: number) => { - if (pendingDiff() !== pending) return + if (tree.pendingDiff !== pending) return if (count > 60) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } - const root = reviewScroll() + const root = tree.reviewScroll if (!root) { requestAnimationFrame(() => attempt(count + 1)) return @@ -1515,7 +1552,7 @@ export default function Page() { } if (Math.abs(root.scrollTop - top) <= 1) { - setPendingDiff(undefined) + setTree("pendingDiff", undefined) return } @@ -1558,13 +1595,17 @@ export default function Page() { void sync.session.diff(id) }) + let treeDir: string | undefined createEffect(() => { + const dir = sdk.directory if (!isDesktop()) return if (!layout.fileTree.opened()) return if (sync.status === "loading") return fileTreeTab() - void file.tree.list("") + const refresh = treeDir !== dir + treeDir = dir + void (refresh ? file.tree.refresh("") : file.tree.list("")) }) const autoScroll = createAutoScroll({ @@ -1599,6 +1640,18 @@ export default function Page() { let scrollSpyFrame: number | undefined let scrollSpyTarget: HTMLDivElement | undefined + createEffect( + on( + sessionKey, + () => { + if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + scrollSpyFrame = undefined + scrollSpyTarget = undefined + }, + { defer: true }, + ), + ) + const anchor = (id: string) => `message-${id}` const setScrollRef = (el: HTMLDivElement | undefined) => { @@ -1713,20 +1766,14 @@ export default function Page() { window.history.replaceState(null, "", `#${anchor(id)}`) } - createEffect(() => { - const sessionID = params.id - if (!sessionID) return - const raw = sessionStorage.getItem("opencode.pendingMessage") - if (!raw) return - const parts = raw.split("|") - const pendingSessionID = parts[0] - const messageID = parts[1] - if (!pendingSessionID || !messageID) return - if (pendingSessionID !== sessionID) return - - sessionStorage.removeItem("opencode.pendingMessage") - setUi("pendingMessage", messageID) - }) + createEffect( + on(sessionKey, (key) => { + if (!params.id) return + const messageID = layout.pendingMessage.consume(key) + if (!messageID) return + setUi("pendingMessage", messageID) + }), + ) const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const root = scroller @@ -1940,7 +1987,7 @@ export default function Page() { createEffect(() => { if (!prompt.ready()) return - handoff.prompt = previewPrompt() + setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) createEffect(() => { @@ -1960,20 +2007,22 @@ export default function Page() { return language.t("terminal.title") } - handoff.terminals = terminal.all().map(label) + touch(handoff.terminal, params.dir!, terminal.all().map(label)) }) createEffect(() => { if (!file.ready()) return - handoff.files = Object.fromEntries( - tabs() - .all() - .flatMap((tab) => { - const path = file.pathFromTab(tab) - if (!path) return [] - return [[path, file.selectedLines(path) ?? null] as const] - }), - ) + setSessionHandoff(sessionKey(), { + files: Object.fromEntries( + tabs() + .all() + .flatMap((tab) => { + const path = file.pathFromTab(tab) + if (!path) return [] + return [[path, file.selectedLines(path) ?? null] as const] + }), + ), + }) }) onCleanup(() => { @@ -2049,7 +2098,7 @@ export default function Page() { diffs={diffs} view={view} diffStyle="unified" - focusedFile={activeDiff()} + focusedFile={tree.activeDiff} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} comments={comments.all()} focusedComment={comments.focus()} @@ -2483,7 +2532,7 @@ export default function Page() { when={prompt.ready()} fallback={
- {handoff.prompt || language.t("prompt.loading")} + {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
} > @@ -2734,7 +2783,7 @@ export default function Page() { const p = path() if (!p) return null if (file.ready()) return file.selectedLines(p) ?? null - return handoff.files[p] ?? null + return handoff.session.get(sessionKey())?.files[p] ?? null }) let wrap: HTMLDivElement | undefined @@ -3228,7 +3277,7 @@ export default function Page() { allowed={diffFiles()} kinds={kinds()} draggable={false} - active={activeDiff()} + active={tree.activeDiff} onFileClick={(node) => focusReviewDiff(node.path)} /> @@ -3288,7 +3337,7 @@ export default function Page() { fallback={
- + {(title) => (
{title} From a3b281b2f3414b82518909d5e31e4fbbd3f7bf3b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 4 Feb 2026 10:31:21 -0500 Subject: [PATCH 115/140] ci: remove source-based AUR package from publish script Simplifies the release process by publishing only the binary package to AUR, eliminating the need to maintain separate source and binary build configurations. --- packages/opencode/script/publish.ts | 68 +---------------------------- 1 file changed, 1 insertion(+), 67 deletions(-) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 3113a85003c..fbc1c83ba6d 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -95,73 +95,7 @@ if (!Script.preview) { "", ].join("\n") - // Source-based PKGBUILD for opencode - const sourcePkgbuild = [ - "# Maintainer: dax", - "# Maintainer: adam", - "", - "pkgname='opencode'", - `pkgver=${pkgver}`, - `_subver=${_subver}`, - "options=('!debug' '!strip')", - "pkgrel=1", - "pkgdesc='The AI coding agent built for the terminal.'", - "url='https://github.com/anomalyco/opencode'", - "arch=('aarch64' 'x86_64')", - "license=('MIT')", - "provides=('opencode')", - "conflicts=('opencode-bin')", - "depends=('ripgrep')", - "makedepends=('git' 'bun' 'go')", - "", - `source=("opencode-\${pkgver}.tar.gz::https://github.com/anomalyco/opencode/archive/v\${pkgver}\${_subver}.tar.gz")`, - `sha256sums=('SKIP')`, - "", - "build() {", - ` cd "opencode-\${pkgver}"`, - ` bun install`, - " cd ./packages/opencode", - ` OPENCODE_CHANNEL=latest OPENCODE_VERSION=${pkgver} bun run ./script/build.ts --single`, - "}", - "", - "package() {", - ` cd "opencode-\${pkgver}/packages/opencode"`, - ' mkdir -p "${pkgdir}/usr/bin"', - ' target_arch="x64"', - ' case "$CARCH" in', - ' x86_64) target_arch="x64" ;;', - ' aarch64) target_arch="arm64" ;;', - ' *) printf "unsupported architecture: %s\\n" "$CARCH" >&2 ; return 1 ;;', - " esac", - ' libc=""', - " if command -v ldd >/dev/null 2>&1; then", - " if ldd --version 2>&1 | grep -qi musl; then", - ' libc="-musl"', - " fi", - " fi", - ' if [ -z "$libc" ] && ls /lib/ld-musl-* >/dev/null 2>&1; then', - ' libc="-musl"', - " fi", - ' base=""', - ' if [ "$target_arch" = "x64" ]; then', - " if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then", - ' base="-baseline"', - " fi", - " fi", - ' bin="dist/opencode-linux-${target_arch}${base}${libc}/bin/opencode"', - ' if [ ! -f "$bin" ]; then', - ' printf "unable to find binary for %s%s%s\\n" "$target_arch" "$base" "$libc" >&2', - " return 1", - " fi", - ' install -Dm755 "$bin" "${pkgdir}/usr/bin/opencode"', - "}", - "", - ].join("\n") - - for (const [pkg, pkgbuild] of [ - ["opencode-bin", binaryPkgbuild], - ["opencode", sourcePkgbuild], - ]) { + for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) { for (let i = 0; i < 30; i++) { try { await $`rm -rf ./dist/aur-${pkg}` From 61d3f788b847593a865d1aa8a9a112911f55d117 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 4 Feb 2026 10:00:55 -0600 Subject: [PATCH 116/140] fix(app): don't show scroll-to-bottom unecessarily --- packages/app/src/pages/session.tsx | 67 ++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7ff4bebb4d9..f74eadc87be 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -279,6 +279,10 @@ export default function Page() { pendingMessage: undefined as string | undefined, scrollGesture: 0, autoCreated: false, + scroll: { + overflow: false, + bottom: true, + }, }) createEffect( @@ -795,6 +799,7 @@ export default function Page() { let inputRef!: HTMLDivElement let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined + let content: HTMLDivElement | undefined const scrollGestureWindowMs = 250 @@ -1618,10 +1623,40 @@ export default function Page() { window.history.replaceState(null, "", window.location.href.replace(/#.*$/, "")) } + let scrollStateFrame: number | undefined + let scrollStateTarget: HTMLDivElement | undefined + + const updateScrollState = (el: HTMLDivElement) => { + const max = el.scrollHeight - el.clientHeight + const overflow = max > 1 + const bottom = !overflow || el.scrollTop >= max - 2 + + if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom) return + setUi("scroll", { overflow, bottom }) + } + + const scheduleScrollState = (el: HTMLDivElement) => { + scrollStateTarget = el + if (scrollStateFrame !== undefined) return + + scrollStateFrame = requestAnimationFrame(() => { + scrollStateFrame = undefined + + const target = scrollStateTarget + scrollStateTarget = undefined + if (!target) return + + updateScrollState(target) + }) + } + const resumeScroll = () => { setStore("messageId", undefined) autoScroll.forceScrollToBottom() clearMessageHash() + + const el = scroller + if (el) scheduleScrollState(el) } // When the user returns to the bottom, treat the active message as "latest". @@ -1657,8 +1692,17 @@ export default function Page() { const setScrollRef = (el: HTMLDivElement | undefined) => { scroller = el autoScroll.scrollRef(el) + if (el) scheduleScrollState(el) } + createResizeObserver( + () => content, + () => { + const el = scroller + if (el) scheduleScrollState(el) + }, + ) + const turnInit = 20 const turnBatch = 20 let turnHandle: number | undefined @@ -1759,6 +1803,8 @@ export default function Page() { el.scrollTo({ top: el.scrollHeight, behavior: "auto" }) }) } + + if (el) scheduleScrollState(el) }, ) @@ -1839,6 +1885,9 @@ export default function Page() { const hash = window.location.hash.slice(1) if (!hash) { autoScroll.forceScrollToBottom() + + const el = scroller + if (el) scheduleScrollState(el) return } @@ -1864,6 +1913,9 @@ export default function Page() { } autoScroll.forceScrollToBottom() + + const el = scroller + if (el) scheduleScrollState(el) } const closestMessage = (node: Element | null): HTMLElement | null => { @@ -2029,6 +2081,7 @@ export default function Page() { cancelTurnBackfill() document.removeEventListener("keydown", handleKeyDown) if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame) + if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) }) return ( @@ -2133,8 +2186,9 @@ export default function Page() {