diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 183f7373d..e1ce2434a 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -24,6 +24,10 @@ jobs: distribution: temurin java-version: '21' - uses: android-actions/setup-android@v3 + with: + # v3's default also installs the obsolete `tools` package, which + # current runner cmdline-tools no longer provide. + packages: platform-tools - name: Install Android SDK run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' - name: Assemble, unit test and lint Android diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4afb37768..307738e8e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -42,6 +42,10 @@ jobs: distribution: temurin java-version: '21' - uses: android-actions/setup-android@v3 + with: + # v3's default also installs the obsolete `tools` package, which + # current runner cmdline-tools no longer provide. + packages: platform-tools - name: Install Android SDK run: sdkmanager 'platforms;android-36' 'build-tools;36.0.0' - name: Assemble, unit test and lint Android diff --git a/AGENTS.md b/AGENTS.md index cacd023c7..5ba2b402f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -301,4 +301,14 @@ handled or safely ignored. - Skill toggling edits only the `disable-model-invocation` frontmatter key on the target `SKILL.md`; keep that surgical so user formatting survives. - `/api/skills/install` shells through `npx skills add ... --agent universal`, which installs into the ecosystem-standard `.agents/skills` dirs omp reads; project installs run with the selected cwd. -[Showing lines 1-300 of 376. Use :301 to continue] \ No newline at end of file +[Showing lines 1-300 of 376. Use :301 to continue] + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/DESIGN.md b/DESIGN.md index 4a1ecd2f3..8f9f465dd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -51,7 +51,7 @@ Browser │ HTTP / Server-Sent Events ▼ ompgui (Next.js on Node) - ├─ reads native OMP session files and selected configuration + ├─ reads native OMP sessions and catalog-filtered global/project settings ├─ serves allow-listed project files └─ starts one `omp --mode rpc-ui` child per active session │ NDJSON over stdio @@ -96,13 +96,40 @@ atomic where possible; archive or deletion stops the associated live process first. ompgui does not provide a general editor for session JSONL or opaque OMP state. -Models and allow-listed OMP settings use surgical YAML updates that preserve -unrelated content. Plugin operations run the installed `omp plugin` CLI. MCP -configuration is project-local, validated before writing, and saved atomically. -Renaming a server to an existing name returns HTTP 409 without overwriting either -configuration. -Configuration replacements use exclusive, owner-only temporary files. Project -configuration targets are checked through symlinks before creating directories. +Models use surgical YAML updates. Native OMP settings are defined once in +`lib/omp/settings-catalog.ts`; `/api/omp-settings` and Relay's `settings.get` / +`settings.update` call the same settings service, so browser and Android receive +the same catalog, validation, scope policy, redaction, confirmation rules, and +per-layer/effective-value DTOs. Values in that DTO are saved configuration, not +live-session state or environment/CLI overrides; clients supply catalog defaults +for absent effective values. + +The global layer reads `~/.omp/agent/config.yml`, falling back to an existing +`config.yaml`; an authorized project layer reads and writes only `.omp/config.yml`. +Project values recursively merge over global values for display; a null project +model-role entry inherits the global role, and ordered role-selector arrays are +preserved. Scoped model/provider registry arrays that the simple editor cannot +represent are marked read-only and protected against replacement or reset. +A save applies a minimal leaf patch, with structured records replacing that record at the selected +layer and ordered arrays remaining ordered; reset removes the selected-layer leaf +so defaults or inheritance apply. Catalog-designated secret values are never +returned, only presence, and sensitive changes fail closed without their required +confirmation. YAML aliases are isolated before mutation so editing an anchor +does not change unrelated alias consumers. Unrelated YAML and comments are +preserved. New or restarted OMP sessions consume saved changes; ompgui does not +silently restart a live RPC child. + +Every settings read-modify-write holds OMP's native cross-process config lock and +then performs an atomic replacement. The Node implementation uses `koffi` for the +platform primitives and `xxhash-wasm` for OMP-compatible lock names; Darwin +interoperability has been exercised, while the Linux and Windows implementations +are source-complete but were not runtime-tested in that verification pass. +Configuration replacements use exclusive, owner-only temporary files, and project +targets are checked through symlinks before directories are created. + +Plugin operations run the installed `omp plugin` CLI. MCP configuration is +project-local, validated before writing, and saved atomically. Renaming a server +to an existing name returns HTTP 409 without overwriting either configuration. ### Server-owned pre-dispatch queue diff --git a/README.ja.md b/README.ja.md index 130f3db21..9f70b7cde 100644 --- a/README.ja.md +++ b/README.ja.md @@ -2,10 +2,12 @@ [English](./README.md) | [한국어](./README.ko.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) -> **Android APK (Android 12+)** — Kotlin 製のコンパニオンアプリでリモートの ompgui サーバーに接続し、最新セッションの読み取り専用オフラインスナップショットを利用できます。[ompgui Remote v0.7.10 をダウンロード](https://github.com/dbc-hbin/ompgui/releases/download/android-v0.7.10/ompgui-remote-v0.7.10.apk) · [リリースノート](https://github.com/dbc-hbin/ompgui/releases/tag/android-v0.7.10) +> **Android APK (Android 12+)** — Kotlin 製のコンパニオンアプリでリモートの ompgui サーバーに接続し、最新セッションの読み取り専用オフラインスナップショットを利用できます。[ompgui Remote v0.7.11 をダウンロード](https://github.com/dbc-hbin/ompgui/releases/download/v0.7.11/ompgui-remote-v0.7.11.apk) · [リリースノート](https://github.com/dbc-hbin/ompgui/releases/tag/v0.7.11) > `android/` の実験的なネイティブ Compose クライアントは、認証済みの `/relay` WebSocket を使用し、セッション・履歴の操作、リンク付きメッセージ、オフラインの Mermaid 図とコードの構文強調、画像/PDF/音声/HTML/Markdown/DOCX のインラインプレビューを提供します。ファイルの自動更新は表示中のみ動作し、未保存の編集を保持します。許可された隠しファイルの閲覧とアーカイブ検索にも対応します。モデル・設定には公開 models.dev カタログ、高度な OMP 設定、構成とは別に表示されるセッションごとの実際の MCP 実行状態が含まれます。添付ファイルは端末上で巨大な単一 JSON にまとめず、ストリーミングでステージングします。画像は各 10 MiB で最大 10 件、テキスト添付は独立した枠で各 256 KiB、最大 10 件です。OMP 自体の画像正規化とプロバイダーごとの画像数制限は引き続き適用されます。 > +> **現在の設定機能:** Web と Android APK は、スコープ、継承、リセット、検証、保護されたシークレット/管理者操作を含む同一の検索・カテゴリ対応 OMP 設定カタログを使用します。端末ローカルの思考表示設定は会話の描画だけを変え、最終回答は隠しません。 +> > このクライアントはホスト型/E2E リレーを追加するものではありません。JDK 21 を使用し、リポジトリのルートで `npm install` を実行してから、`android/` で `./gradlew :app:assembleDebug` を実行します。オフラインアセットはインストール済みの npm 依存パッケージから生成されます。出力先は `android/app/build/outputs/apk/debug/app-debug.apk` です。サーバーで無効化されている更新、ログアウト、保存済み API キーの変更操作は引き続き利用できません。 [oh-my-pi (omp) コーディングエージェント](https://github.com/can1357/oh-my-pi)のローカル Web UI です。ompgui はローカルの omp セッションファイルを読み込み、セッションの閲覧、リアルタイムチャット、モデル設定、スキル管理、プロジェクトファイルのプレビューを行えるブラウザワークスペースを提供します。 @@ -127,7 +129,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **ブランチをまたいで作業**: サイドバーから Git ワークツリーを切り替えると、新しいセッションとエクスプローラーが選択したチェックアウトに追従します。 - **プロジェクトを見ながらチャット**: エージェントの作業中に、左側でファイルを閲覧し、右側でソース、ドキュメント、画像、音声、PDF をプレビューできます。 - **セッション状態をひと目で把握**: コンテキスト使用量、コスト、コンパクション状態、システムプロンプトの詳細をトップバーで確認できます。 -- **ターミナルに頼らない設定**: モデル、ログイン/APIキー、モデルテスト、ネイティブ OMP 制御(アドバイザー、承認、Bash ポリシー、思考、コンパクション、メモリ、自動学習、リトライ/フォールバック)、スキル、プラグイン、プロジェクト MCP サーバーを Web UI から管理できます。 +- **OMP 設定を一貫して管理**: Web と Android が、モデル、エージェント、ツール、安全性、システム動作のための検索・カテゴリ対応カタログを共有し、グローバル/プロジェクト継承、デフォルトへのリセット、高度な値の検証を提供します。 - **Settings で MCP を管理**: 専用の MCP タブでプロジェクトサーバーの状態(有効 / 無効 / 無効な設定)を表示し、追加、編集、名前変更、検証、削除を行えます。設定エラーはトーストで通知されます。 - **OMP を最新に保つ**: Settings からインストール済みランタイムを確認・更新し、必要に応じてアクティブなセッションを再起動できます。 - **完了を見逃さない**: エージェント完了時のブラウザー通知を有効にし、インストール済みスキルの更新を確認できます。 @@ -146,6 +148,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | 別の omp エージェントディレクトリを指定(デフォルト `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | サーバーサイドリクエスト用の標準プロキシ変数 | +### OMP 設定 + +Web UI と Android APK は同じ共有カタログをカテゴリと検索で表示し、デフォルト値、グローバルまたはプロジェクトのスコープ、継承された有効値、リセットに対応します。グローバル設定は `~/.omp/agent/config.yml` を使用し、`config.yml` が存在せず既存の `config.yaml` がある場合に限り、後者を読み書きのフォールバックとして使用します。プロジェクト設定は、許可されたワークスペースの正規パス `.omp/config.yml` だけを使用します。表示する有効値では、プロジェクト層をグローバル層へ再帰的にマージします。保存時には、構造化レコードが選択した層の該当レコード全体を置換し、順序付き配列は順序を保持します。数値、列挙値、配列、構造化値は最小限の YAML 更新前に検証され、無関係なキーとコメントは保持されます。 + +表示される有効値は保存済みのグローバル/プロジェクト構成を統合したもので、実行中セッションの実値でも、環境変数や CLI による上書きでもありません。変更は新規または再起動したセッションで反映され、保存だけで実行中の RPC セッションが自動再起動することはありません。カタログで確認対象に指定された変更には明示的な確認が必要です。シークレットは書き込み専用で、クライアントには有無だけが返され、置換または削除はできますが保存値は読み取れません。端末ごとの思考表示設定はローカル表示だけに作用し、思考ブロックを隠しても最終回答は隠しません。 + ## アーキテクチャ ompgui は Node 上でホストされる Next.js アプリで、インストール済みの `omp` バイナリを操作します。エージェント自体は同梱していません: @@ -153,6 +161,7 @@ ompgui は Node 上でホストされる Next.js アプリで、インストー - **ライブセッション**: `omp --mode rpc-ui`(stdio 上の NDJSON)を、アクティブなセッションごとに 1 つの子プロセスとして起動します。そのため、エージェントのバージョンは常にインストールされているものと完全に一致します。 - **セッション閲覧**: omp のセッションファイル(`~/.omp/agent/sessions//_.jsonl`)を直接読み込みます。タイトル変更、アーカイブ、削除は、OMP のライブ書き込みと競合しないよう保護されたネイティブファイルのメンテナンス操作です。 - **モデルと認証**: omp 子プロセスに対する RPC コマンドを使用します。モデルパネルは omp エージェントディレクトリ内の `models.yml` を編集します。 +- **OMP 設定**: `lib/omp/settings-catalog.ts` が Web と Relay/Android で共有するカタログを定義し、設定サービスがカタログで絞り込んだ各層/有効値を返して、検証済みの最小パッチを適用します。グローバルおよび許可されたプロジェクトへの書き込みは、OMP 互換のプロセス間ロックとアトミック置換を使い、ネイティブ OMP と ompgui が互いのファイル世代を上書きしないようにします。 - **スキルとプラグイン**: omp のスキルディレクトリ(`~/.omp/agent/skills`、プロジェクトの `.omp/skills`、互換ディレクトリ)をスキャンし、プラグイン管理には `omp plugin` を呼び出します。 - **ファイルアクセス**: ファイルの閲覧とプレビューは、選択したプロジェクトディレクトリとセッションに現れる作業ディレクトリに限定されます。 - **フォークとセッション内ブランチの違い**: フォークは新しい `.jsonl` ファイルを作成します。「ここから編集」は同じセッションファイル内に別のブランチを作成します。 diff --git a/README.ko.md b/README.ko.md index d3c53f5c5..de4af9d3f 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,10 +2,12 @@ [English](./README.md) | [한국어](./README.ko.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) -> **Android APK (Android 12+)** — Kotlin 보조 앱으로 원격 ompgui 서버에 연결하고, 최신 세션의 읽기 전용 오프라인 스냅샷을 이용할 수 있습니다. [ompgui Remote v0.7.10 다운로드](https://github.com/dbc-hbin/ompgui/releases/download/android-v0.7.10/ompgui-remote-v0.7.10.apk) · [릴리스 노트](https://github.com/dbc-hbin/ompgui/releases/tag/android-v0.7.10) +> **Android APK (Android 12+)** — Kotlin 보조 앱으로 원격 ompgui 서버에 연결하고, 최신 세션의 읽기 전용 오프라인 스냅샷을 이용할 수 있습니다. [ompgui Remote v0.7.11 다운로드](https://github.com/dbc-hbin/ompgui/releases/download/v0.7.11/ompgui-remote-v0.7.11.apk) · [릴리스 노트](https://github.com/dbc-hbin/ompgui/releases/tag/v0.7.11) > `android/`의 실험용 네이티브 Compose 클라이언트는 인증된 `/relay` WebSocket을 사용하며, 세션/기록 제어와 링크·도구 호출·결과·오류·미디어 참조를 포함하는 풍부한 온라인 대화 기록, 오프라인 Mermaid 다이어그램 및 코드 구문 강조, 이미지/PDF/오디오/HTML/Markdown/DOCX 인라인 미리보기를 제공합니다. 온라인 기록은 보호된 오프라인 스냅샷과 별개입니다. 크기가 제한된 미리보기에서 `sessions.content`로 전체 텍스트를 나누어 가져오고 미디어 참조도 페이지 단위로 조회하므로 대화 기록을 4,000자로 잘라내지 않습니다. 오프라인 캐시의 개인정보 보호 제한은 그대로 유지됩니다. 파일 자동 새로고침은 화면에 보일 때만 동작하며 저장하지 않은 편집 내용을 보존합니다. 허용된 숨김 파일 탐색과 아카이브 검색도 지원합니다. 모델/설정에서는 공개 models.dev 카탈로그, 고급 OMP 설정, 구성 상태와 구분되는 세션별 실제 MCP 실행 상태를 확인할 수 있습니다. 첨부 파일은 휴대폰에서 하나의 거대한 JSON으로 묶지 않고 스트리밍으로 스테이징하며, 이미지는 개당 10 MiB로 최대 10개, 텍스트 첨부는 별도로 개당 256 KiB로 최대 10개까지 지원합니다. OMP 자체의 이미지 정규화와 제공자별 이미지 개수 제한은 계속 적용됩니다. > +> **현재 설정 기능:** 웹과 Android APK는 범위, 상속, 초기화, 유효성 검사, 보호된 비밀/관리자 제어를 포함하는 동일한 검색·카테고리형 OMP 설정 카탈로그를 사용합니다. 기기 로컬의 추론 표시 설정은 대화 렌더링만 바꾸며 최종 답변은 숨기지 않습니다. +> > 네이티브 인터페이스는 웹 앱의 Warm 및 OMP 색상 팔레트를 따르며, 간결한 프로젝트/세션 트리와 설정/사용량 하단 메뉴, 도구 모음을 한 줄로 정리한 메시지 입력창을 제공합니다. 기록, 세션 정보, 명령 및 런타임 제어는 세션 메뉴에 모았고, 보조 워크스페이스 작업은 더보기 메뉴에 유지했습니다. 런타임 제어는 펼칠 수 있는 트리 카테고리로 구성하고 현재 값을 일정한 열에 맞춰 표시합니다. 설정은 간결한 행 단위 선택 컨트롤을 사용하며, 드롭다운 메뉴는 해당 컨트롤에 붙어 열립니다. Android 네이티브 컨트롤, 파일 선택기, 시트 탐색 방식과 서로 겹치지 않는 최소 48dp 터치 영역을 유지합니다. > > 이 클라이언트는 호스팅형/E2E 릴레이를 추가하지 않습니다. JDK 21을 사용하여 저장소 루트에서 `npm install`을 실행한 뒤, `android/`에서 `./gradlew :app:assembleDebug`로 빌드합니다. 오프라인 에셋은 설치된 npm 의존성에서 생성되며, 출력 경로는 `android/app/build/outputs/apk/debug/app-debug.apk`입니다. 서버에서 비활성화된 업데이트, 로그아웃, 저장된 API 키 변경 작업은 여전히 사용할 수 없습니다. @@ -142,7 +144,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **정확한 Markdown 미리보기**: YAML frontmatter를 요약 카드(제목 및 키/값 행)로 렌더링하고, 수식 펜스를 목록 안에서도 정렬하며, `5~7U` 같은 CJK 범위를 올바르게 표시합니다(GFM에서는 취소선에 이제 `~~`가 필요합니다). - **Windows에서 자연스럽게 프로젝트 선택**: 파일 시스템 루트의 드라이브 선택기와 대소문자 무시·심볼릭 링크 인식 프로젝트 식별자로 드라이브와 worktree 사이에서도 사이드바를 안정적으로 유지합니다. - **세션 상태 명확히 확인**: 상단 바에서 컨텍스트 사용량, 비용, 압축 상태, 시스템 프롬프트 세부 정보를 확인합니다. -- **터미널 설정 줄이기**: 웹 UI에서 모델, 로그인/API 키, 모델 테스트, OMP 기본 제어(어드바이저, 승인, Bash 정책, 추론, 압축, 메모리, 자동 학습, 재시도/폴백), 스킬, 플러그인, 프로젝트 MCP 서버를 관리합니다. +- **OMP 설정을 일관되게 관리**: 웹과 Android가 모델, 에이전트, 도구, 안전, 시스템 동작을 위한 하나의 검색·카테고리형 설정 카탈로그를 공유하며 전역/프로젝트 상속, 기본값 초기화, 고급 값 검증을 제공합니다. - **Settings에서 MCP 관리**: 전용 MCP 탭에서 설치된 프로젝트 서버의 상태(활성화/비활성화/잘못됨)를 확인하고 추가·편집·이름 변경·검증·삭제를 수행하며 설정 오류를 코너 토스트로 표시합니다. - **OMP 최신 상태 유지**: Settings에서 설치된 런타임 버전을 확인하고 업데이트하거나 필요할 때 활성 세션을 재시작합니다. - **완료 알림 받기**: 에이전트가 작업을 마치면 브라우저 알림을 받도록 선택하고 설치된 스킬의 업데이트를 확인합니다. @@ -161,6 +163,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | 다른 omp 에이전트 디렉터리 지정(기본 `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 서버 측 요청에 사용하는 표준 프록시 변수 | +### OMP 설정 + +웹 UI와 Android APK는 동일한 공유 카탈로그를 카테고리/검색 방식으로 표시하며 기본값, 전역 또는 프로젝트 범위, 상속된 유효 값, 초기화를 지원합니다. 전역 설정은 `~/.omp/agent/config.yml`을 사용하며, `config.yml`이 없고 기존 `config.yaml`이 있을 때만 그 파일을 읽기/쓰기 대체 경로로 사용합니다. 프로젝트 설정은 승인된 워크스페이스의 표준 `.omp/config.yml`만 사용합니다. 화면에 표시되는 유효 값은 프로젝트 계층을 전역 계층 위에 재귀적으로 병합합니다. 저장할 때는 구조화된 레코드가 선택한 계층의 해당 레코드 전체를 대체하고, 순서가 있는 배열은 순서를 유지합니다. 숫자, enum, 배열, 구조화된 값은 최소 YAML 업데이트 전에 검증되고 관련 없는 키와 주석은 보존됩니다. + +표시되는 유효 값은 저장된 전역/프로젝트 구성의 병합 결과이며, 이미 실행 중인 세션의 실제 값이나 환경 변수/CLI 재정의 값이 아닙니다. 변경은 새 세션 또는 재시작한 세션에서 적용되며 저장만으로 실행 중인 RPC 세션을 자동 재시작하지 않습니다. 카탈로그에서 확인 대상으로 지정한 변경에는 명시적 확인이 필요합니다. 비밀 값은 쓰기 전용이므로 클라이언트에는 존재 여부만 반환되고 교체 또는 삭제만 가능하며 저장된 값은 읽을 수 없습니다. 기기별 추론 표시 설정은 로컬 표현에만 영향을 주어 추론 블록은 숨길 수 있지만 최종 답변은 숨기지 않습니다. + ## 아키텍처 ompgui은 Node가 호스팅하는 Next.js 앱으로, 설치된 `omp` 바이너리를 구동합니다. 에이전트를 내장하지 않습니다. @@ -168,7 +176,7 @@ ompgui은 Node가 호스팅하는 Next.js 앱으로, 설치된 `omp` 바이너 - **실시간 세션**: `omp --mode rpc-ui`(stdio 기반 NDJSON)를 실행하며 활성 세션마다 자식 프로세스를 하나씩 사용합니다. 설치된 OMP가 RPC v2를 알리면 v2를 협상하고, 큰 프레임은 제한된 청크 재조립을 사용하며, 구버전은 v1로 폴백합니다. 실행 전 호스트 환경(`PORT`, `NEXT_*`, `NODE_ENV`)을 제거하고 POSIX(프로세스 그룹)와 Windows(`taskkill /t`) 모두 정상 종료를 지원합니다. - **세션 탐색**: omp 세션 파일(`~/.omp/agent/sessions//_.jsonl`)을 직접 읽습니다. 제목·보관·삭제는 실시간 OMP 쓰기를 보호하는 제한적인 기본 파일 유지보수 작업입니다. 프로젝트는 안정적인 `projectKey`(Windows 대소문자 무시, 심볼릭 링크 해석)로 묶어 사이드바가 드라이브나 worktree 사이에서 이동하지 않게 합니다. - **모델 및 인증**: 엄격한 페이로드 검증(알 수 없는 형태 차단, 안전한 폴백)을 적용한 RPC 명령으로 omp 자식 프로세스와 통신합니다. Models 패널은 omp 에이전트 디렉터리의 `models.yml`을 편집하며 빈 자리 행을 제거하고 모호한 `enabledModels` 항목을 거부합니다. -- **기본 설정**: General/MCP 설정 패널은 `~/.omp/agent/config.yml`(또는 `config.yaml` 폴백)의 허용 목록에 포함된 항목을 읽고 씁니다. 관련 없는 키와 주석은 보존하며 변경 사항은 새 세션과 재시작한 세션에 적용됩니다. +- **OMP 설정**: `lib/omp/settings-catalog.ts`가 웹과 Relay/Android가 공유하는 카탈로그를 정의하고, 설정 서비스가 카탈로그로 필터링한 계층별/유효 값을 반환하며 검증된 최소 패치를 적용합니다. 전역 및 승인된 프로젝트 쓰기는 OMP 호환 프로세스 간 잠금과 원자적 교체를 사용해 네이티브 OMP와 ompgui가 서로의 파일 세대를 덮어쓰지 않게 합니다. - **스킬 및 플러그인**: omp 스킬 디렉터리(`~/.omp/agent/skills`, 프로젝트 `.omp/skills`, 호환 디렉터리)를 검색하고 플러그인 관리는 `omp plugin`을 호출합니다. - **MCP 서버**: 프로젝트 서버는 Git 최상위의 OMP 기본 위치(`.omp/mcp.json`, 이후 호환 파일)를 통해 관리하며 stdio/http/sse 스키마를 검증하고 원자적으로 기록합니다. - **파일 접근**: 파일 탐색과 미리보기는 선택한 프로젝트 디렉터리 및 세션에 나타난 작업 디렉터리로 제한됩니다. `isWindowsAbsolutePath`/`samePath` 단일 헬퍼로 경로를 정규화하고 `realpath` 해석 뒤 심볼릭 링크 탈출을 거부합니다. Windows에서는 루트에서 드라이브 목록을 제공하는 디렉터리 선택기를 사용합니다. @@ -212,7 +220,7 @@ ompgui은 영어, 중국어 간체(简体中文), 일본어(日本語), 한국 - **접근성**: WCAG AA 준수 — Lighthouse 접근성 점수 100/100, 전면 키보드 탐색, focus-visible 링, ARIA 역할 - **성능**: 메모이제이션된 목록 컴포넌트, RAF로 제한한 스크롤/마우스 핸들러, 디바운스 검색, 스트리밍 JSONL 리더, ETag 캐시 세션 목록 - **복원력**: omp 자식 프로세스의 정상 종료(프로세스 그룹 종료), 오류 경계, 원자적 세션 파일 재작성 -- **테스트**: 세션 파싱, 터미널 입력, Markdown 렌더링, 메시지 표시, 기본 설정, MCP 구성을 다루는 집중 테스트 스위트 +- **테스트**: 세션 파싱, 터미널 입력, Markdown 렌더링, 메시지 표시, OMP 설정, MCP 구성을 다루는 집중 테스트 스위트 ## 크레딧 diff --git a/README.md b/README.md index e6101dcd8..e27ed4907 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ [English](./README.md) | [한국어](./README.ko.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) -> **Android APK (Android 12+)** — Use the Kotlin companion app to connect to a remote ompgui server, with a read-only offline snapshot of the latest session. [Download ompgui Remote v0.7.10](https://github.com/dbc-hbin/ompgui/releases/download/android-v0.7.10/ompgui-remote-v0.7.10.apk) · [Release notes](https://github.com/dbc-hbin/ompgui/releases/tag/android-v0.7.10) +> **Android APK (Android 12+)** — Use the Kotlin companion app to connect to a remote ompgui server, with a read-only offline snapshot of the latest session. [Download ompgui Remote v0.7.11](https://github.com/dbc-hbin/ompgui/releases/download/v0.7.11/ompgui-remote-v0.7.11.apk) · [Release notes](https://github.com/dbc-hbin/ompgui/releases/tag/v0.7.11) > The Kotlin companion app in `android/` uses authenticated `/relay` WebSockets for session/history controls and rich online transcripts with linked messages, tool calls/results/errors, and media references, plus offline Mermaid diagrams and code highlighting and inline image/PDF/audio/HTML/Markdown/DOCX previews. Online history is independent of the protected offline snapshot: bounded previews fetch full text through `sessions.content` and paginate media references, rather than truncating the transcript at 4,000 characters. Offline cache privacy restrictions remain unchanged. Visible-only file auto-refresh preserves unsaved edits; browsing supports allowed hidden files and archive search. Models/settings include the public models.dev catalog, advanced OMP settings, and actual per-session MCP runtime status shown separately from configuration. Attachments are streamed into staging rather than packed into one giant phone JSON payload: up to 10 images at 10 MiB each and, independently, 10 text attachments at 256 KiB each. OMP's own image normalization and provider-specific image-count limi… > -> New in v0.7.0: Settings redesigned with tabbed categories and always-visible search (web-like parity), all Strings migrated to string resources (English/Korean/Japanese/Chinese complete), Usage sheet redesigned with capacity chips and color-coded progress bars, and tighter session/message spacing. +> **Current settings:** Web and the Android APK now use the same searchable, categorized OMP settings catalog, including scope, inheritance, reset, validation, and guarded secret/admin controls. The device-local thinking-visibility switch only changes transcript rendering; final answers remain visible. > > Its native interface follows the web app’s Warm and OMP palettes, with a compact project/session tree, a Settings/Usage footer, and a compact composer with a single-row toolbar. History, session information, commands, and runtime controls live in the session menu; secondary workspace actions remain in overflow menus. Runtime controls use expandable tree categories with aligned current values. Settings use compact row selectors, and dropdown menus are anchored to their triggers. Native Android controls, file pickers, sheet navigation, and distinct touch regions of at least 48 dp are retained. > @@ -144,7 +144,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **Preview markdown faithfully**: YAML frontmatter renders in a summary card (title + key/value rows), math fences stay aligned inside lists, and CJK ranges like `5~7U` are no longer mangled (GFM now requires `~~` for strikethrough). - **Pick projects naturally on Windows**: a drive picker at the filesystem root and a case-folded, symlink-aware project identity keep the sidebar stable across drives and worktrees. - **See session state clearly**: context usage, cost, compaction state, and system prompt details are visible from the top bar. -- **Configure less from the terminal**: manage models, login/API keys, model tests, native OMP controls (advisor, approval, Bash policy, thinking, compaction, memory, auto-learn, retry/fallback), skills, plugins, and project MCP servers from the web UI. +- **Configure OMP consistently**: web and Android share one searchable, categorized settings catalog for models, agents, tools, safety, and system behavior, with global/project inheritance, reset-to-default controls, and validated advanced values. - **MCP management in Settings**: a dedicated MCP tab lists installed project servers with status (enabled / disabled / invalid), supports add/edit/rename/validate/remove, and surfaces configuration failures as corner toasts. - **Keep OMP current**: check the installed runtime version, update it, and restart active sessions from Settings when needed. - **Stay informed**: opt into browser notifications when an agent finishes, and check installed skills for updates. @@ -163,6 +163,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | Point at another omp agent directory (default `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | Standard proxy variables for server-side requests | +### OMP settings + +The web UI and Android APK render the same shared catalog with category/search navigation, defaults, global or project scope, inherited effective values, and reset. Global settings use `~/.omp/agent/config.yml`; only when it is absent and `config.yaml` already exists is that file used as the read/write fallback. Project settings use only the authorized workspace's canonical `.omp/config.yml`. Displayed effective values recursively merge the project layer over the global layer. On save, a structured record replaces the corresponding record in the selected layer, while ordered arrays retain their order. Numbers, enums, arrays, and structured values are validated before a minimal YAML update; unrelated keys and comments are preserved. + +The displayed effective value is the saved global/project configuration, not the value of an already-running session and not environment-variable or CLI overrides. Changes are picked up by new or restarted sessions; saving does not automatically restart live RPC sessions. Changes marked by the catalog as confirmation-required require explicit confirmation. Secrets are write-only: clients receive presence status only and can replace or delete them, never read their stored value. The per-device thinking-visibility preference is local presentation only: it can hide thinking blocks without hiding the final answer. + ## Architecture ompgui is a Node-hosted Next.js app that drives your installed `omp` binary — it does not embed the agent: @@ -170,7 +176,7 @@ ompgui is a Node-hosted Next.js app that drives your installed `omp` binary — - **Live sessions**: spawns `omp --mode rpc-ui` (NDJSON over stdio), one child process per active session, so the agent version is always exactly what you have installed. It negotiates RPC v2 when the installed OMP advertises it, uses bounded chunk reassembly for large frames, and falls back to v1 for older versions. Host env (`PORT`, `NEXT_*`, `NODE_ENV`) is stripped before spawn, and shutdown is graceful on both POSIX (process-group) and Windows (`taskkill /t`). - **Session browsing**: reads omp's session files (`~/.omp/agent/sessions//_.jsonl`) directly; title, archive, and delete are narrow native-file maintenance operations guarded against live OMP writes. Projects are grouped by a stable `projectKey` (Windows case-folded, symlink-resolved) so the sidebar doesn't jump between drives or worktrees. - **Models and auth**: RPC commands against the omp child process with strict payload validation (unknown-shape guards, safe fallbacks); the Models panel edits `models.yml` in the omp agent directory, dropping blank placeholder rows and rejecting ambiguous `enabledModels` entries. -- **Native settings**: the General/MCP settings panels read and write the allow-listed subset of `~/.omp/agent/config.yml` (or `config.yaml` fallback), preserving unrelated keys and comments. Changes apply to new and restarted sessions. +- **Native settings**: `lib/omp/settings-catalog.ts` defines the catalog shared by the web and Relay/Android clients, while the settings service returns catalog-filtered per-layer and effective values and applies validated minimal patches. Global and authorized project writes use OMP-compatible cross-process locks plus atomic replacement so native OMP and ompgui cannot overwrite one another's generation. - **Skills and plugins**: scans omp's skill directories (`~/.omp/agent/skills`, project `.omp/skills`, and compat dirs) and shells out to `omp plugin` for plugin management. - **MCP servers**: project servers are managed through OMP's native locations (`.omp/mcp.json`, then compatibility files) at the git top level, validated against the stdio/http/sse schema and written atomically. - **File access**: file browsing and preview are scoped to the selected project directory and working directories that appear in sessions; paths are canonicalized via a single `isWindowsAbsolutePath`/`samePath` helper and symlink escapes are rejected after `realpath` resolution. On Windows the directory picker offers a drive list at the root. diff --git a/README.zh-CN.md b/README.zh-CN.md index 056d60747..319e0a512 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -2,10 +2,12 @@ [English](./README.md) | [한국어](./README.ko.md) | [日本語](./README.ja.md) | [简体中文](./README.zh-CN.md) -> **Android APK(Android 12+)** — 使用 Kotlin 配套应用连接远程 ompgui 服务器,并查看最新会话的只读离线快照。[下载 ompgui Remote v0.7.10](https://github.com/dbc-hbin/ompgui/releases/download/android-v0.7.10/ompgui-remote-v0.7.10.apk) · [发行说明](https://github.com/dbc-hbin/ompgui/releases/tag/android-v0.7.10) +> **Android APK(Android 12+)** — 使用 Kotlin 配套应用连接远程 ompgui 服务器,并查看最新会话的只读离线快照。[下载 ompgui Remote v0.7.11](https://github.com/dbc-hbin/ompgui/releases/download/v0.7.11/ompgui-remote-v0.7.11.apk) · [发行说明](https://github.com/dbc-hbin/ompgui/releases/tag/v0.7.11) > `android/` 中的实验性原生 Compose 客户端使用经过身份验证的 `/relay` WebSocket,提供会话/历史记录控制、带链接的消息、离线 Mermaid 图表与代码语法高亮,以及图片/PDF/音频/HTML/Markdown/DOCX 内联预览。文件仅在界面可见时自动刷新,并保留未保存的编辑;支持浏览允许访问的隐藏文件和搜索归档。模型与设置包括公开的 models.dev 目录、高级 OMP 设置,以及与配置状态分开显示的各会话实际 MCP 运行状态。附件通过流式传输暂存,而不是在手机端打包成一个巨大的 JSON:图片最多 10 个,每个 10 MiB;文本附件独立计数,最多 10 个,每个 256 KiB。OMP 自身的图像规范化处理和各提供商的图片数量限制仍然适用。 > +> **当前设置功能:** Web 与 Android APK 使用同一套可搜索、分类的 OMP 设置目录,包括作用域、继承、重置、校验以及受保护的机密/管理员操作。设备本地的思考可见性开关只改变对话渲染,不会隐藏最终答案。 +> > 此客户端不会添加托管式/E2E 中继。构建时使用 JDK 21,先在仓库根目录运行 `npm install`,再在 `android/` 中运行 `./gradlew :app:assembleDebug`;离线资源从已安装的 npm 依赖生成。输出文件为 `android/app/build/outputs/apk/debug/app-debug.apk`。服务器禁用的更新、退出登录以及已存储 API 密钥的修改操作仍不可用。 [oh-my-pi (omp) 编程智能体](https://github.com/can1357/oh-my-pi)的本地 Web UI。ompgui 读取本机的 omp 会话文件,在浏览器中提供一个工作区,支持会话浏览、实时对话、模型配置、技能管理和项目文件预览。 @@ -127,7 +129,7 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui - **跨分支工作**:在侧边栏切换 Git 工作树,新会话和资源管理器都会跟随你选择的检出。 - **边看项目边聊天**:左侧浏览文件,右侧预览源码、文档、图片、音频和 PDF,同时智能体继续工作。 - **清晰掌握会话状态**:上下文用量、费用、压缩上下文状态和系统提示词详情都显示在顶栏。 -- **减少对终端配置的依赖**:在 Web UI 中管理模型、登录/API 密钥、模型测试、原生 OMP 控制(顾问、审批、Bash 策略、思考、压缩、记忆、自动学习、重试/回退)、技能、插件和项目 MCP 服务器。 +- **一致地管理 OMP 设置**:Web 与 Android 共享一套面向模型、智能体、工具、安全和系统行为的可搜索、分类设置目录,并提供全局/项目继承、重置为默认值以及高级值校验。 - **在设置中管理 MCP**:专用 MCP 标签页显示项目服务器状态(已启用 / 已禁用 / 无效),支持添加、编辑、重命名、校验和删除,并通过角落提示显示配置失败。 - **保持 OMP 为最新版本**:可在设置中检查已安装运行时、更新它,并按需重启活动会话。 - **及时获知完成状态**:可选择在智能体完成时接收浏览器通知,并检查已安装技能的更新。 @@ -146,6 +148,12 @@ OMP_WEB_HOSTNAME=0.0.0.0 OMP_WEB_PASSWORD="your-strong-password" ompgui | `PI_CODING_AGENT_DIR` | 指向其他 omp agent 目录(默认 `~/.omp/agent`) | | `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` | 服务器端请求使用的标准代理变量 | +### OMP 设置 + +Web UI 与 Android APK 通过分类和搜索呈现同一个共享目录,支持默认值、全局或项目作用域、继承后的有效值以及重置。全局设置使用 `~/.omp/agent/config.yml`;只有在 `config.yml` 不存在且已有 `config.yaml` 时,才将后者作为读写回退。项目设置仅使用已授权工作区中的规范路径 `.omp/config.yml`。界面显示有效值时,会把项目层递归合并到全局层之上;保存时,结构化记录会替换所选层中对应的完整记录,有序数组则保留顺序。数字、枚举、数组和结构化值会在最小化更新 YAML 前接受校验;无关的键和注释会保留。 + +界面显示的有效值是已保存的全局/项目配置合并结果,不是已运行会话中的实际值,也不包含环境变量或 CLI 覆盖。更改由新建或重启后的会话读取;保存不会自动重启正在运行的 RPC 会话。目录中被指定为需要确认的更改必须得到明确确认。机密值为只写:客户端只收到是否已设置的状态,可以替换或删除,但不能读取已保存的值。每台设备的思考可见性设置只影响本地显示:可以隐藏思考块,但不会隐藏最终答案。 + ## 架构 ompgui 是一个由 Node 托管的 Next.js 应用,驱动你已安装的 `omp` 二进制文件——它并不内嵌智能体: @@ -153,6 +161,7 @@ ompgui 是一个由 Node 托管的 Next.js 应用,驱动你已安装的 `omp` - **实时会话**:启动 `omp --mode rpc-ui`(基于 stdio 的 NDJSON),每个活动会话对应一个子进程,因此智能体版本始终与你安装的完全一致。 - **会话浏览**:直接读取 omp 的会话文件(`~/.omp/agent/sessions//_.jsonl`);标题、归档和删除是受保护的原生文件维护操作,不会与 OMP 的实时写入竞争。 - **模型与认证**:通过 RPC 命令与 omp 子进程交互;模型面板编辑 omp agent 目录中的 `models.yml`。 +- **OMP 设置**:`lib/omp/settings-catalog.ts` 定义 Web 与 Relay/Android 共用的目录;设置服务返回经目录过滤的各层值和有效值,并应用通过校验的最小补丁。全局及已授权项目的写入使用与 OMP 兼容的跨进程锁和原子替换,避免原生 OMP 与 ompgui 相互覆盖文件版本。 - **技能与插件**:扫描 omp 的技能目录(`~/.omp/agent/skills`、项目内 `.omp/skills` 及兼容目录),并调用 `omp plugin` 进行插件管理。 - **文件访问**:文件浏览与预览仅限于所选项目目录以及会话中出现过的工作目录。 - **分叉与会话内分支**:分叉会创建新的 `.jsonl` 文件;“从此处编辑”则在同一会话文件内创建另一个分支。 diff --git a/android/app/build.gradle b/android/app/build.gradle index 1889603fe..1fd0ced9e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -51,8 +51,8 @@ android { applicationId "com.dbchbin.ompgui.remote" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 708 - versionName "0.7.8" + versionCode 711 + versionName "0.7.11" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d195d8c45..b4f3cf56a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -52,6 +52,7 @@ + diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayClient.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayClient.kt index 02ab9efca..45b7dede5 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayClient.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayClient.kt @@ -2,6 +2,8 @@ package com.dbchbin.ompgui.remote.net import android.app.Application import android.content.Context +import android.net.ConnectivityManager +import android.net.Network import android.os.Build import android.os.Handler import android.os.Looper @@ -15,6 +17,7 @@ import com.dbchbin.ompgui.remote.relay.parseRecalledDraft import com.dbchbin.ompgui.remote.relay.reconcileMessageQueue import com.dbchbin.ompgui.remote.relay.AttachmentSource import com.dbchbin.ompgui.remote.relay.AttachmentTransfer +import com.dbchbin.ompgui.remote.relay.ChatRequests import com.dbchbin.ompgui.remote.relay.ClientFrame import com.dbchbin.ompgui.remote.relay.EventProjector import com.dbchbin.ompgui.remote.relay.PairingPolicy @@ -56,6 +59,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import org.json.JSONObject +internal fun resolvedBranchLeafId(requestedLeafId: String?, persistedLeafId: String?): String? = + requestedLeafId ?: persistedLeafId + /** * Process-scoped owner of the relay WebSocket and all connection/UI state. * @@ -212,12 +218,68 @@ class RelayClient private constructor( fun refreshMessageQueue() { val session = openedSessionId ?: return - scopedRequest("sessions", "command", JSONObject().put("id", session) - .put("command", JSONObject().put("type", "get_message_queue"))) { result -> + scopedRequest( + "sessions", + "command", + JSONObject().put("id", session) + .put("command", JSONObject().put("type", "get_message_queue")), + onError = {}, + ) { result -> applyMessageQueue(result.optJSONObject("result") ?: result) } } + fun loadEarlierMessages() { + val state = _ui.value + val session = openedSessionId ?: return + val pageRange = ChatRequests.previousLivePage(state.transcriptOffset) ?: return + if (state.earlierMessagesLoading || awaitingSnapshot) return + val generation = sessionGeneration + val leafId = state.branchLeafId + _ui.update { it.copy(earlierMessagesLoading = true, earlierMessagesError = null) } + requestScope.launch { + try { + val response = request( + ChatRequests.DOMAIN_SESSIONS, + ChatRequests.ACTION_HISTORY, + ChatRequests.liveHistoryArgs(session, leafId, pageRange.first, pageRange.count()), + ) + val page = ChatRequests.parseHistoryPage(response) + if (!ChatRequests.isCurrentLiveHistoryRequest( + generation, sessionGeneration, session, openedSessionId, + leafId, _ui.value.branchLeafId, page.leafId, + )) return@launch + check(page.offset == pageRange.first && page.messages.length() <= pageRange.count()) { + "Invalid earlier history page" + } + val earlier = ChatRequests.liveHistoryMessages(page) + _ui.update { + it.copy( + messages = ChatRequests.prependEarlierMessages(it.messages, earlier), + transcriptTotal = maxOf(it.transcriptTotal, page.total), + transcriptOffset = page.offset, + earlierMessagesLoading = false, + earlierMessagesError = null, + ) + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + if (ChatRequests.isCurrentLiveHistoryRequest( + generation, sessionGeneration, session, openedSessionId, + leafId, _ui.value.branchLeafId, null, + )) { + _ui.update { + it.copy( + earlierMessagesLoading = false, + earlierMessagesError = app.getString(R.string.chat_earlier_failed), + ) + } + } + } + } + } + fun consumeRecalledDraft(id: String) { _ui.update { if (it.recalledDraft?.id == id) it.copy(recalledDraft = null) else it } } @@ -289,7 +351,10 @@ class RelayClient private constructor( requestedLeafId = null invalidateRequests("The selected session changed") _ui.update { it.copy( - messages = emptyList(), running = false, draft = "", chatTitle = "", + messages = emptyList(), transcriptTotal = 0, transcriptOffset = 0, + transcriptNextOffset = 0, transcriptHasMore = false, + earlierMessagesLoading = false, earlierMessagesError = null, + running = false, draft = "", chatTitle = "", currentModel = null, pickerOpen = false, todos = emptyList(), subagents = emptyList(), contextFraction = null, sessionThinkingLevel = null, sessionCwd = null, filesPath = "", branches = emptyList(), branchLeafId = null, @@ -321,7 +386,7 @@ class RelayClient private constructor( invalidateRequests("Relay connection changed") pendingSessionId = openedSessionId awaitingSnapshot = openedSessionId != null - _ui.update { it.copy(extensionStatus = emptyMap(), extensionWidgets = emptyMap()) } + _ui.update { it.copy(extensionStatus = emptyMap(), extensionWidgets = emptyMap(), sessionsRefreshing = false, sessionsLoaded = false) } } _ui.update { it.copy(connection = state) } when (state) { @@ -342,10 +407,16 @@ class RelayClient private constructor( override fun onProtocolError(message: String) { setUiError(message, RelayUiErrorKind.Protocol) } + + override fun onRetryScheduled(delayMs: Long?) { + val at = delayMs?.let { System.currentTimeMillis() + it } + _ui.update { it.copy(nextRetryAtMillis = at) } + } }) val device = store.load() if (device != null) { _ui.update { it.copy(screen = RemoteScreen.Sessions, paired = true) } + registerNetworkCallback() connection.connectToken(device.relayUrl, device.deviceId, device.token, deviceLabel) } else if (!store.isAvailable()) { uiErrorKind = RelayUiErrorKind.Pairing @@ -410,7 +481,58 @@ class RelayClient private constructor( } fun refreshSessions() { - connection.send(ClientFrame.SessionsList) + if (connection.send(ClientFrame.SessionsList)) { + _ui.update { it.copy(sessionsRefreshing = true) } + } else { + reconnectNow() + } + } + + /** + * User- or network-initiated reconnect: skips any pending backoff. When the + * connection gave up (Failed without a scheduled retry), a user request + * restarts from the saved device credentials. + */ + fun reconnectNow() { + if (connection.reconnectNow()) return + if (connection.state != ConnectionState.Failed) return + val device = store.load() ?: return + connection.connectToken(device.relayUrl, device.deviceId, device.token, deviceLabel) + } + + private var networkCallback: ConnectivityManager.NetworkCallback? = null + + /** Reconnects as soon as a default network appears; kept for as long as a device is paired. */ + private fun registerNetworkCallback() { + if (networkCallback != null) return + val manager = app.getSystemService(ConnectivityManager::class.java) ?: return + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + handler.post { + if (networkCallback !== this) return@post + val state = connection.state + if (state == ConnectionState.Failed || state == ConnectionState.Connecting) { + connection.reconnectNow() + } + } + } + } + try { + manager.registerDefaultNetworkCallback(callback, handler) + networkCallback = callback + } catch (_: RuntimeException) { + // SecurityException without ACCESS_NETWORK_STATE, or the per-app callback limit. + } + } + + private fun unregisterNetworkCallback() { + val callback = networkCallback ?: return + networkCallback = null + try { + app.getSystemService(ConnectivityManager::class.java)?.unregisterNetworkCallback(callback) + } catch (_: IllegalArgumentException) { + // Already unregistered. + } } fun openModelPicker() { @@ -670,7 +792,12 @@ class RelayClient private constructor( fun fetchBranches(id: String) { val sessionId = id.trim() if (sessionId.isEmpty()) return - scopedRequest("sessions", "branches", JSONObject().put("id", sessionId)) { result -> + scopedRequest( + "sessions", + "branches", + JSONObject().put("id", sessionId), + onError = {}, + ) { result -> if (openedSessionId != sessionId) return@scopedRequest val entries = result.getJSONArray("branches") val branches = buildList { @@ -681,7 +808,11 @@ class RelayClient private constructor( } } _ui.update { it.copy(branches = branches, - branchLeafId = requestedLeafId ?: result.optString("leafId").takeIf { leaf -> leaf.isNotBlank() && leaf != "null" }) } + branchLeafId = resolvedBranchLeafId( + requestedLeafId, + result.optString("leafId").takeIf { leaf -> leaf.isNotBlank() && leaf != "null" }, + ), + ) } } } @@ -704,7 +835,12 @@ class RelayClient private constructor( val text = query.trim() if (directory.isEmpty() || text.isEmpty()) return fileMatchQuery = text - scopedRequest("files", "search", JSONObject().put("cwd", directory).put("query", text)) { result -> + scopedRequest( + "files", + "search", + JSONObject().put("cwd", directory).put("query", text), + onError = {}, + ) { result -> if (fileMatchQuery != text) return@scopedRequest val entries = result.getJSONArray("matches") val matches = buildList { @@ -768,6 +904,7 @@ class RelayClient private constructor( if (opened != null) RelayNotifications.cancelAgentDone(app, opened) if (pending != null && pending != opened) RelayNotifications.cancelAgentDone(app, pending) RelayForegroundService.stop(app) + unregisterNetworkCallback() connection.close() _ui.value = RemoteUiState(screen = RemoteScreen.Pairing, paired = false) } @@ -823,6 +960,7 @@ class RelayClient private constructor( pairingServerId = serverId if (url != null && token != null) { connection.promoteToToken(url, frame.deviceId, token, deviceLabel) + registerNetworkCallback() } if (saveFailed) { uiErrorKind = RelayUiErrorKind.Pairing @@ -853,6 +991,7 @@ class RelayClient private constructor( when { PairingPolicy.shouldClearCredentials(frame.code, hasSavedDevice, attempt) -> { store.clear() + unregisterNetworkCallback() RelayForegroundService.stop(app) setUiError(frame.message, RelayUiErrorKind.Pairing, frame.code) _ui.update { @@ -898,6 +1037,8 @@ class RelayClient private constructor( _ui.update { it.copy( sessions = frame.sessions, + sessionsLoaded = true, + sessionsRefreshing = false, runningIds = frame.runningIds.toSet(), ) } @@ -909,6 +1050,7 @@ class RelayClient private constructor( if (frame.id != openedSessionId) return if (requestedLeafId != null && frame.leafId != requestedLeafId) return awaitingSnapshot = false + clearUiErrors(RelayUiErrors.clearKindsOnHealthySnapshot()) requestSessionState() fetchBranches(frame.id) val snapshotModel = frame.agent.model @@ -917,6 +1059,12 @@ class RelayClient private constructor( chatTitle = frame.title?.takeIf { title -> title.isNotBlank() } ?: it.chatTitle, messages = frame.messages, + transcriptTotal = frame.transcript.total, + transcriptOffset = frame.transcript.offset, + transcriptNextOffset = frame.transcript.nextOffset, + transcriptHasMore = frame.transcript.hasMore, + earlierMessagesLoading = false, + earlierMessagesError = null, running = frame.agent.running, currentModel = snapshotModel ?: it.currentModel, sessionCwd = frame.cwd ?: it.sessionCwd, @@ -969,7 +1117,12 @@ class RelayClient private constructor( is ServerFrame.GitStatusResult, is ServerFrame.GitDiffResult -> Unit // Correlated domain requests own Git state. is ServerFrame.Branches -> { if (frame.id != openedSessionId) return - _ui.update { it.copy(branches = frame.branches, branchLeafId = frame.leafId) } + _ui.update { + it.copy( + branches = frame.branches, + branchLeafId = resolvedBranchLeafId(requestedLeafId, frame.leafId), + ) + } } is ServerFrame.SessionExported, is ServerFrame.Skills, is ServerFrame.SkillUpdated, is ServerFrame.Plugins, is ServerFrame.Mcp, is ServerFrame.McpDeleted, is ServerFrame.McpUpserted, @@ -1076,17 +1229,19 @@ class RelayClient private constructor( } is ServerFrame.CmdErr -> { pendingSubagentRosters.remove(frame.req) - pendingCmds.remove(frame.req) ?: return - setUiError(frame.message, RelayUiErrorKind.Command, frame.code) + val command = pendingCmds.remove(frame.req) ?: return + if (RelayUiErrors.shouldSurfaceCommandFailure(command)) { + setUiError(frame.message, RelayUiErrorKind.Command, frame.code) + } } is ServerFrame.CmdOk -> { when (pendingCmds.remove(frame.req)) { - "get_state", "set_model" -> { + "get_state" -> applySessionState(frame) + "set_model" -> { clearUiErrors(setOf(RelayUiErrorKind.Command)) applySessionState(frame) } "get_subagents" -> { - clearUiErrors(setOf(RelayUiErrorKind.Command)) val items = frame.data?.optJSONArray("items") ?: frame.data?.optJSONArray("subagents") val snapshot = parseSubagentChips(items, live = true) @@ -1110,6 +1265,9 @@ class RelayClient private constructor( } } is ServerFrame.Error -> { + // Error frames carry no op/req; a failed sessions.list (internal_error, busy) + // answers only with this, so the pull-to-refresh spinner must stop here too. + _ui.update { it.copy(sessionsRefreshing = false) } setUiError(frame.message, RelayUiErrorKind.Protocol, frame.code) } is ServerFrame.Usage -> { diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayConnection.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayConnection.kt index df27a2b0f..c62eb6fe3 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayConnection.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/net/RelayConnection.kt @@ -93,6 +93,8 @@ class RelayConnection( fun onState(state: ConnectionState) fun onFrame(frame: ServerFrame) fun onProtocolError(message: String) + /** Backoff reconnect scheduled [delayMs] from now, or null once it is cancelled or started. */ + fun onRetryScheduled(delayMs: Long?) {} } @Volatile @@ -108,6 +110,7 @@ class RelayConnection( private var reconnectHello: ClientFrame.Hello? = null private var backoffMs = INITIAL_BACKOFF_MS private var reconnectGeneration = 0 + private var retryPending = false fun setListener(listener: Listener?) { this.listener = listener @@ -180,10 +183,36 @@ class RelayConnection( pendingHello = null transport?.close() transport = null + clearRetry() emitState(ConnectionState.Idle) } + /** + * Cancels a scheduled backoff and reconnects with the saved token hello right away. + * Returns false when there is nothing to reconnect: closed, not in token mode, + * already connected, or a live connect attempt is in flight. + */ + @Synchronized + fun reconnectNow(): Boolean { + if (closed.get() || mode != Mode.Token) return false + val url = reconnectUrl ?: return false + val hello = reconnectHello ?: return false + if (state == ConnectionState.Connected) return false + if (state == ConnectionState.Connecting && !retryPending && transport != null) return false + backoffMs = INITIAL_BACKOFF_MS + pendingHello = hello + start(url) + return true + } + + private fun clearRetry() { + if (!retryPending) return + retryPending = false + listener?.onRetryScheduled(null) + } + private fun start(url: String) { + clearRetry() reconnectGeneration += 1 assembler.clear() transport?.close() @@ -286,6 +315,8 @@ class RelayConnection( val generation = reconnectGeneration val delay = backoffMs backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS) + retryPending = true + listener?.onRetryScheduled(delay) schedule(delay) { dispatch { if (closed.get() || generation != reconnectGeneration) return@dispatch diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/ChatRequests.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/ChatRequests.kt index 3cbe3a228..6c1bddaa0 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/ChatRequests.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/ChatRequests.kt @@ -79,12 +79,28 @@ object ChatRequests { return cmd } - fun historyArgs(id: String, leafId: String? = null, offset: Int = 0, limit: Int = 100): JSONObject { + fun historyArgs(id: String, leafId: String? = null, offset: Int = 0, limit: Int = 50): JSONObject { + require(offset >= 0) { "History offset must not be negative" } + require(limit in 1..50) { "History pages must contain at most 50 entries" } val args = JSONObject().put("id", id).put("offset", offset).put("limit", limit) if (!leafId.isNullOrBlank()) args.put("leafId", leafId) return args } + fun liveHistoryArgs(id: String, leafId: String?, offset: Int, limit: Int): JSONObject { + require(offset >= 0) { "History offset must not be negative" } + require(limit in 1..50) { "Live history pages must contain at most 50 entries" } + return historyArgs(id, leafId, offset, limit).put("online", true) + } + + fun previousLivePage(offset: Int): IntRange? { + if (offset <= 0) return null + val start = (offset - 50).coerceAtLeast(0) + return start until offset + } + + fun shouldShowLiveHistoryLoader(offset: Int): Boolean = offset > 0 + data class HistoryPage( val messages: JSONArray, val entryIds: JSONArray, @@ -103,12 +119,48 @@ object ChatRequests { todoPhases = data.optJSONArray("todoPhases") ?: JSONArray(), total = data.optInt("total", 0), offset = data.optInt("offset", 0), - limit = data.optInt("limit", 100), + limit = data.optInt("limit", 50), hasMore = data.optBoolean("hasMore", false), leafId = data.optString("leafId").trim().takeIf { it.isNotEmpty() }, ) } + fun liveHistoryMessages(page: HistoryPage): List = buildList { + for (index in 0 until page.messages.length()) { + val message = page.messages.optJSONObject(index) ?: continue + val parsed = parseDisplayMessage(message) ?: continue + val entryId = page.entryIds.optString(index).trim().takeIf { it.isNotEmpty() } + ?: parsed.entryId?.takeIf { it.isNotBlank() } + ?: continue + add(parsed.copy(entryId = entryId)) + } + } + + fun prependEarlierMessages( + current: List, + earlier: List, + ): List { + val seen = current.mapNotNullTo(mutableSetOf()) { it.entryId } + val prepend = earlier.filter { message -> + val entryId = message.entryId ?: return@filter false + seen.add(entryId) + } + return prepend + current + } + + fun isCurrentLiveHistoryRequest( + requestGeneration: Long, + currentGeneration: Long, + requestSessionId: String, + currentSessionId: String?, + requestLeafId: String?, + currentLeafId: String?, + responseLeafId: String?, + ): Boolean = requestGeneration == currentGeneration && + requestSessionId == currentSessionId && + requestLeafId == currentLeafId && + (responseLeafId == null || responseLeafId == requestLeafId) + data class SubagentTranscriptPage( val messages: JSONArray, val fromByte: Long, @@ -178,7 +230,7 @@ object ChatRequests { id: String, leafId: String? = null, offset: Int = 0, - limit: Int = 100, + limit: Int = 50, ): JSONObject = requester.request(DOMAIN_SESSIONS, ACTION_HISTORY, historyArgs(id, leafId, offset, limit)) suspend fun thinking( diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayFrames.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayFrames.kt index 23084eadc..24550ba92 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayFrames.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayFrames.kt @@ -19,12 +19,32 @@ object RelayProtocol { const val CHUNK_DEADLINE_MS = 30_000L const val MAX_PROMPT_CHARS = 3 * 1024 * 1024 const val MAX_LABEL_CHARS = 64 - const val MAX_SNAPSHOT_MESSAGES = 50 + const val MAX_SNAPSHOT_MESSAGES = 100 val DEVICE_ID = Regex("^d_[A-Za-z0-9_-]{16,64}$") val SECRET = Regex("^[A-Za-z0-9_-]{32,128}$") val SESSION_ID = Regex("^[A-Za-z0-9._-]{1,128}$") } +data class TranscriptPageInfo( + val total: Int, + val offset: Int, + val nextOffset: Int, + val hasMore: Boolean, +) + +fun parseTranscriptPageInfo(data: JSONObject, messageCount: Int): TranscriptPageInfo { + val offset = data.optInt("offset", 0).coerceAtLeast(0) + val minimumTotal = offset + messageCount + val total = data.optInt("total", minimumTotal).coerceAtLeast(minimumTotal) + val nextOffset = data.optInt("nextOffset", minimumTotal).coerceIn(offset, total) + return TranscriptPageInfo( + total = total, + offset = offset, + nextOffset = nextOffset, + hasMore = data.optBoolean("hasMore", nextOffset < total), + ) +} + data class DisplayMessage( val role: String, val text: String, @@ -238,7 +258,6 @@ sealed class ClientFrame { ) : ClientFrame() data object Usage : ClientFrame() data object SettingsGet : ClientFrame() - data class SettingsUpdate(val settings: JSONObject) : ClientFrame() data object ProjectsList : ClientFrame() data object SlashList : ClientFrame() data class FilesList(val path: String? = null) : ClientFrame() @@ -307,6 +326,7 @@ sealed class ServerFrame { val cwd: String?, val leafId: String?, val messages: List, + val transcript: TranscriptPageInfo, val agent: AgentState, ) : ServerFrame() data class SessionErr(val id: String?, val code: String, val message: String) : ServerFrame() @@ -521,10 +541,6 @@ fun ClientFrame.encode(): String { is ClientFrame.SessionClose -> json.put("op", "session.close") is ClientFrame.Usage -> json.put("op", "usage") is ClientFrame.SettingsGet -> json.put("op", "settings.get") - is ClientFrame.SettingsUpdate -> { - json.put("op", "settings.update") - json.put("settings", settings) - } is ClientFrame.ProjectsList -> json.put("op", "projects.list") is ClientFrame.SlashList -> json.put("op", "slash.list") is ClientFrame.FilesList -> { @@ -897,12 +913,14 @@ private fun parseLogicalServerFrame(raw: String): ServerFrame? { val id = parsed.optString("id").trim() if (!RelayProtocol.SESSION_ID.matches(id)) return null val agent = parsed.optJSONObject("agent") + val messages = parseMessages(parsed.optJSONArray("messages")) ?: return null ServerFrame.Snapshot( id = id, title = parsed.optString("title").trim().takeIf { it.isNotEmpty() }, cwd = parsed.optString("cwd").trim().takeIf { it.isNotEmpty() }, leafId = parsed.optString("leafId").trim().takeIf { it.isNotEmpty() }, - messages = parseMessages(parsed.optJSONArray("messages")), + messages = messages, + transcript = parseTranscriptPageInfo(parsed, messages.size), agent = AgentState( running = agent?.optBoolean("running", false) == true, ready = agent?.optBoolean("ready", false) == true, @@ -1355,11 +1373,11 @@ fun parseDisplayMessage(item: JSONObject, streaming: Boolean = false): DisplayMe ) } -private fun parseMessages(array: JSONArray?): List { +private fun parseMessages(array: JSONArray?): List? { if (array == null) return emptyList() - val out = ArrayList() - val first = maxOf(0, array.length() - RelayProtocol.MAX_SNAPSHOT_MESSAGES) - for (i in first until array.length()) { + if (array.length() > RelayProtocol.MAX_SNAPSHOT_MESSAGES) return null + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { val item = array.optJSONObject(i) ?: continue parseDisplayMessage(item)?.let(out::add) } diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayUiErrors.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayUiErrors.kt index 6f3a42fe1..ec8c251c0 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayUiErrors.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/relay/RelayUiErrors.kt @@ -43,6 +43,14 @@ object RelayUiErrors { else -> false } + fun shouldSurfaceCommandFailure(command: String): Boolean = + command != "get_state" && command != "get_subagents" + + fun clearKindsOnHealthySnapshot(): Set = setOf( + RelayUiErrorKind.Request, + RelayUiErrorKind.Session, + ) + fun clearKindsOnLeaveChat(): Set = setOf( RelayUiErrorKind.Request, RelayUiErrorKind.Command, diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt index d295dd0f8..a24567dd5 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/store/AppPreferences.kt @@ -16,6 +16,7 @@ import android.content.SharedPreferences * - completion chime (desktop `ompgui-sound-enabled`, default true) * - submit during run: steer | queue (desktop `omp-web:submit-during-run`) * - tool calls collapsed (desktop `ompgui:tool-calls-collapsed`, default true) + * - model thinking visibility for this device (default shown) * * Backed by `ompgui_app_prefs` to preserve the existing SettingsSheet keys * (`language`, `soundChime`, `submissionMode`). @@ -28,6 +29,7 @@ object AppPreferences { const val KEY_SOUND_CHIME = "soundChime" const val KEY_SUBMISSION_MODE = "submissionMode" const val KEY_TOOL_CALLS_COLLAPSED = "toolCallsCollapsed" + const val KEY_SHOW_THINKING = "showThinking" const val THEME_SYSTEM = "system" const val THEME_LIGHT = "light" @@ -104,4 +106,11 @@ object AppPreferences { fun setToolCallsCollapsed(context: Context, collapsed: Boolean) { prefs(context).edit().putBoolean(KEY_TOOL_CALLS_COLLAPSED, collapsed).apply() } + + fun isThinkingShown(context: Context): Boolean = + prefs(context).getBoolean(KEY_SHOW_THINKING, true) + + fun setThinkingShown(context: Context, shown: Boolean) { + prefs(context).edit().putBoolean(KEY_SHOW_THINKING, shown).apply() + } } diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/AdaptiveLayout.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/AdaptiveLayout.kt new file mode 100644 index 000000000..937509dde --- /dev/null +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/AdaptiveLayout.kt @@ -0,0 +1,103 @@ +package com.dbchbin.ompgui.remote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.safeDrawingPadding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.dbchbin.ompgui.remote.R + +/** Material 3 "Expanded" width class lower bound. */ +internal val AdaptiveTwoPaneMinWidth = 840.dp +private val AdaptiveListPaneWidth = 360.dp + +/** + * List-detail layout. Compact widths show one pane ([detailOpen] picks which); expanded widths + * ([twoPane]) show both. The list and detail keep the same composition slot in both modes, so + * folding/unfolding or resizing a window preserves their remembered state (scroll, search, + * selection, composer). In two-pane mode each pane applies its own safe-drawing padding, so the + * inner edges consume the opposite-side insets: the list never pads for a right-hand cutout/nav + * bar and the detail never pads for a left-hand one. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun AdaptiveListDetail( + twoPane: Boolean, + detailOpen: Boolean, + list: @Composable () -> Unit, + detail: @Composable () -> Unit, +) { + // The compact detail view removes the list from composition; keep its saveable state + // (search, filters, pins) so returning or unfolding restores it. + val saveableState = rememberSaveableStateHolder() + Row(Modifier.fillMaxSize().background(OmpColors.Bg)) { + if (twoPane || !detailOpen) { + Box( + if (twoPane) { + Modifier + .width(AdaptiveListPaneWidth) + .fillMaxHeight() + .consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.End)) + } else { + Modifier.weight(1f).fillMaxHeight() + }, + ) { saveableState.SaveableStateProvider("list") { list() } } + } + if (twoPane) VerticalDivider(color = OmpColors.Border) + if (twoPane || detailOpen) { + Box( + if (twoPane) { + Modifier + .weight(1f) + .fillMaxHeight() + .consumeWindowInsets(WindowInsets.safeDrawing.only(WindowInsetsSides.Start)) + } else { + Modifier.weight(1f).fillMaxHeight() + }, + ) { detail() } + } + } +} + +@Composable +internal fun AdaptiveEmptyDetail() { + Column( + Modifier.fillMaxSize().background(OmpColors.Bg).safeDrawingPadding().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource(R.string.adaptive_select_session_title), + style = MaterialTheme.typography.titleMedium, + color = OmpColors.Text, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.adaptive_select_session_hint), + style = MaterialTheme.typography.bodyMedium, + color = OmpColors.TextMuted, + textAlign = TextAlign.Center, + ) + } +} diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt index 9b186db4c..e9e9b519e 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatPanels.kt @@ -178,12 +178,95 @@ fun QueueScreen( /** Reduced-motion aware: panels render statically, no animation. */ private fun panelShape() = RoundedCornerShape(10.dp) +// Small tappable chip used when the IME collapses activity headers into one row. +@Composable +fun CompactPanelChip( + icon: ImageVector, + label: String, + description: String, + onClick: () -> Unit, + highlight: Boolean = false, +) { + val chipShape = RoundedCornerShape(8.dp) + Row( + modifier = Modifier + .heightIn(min = 48.dp) + .clip(chipShape) + // onClickLabel (not contentDescription) so TalkBack still reads the count label. + .clickable(role = Role.Button, onClickLabel = description, onClick = onClick), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .clip(chipShape) + .background(OmpColors.BgPanel) + .border(1.dp, if (highlight) OmpColors.Accent else OmpColors.Border, chipShape) + .padding(horizontal = 8.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Icon(icon, contentDescription = null, modifier = Modifier.size(14.dp), tint = if (highlight) OmpColors.Accent else OmpColors.TextMuted) + Text(label, fontSize = 12.sp, color = OmpColors.Text, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +/** Relay-owned items plus prompts queued natively in OMP (separate lists, desktop ChatInput parity). */ +fun queuedMessageCount(queue: com.dbchbin.ompgui.remote.relay.RelayMessageQueue): Int = + queue.items.size + (queue.nativeQueuedCount ?: 0) + +// --------------------------------------------------------------------------- +// Queue header above composer (same visual pattern as Todo/Subagent headers). +// --------------------------------------------------------------------------- + +@Composable +fun QueuePanel(count: Int, onOpen: () -> Unit, compact: Boolean = false) { + if (count <= 0) return + val label = stringResource(R.string.chat_ux_queue_count, count) + val desc = stringResource(R.string.chat_ux_queue_open) + if (compact) { + CompactPanelChip(icon = Icons.Filled.Queue, label = label, description = desc, onClick = onOpen) + return + } + Column( + modifier = Modifier + .fillMaxWidth() + .clip(panelShape()) + .background(OmpColors.BgPanel) + .border(1.5.dp, OmpColors.Border, panelShape()) + .padding(horizontal = 10.dp, vertical = 0.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(6.dp)) + .clickable(role = Role.Button, onClickLabel = desc, onClick = onOpen) + .heightIn(min = 48.dp) + .padding(horizontal = 2.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Filled.Queue, contentDescription = null, modifier = Modifier.size(16.dp), tint = OmpColors.TextMuted) + Text( + label, + modifier = Modifier.weight(1f), + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + color = OmpColors.Text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Icon(Icons.Filled.ExpandMore, contentDescription = null, modifier = Modifier.size(18.dp), tint = OmpColors.TextDim) + } + } +} + // --------------------------------------------------------------------------- // Todo phases above composer (desktop ComposerPanels/TodoList parity). // --------------------------------------------------------------------------- @Composable -fun TodoPanel(todos: List) { +fun TodoPanel(todos: List, compact: Boolean = false) { if (todos.isEmpty()) return var expanded by remember { mutableStateOf(false) } val focusManager = LocalFocusManager.current @@ -195,7 +278,16 @@ fun TodoPanel(todos: List) { } else { stringResource(R.string.chat_todo_expand) } - Column( + if (compact) CompactPanelChip( + icon = Icons.Filled.Checklist, + label = stringResource(R.string.chat_activity_todos, done, tasks.size), + description = headerDesc, + onClick = { + focusManager.clearFocus() + keyboard?.hide() + expanded = true + }, + ) else Column( modifier = Modifier .fillMaxWidth() .clip(panelShape()) @@ -333,6 +425,7 @@ fun SubagentPanel( requester: RelayRequester, sessionId: String, subagents: List, + compact: Boolean = false, ) { if (subagents.isEmpty()) return var expanded by remember(sessionId) { mutableStateOf(false) } @@ -347,7 +440,17 @@ fun SubagentPanel( } else { stringResource(R.string.chat_subagent_expand) } - Column( + if (compact) CompactPanelChip( + icon = Icons.Filled.Groups, + label = stringResource(R.string.chat_subagent_hub_summary, subagents.size), + description = headerDesc, + highlight = live > 0, + onClick = { + focusManager.clearFocus() + keyboard?.hide() + expanded = true + }, + ) else Column( modifier = Modifier .fillMaxWidth() .clip(panelShape()) @@ -555,7 +658,7 @@ private fun SubagentTranscriptDialog( ) { Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { IconButton(onClick = onDismiss) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.chat_back), Modifier.size(12.dp), tint = OmpColors.TextMuted) + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.chat_back), Modifier.size(18.dp), tint = OmpColors.TextMuted) } Spacer(Modifier.weight(1f)) IconButton(onClick = { refresh++ }, enabled = !loading && sessionId.isNotBlank()) { @@ -1048,7 +1151,7 @@ fun LongMessageText( if (showCopy) IconButton(onClick = { val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", message.text)) - }) { Icon(Icons.Filled.ContentCopy, stringResource(R.string.chat_copy_content), modifier = Modifier.size(12.dp), tint = OmpColors.TextMuted) } + }) { Icon(Icons.Filled.ContentCopy, stringResource(R.string.chat_copy_content), modifier = Modifier.size(18.dp), tint = OmpColors.TextMuted) } if (collapsible && message.text.length > 4_000) RuntimeChip( if (expanded) stringResource(R.string.chat_show_less) else stringResource(R.string.chat_show_full, message.text.length), onClick = { expanded = !expanded }, @@ -1111,17 +1214,18 @@ fun TranscriptContent( } } val content = full?.optJSONArray("content") ?: message.content + val thinkingShown = LocalThinkingShown.current val truncated = message.truncated && full == null var localActivityExpanded by remember(sessionId, leafId, message.entryId) { mutableStateOf(false) } val showActivity = activityExpanded ?: localActivityExpanded val hasActivity = content != null && (0 until content.length()).any { val type = content.optJSONObject(it)?.optString("type") - type == "thinking" || type == "toolCall" + (thinkingShown && type == "thinking") || type == "toolCall" } Column(Modifier.fillMaxWidth()) { Column(contentModifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { if (hasActivity && activityExpanded == null) TranscriptDisclosure( - label = "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)}", + label = if (thinkingShown) "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)}" else stringResource(R.string.new_session_tools), expanded = showActivity, loading = message.streaming, onClick = { localActivityExpanded = !localActivityExpanded }, @@ -1134,7 +1238,7 @@ fun TranscriptContent( "text" -> LongMessageText(requester, sessionId, message.copy(text = block.optString("text")), showCopy = false, collapsible = message.role != "assistant") "toolCall" -> if (showActivity) TranscriptTool(requester, sessionId, leafId, block, results[block.optString("toolCallId")], truncated) "image" -> if (block.optString("data").isNotEmpty()) HistoryImage(block) - "thinking" -> { + "thinking" -> if (thinkingShown) { var thinking by remember(block) { mutableStateOf(null) } var thinkingBusy by remember { mutableStateOf(false) } var thinkingFailure by remember { mutableStateOf(null) } @@ -1222,12 +1326,12 @@ fun TranscriptContent( val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", text)) } - }) { Icon(Icons.Filled.ContentCopy, stringResource(R.string.chat_copy_content), Modifier.size(12.dp), tint = OmpColors.TextMuted) } + }) { Icon(Icons.Filled.ContentCopy, stringResource(R.string.chat_copy_content), Modifier.size(18.dp), tint = OmpColors.TextMuted) } if (message.role == "user" && onEdit != null) IconButton(enabled = actionsEnabled && !busy, modifier = Modifier.size(48.dp), onClick = { perform { onEdit(actionMessage()) } - }) { Icon(Icons.Filled.Edit, stringResource(R.string.chat_edit_input), Modifier.size(12.dp), tint = OmpColors.TextMuted) } + }) { Icon(Icons.Filled.Edit, stringResource(R.string.chat_edit_input), Modifier.size(18.dp), tint = OmpColors.TextMuted) } if (message.role == "user" && onFork != null) IconButton(enabled = actionsEnabled && !busy && !message.entryId.isNullOrBlank(), modifier = Modifier.size(48.dp), onClick = onFork) { - Icon(Icons.Filled.AccountTree, stringResource(R.string.chat_fork_here), Modifier.size(12.dp), tint = OmpColors.TextMuted) + Icon(Icons.Filled.AccountTree, stringResource(R.string.chat_fork_here), Modifier.size(18.dp), tint = OmpColors.TextMuted) } } } @@ -1247,7 +1351,7 @@ private fun TranscriptJson(value: String, initiallyExpanded: Boolean = false) { IconButton(onClick = { val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager clipboard.setPrimaryClip(android.content.ClipData.newPlainText("", value)) - }) { Icon(Icons.Filled.ContentCopy, stringResource(R.string.chat_copy_content), modifier = Modifier.size(12.dp), tint = OmpColors.TextMuted) } + }) { Icon(Icons.Filled.ContentCopy, stringResource(R.string.chat_copy_content), modifier = Modifier.size(18.dp), tint = OmpColors.TextMuted) } } if (expanded) androidx.compose.foundation.text.selection.SelectionContainer { Text(value, Modifier.fillMaxWidth().heightIn(max = 420.dp).verticalScroll(rememberScrollState()), color = OmpColors.Text, fontFamily = FontFamily.Monospace, fontSize = 12.sp) @@ -1308,13 +1412,15 @@ fun ChatExtensionHost( requests: List, notices: List, status: Map, widgets: Map>, onDismissNotice: (String) -> Unit, onDismissRequest: (String) -> Unit, + compact: Boolean = false, ) { val context = LocalContext.current val scope = rememberCoroutineScope() val currentSessionId by androidx.compose.runtime.rememberUpdatedState(sessionId) var error by remember(sessionId, requests.firstOrNull()?.id) { mutableStateOf(null) } var sending by remember(sessionId, requests.firstOrNull()?.id) { mutableStateOf(false) } - Column(Modifier.fillMaxWidth().heightIn(max = 220.dp).verticalScroll(rememberScrollState())) { + // Keyboard open: keep notices/widgets small so the transcript keeps visible space. + Column(Modifier.fillMaxWidth().heightIn(max = if (compact) 96.dp else 220.dp).verticalScroll(rememberScrollState())) { ExtensionNoticeList(notices, onDismissNotice) status.forEach { (key, value) -> Text("$key: $value", color = OmpColors.Text) } widgets.forEach { (key, lines) -> Text("$key\n${lines.joinToString("\n")}", color = OmpColors.Text) } @@ -1405,7 +1511,9 @@ private fun ChatHistoryContent(requester: RelayRequester, sessionId: String, lea @Composable private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: String?, entryId: String, message: JSONObject, onOpenSession: (String) -> Unit, onEditMessage: (String) -> Unit) { val context = LocalContext.current - var details by remember { mutableStateOf(null) } + val thinkingShown = LocalThinkingShown.current + var thinkingDetails by remember { mutableStateOf(null) } + var actionFeedback by remember { mutableStateOf(null) } var media by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } @@ -1445,8 +1553,8 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S } else { MessageText(block.optString("text"), Modifier.fillMaxWidth()) } - "thinking" -> RuntimeChip(stringResource(R.string.chat_load_thinking, index + 1), enabled = !busy && entryId.isNotBlank(), onClick = { - perform { details = ChatRequests.thinking(requester, sessionId, entryId, index).getString("thinking"); thinkingExpanded = true } + "thinking" -> if (thinkingShown) RuntimeChip(stringResource(R.string.chat_load_thinking, index + 1), enabled = !busy && entryId.isNotBlank(), onClick = { + perform { thinkingDetails = ChatRequests.thinking(requester, sessionId, entryId, index).getString("thinking"); thinkingExpanded = true } }) "image" -> HistoryImage(block) else -> { @@ -1465,12 +1573,13 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S } else { MessageText(content?.toString().orEmpty(), plainText = true) } - details?.let { + if (thinkingShown) thinkingDetails?.let { RuntimeChip(if (thinkingExpanded) stringResource(R.string.chat_hide_details) else stringResource(R.string.chat_show_details), onClick = { thinkingExpanded = !thinkingExpanded }) if (thinkingExpanded) androidx.compose.foundation.text.selection.SelectionContainer { Text(it, color = OmpColors.TextMuted, fontSize = 13.sp, lineHeight = 20.sp) } } + actionFeedback?.let { Text(it, color = OmpColors.TextMuted, fontSize = 13.sp, lineHeight = 20.sp) } media?.let { images -> for (i in 0 until images.length()) images.optJSONObject(i)?.let { HistoryImage(it) } } error?.let { Text(it, color = OmpColors.StatusError) } if (message.optString("role") == "user") RuntimeChip(stringResource(R.string.chat_copy_to_composer), onClick = { @@ -1484,7 +1593,7 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S perform { val result = ChatRequests.media(requester, sessionId, entryId) media = result.getJSONArray("images") - details = context.getString(R.string.chat_media_summary, media?.length() ?: 0, result.optInt("missingCount")) + actionFeedback = context.getString(R.string.chat_media_summary, media?.length() ?: 0, result.optInt("missingCount")) } }) if (message.optString("role") == "user") RuntimeChip(stringResource(R.string.chat_fork_here), enabled = !busy, onClick = { @@ -1492,7 +1601,7 @@ private fun HistoryEntry(requester: RelayRequester, sessionId: String, leafId: S val command = JSONObject().put("type", "fork").put("entryId", entryId) if (!leafId.isNullOrBlank()) command.put("leafId", leafId) val result = ChatRequests.command(requester, sessionId, command).getJSONObject("result") - if (result.optBoolean("cancelled")) details = context.getString(R.string.chat_fork_cancelled) + if (result.optBoolean("cancelled")) actionFeedback = context.getString(R.string.chat_fork_cancelled) else onOpenSession(result.getString("newSessionId")) } }) diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt index 2f6d33dda..df39e1307 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ChatScreen.kt @@ -32,6 +32,11 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.material.icons.filled.Queue import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -66,6 +71,7 @@ import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -80,8 +86,11 @@ import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.SolidColor @@ -134,6 +143,32 @@ private const val MAX_IMAGE_BYTES = AttachmentTransfer.MAX_IMAGE_BYTES private const val MAX_TEXT_BYTES = AttachmentTransfer.MAX_TEXT_BYTES private val THINKING_LEVELS = listOf("auto", "minimal", "low", "medium", "high", "xhigh", "max") +internal fun transcriptTurnKey(turn: List): String { + val last = turn.last() + return last.entryId?.takeIf { it.isNotBlank() }?.let { "entry:$it" } + ?: "legacy:${last.role}:${last.toolCallId.orEmpty()}:${last.timestamp ?: ""}:${last.text.hashCode()}" +} + +internal fun groupTranscriptMessages( + messages: List, + callIds: Set, + pageBoundaryEntryIds: Set = emptySet(), +): List> { + val turns = mutableListOf>() + for (message in messages) { + if ((message.role == "toolResult" || message.role == "tool") && message.toolCallId in callIds) continue + val startsLoadedPage = message.entryId?.let(pageBoundaryEntryIds::contains) == true + if (startsLoadedPage || message.role == "user" || turns.lastOrNull()?.firstOrNull()?.role == "user" || turns.isEmpty()) { + turns.add(mutableListOf(message)) + } else { + turns.last().add(message) + } + } + return turns +} + +private data class TranscriptScrollAnchor(val turnKey: String, val scrollOffset: Int) + private fun guessMimeType(name: String): String { return when (name.substringAfterLast('.', "").lowercase(Locale.US)) { "png" -> "image/png" @@ -305,6 +340,10 @@ fun ChatScreen( onDismissChatNotice: (String) -> Unit, title: String, messages: List, + transcriptOffset: Int, + earlierMessagesLoading: Boolean, + earlierMessagesError: String?, + onLoadEarlierMessages: () -> Unit, draft: String, running: Boolean, connection: ConnectionState, @@ -490,12 +529,20 @@ fun ChatScreen( try { val response = com.dbchbin.ompgui.remote.relay.ChatRequests.command(requester, sessionId, command) completed(response.optJSONObject("result") ?: response) - val stateResponse = com.dbchbin.ompgui.remote.relay.ChatRequests.sessionState(requester, sessionId) - runtimeState = stateResponse.optJSONObject("state") ?: stateResponse } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled } catch (failure: Exception) { commandError = failure.message ?: context.getString(R.string.chat_error_command_failed) + return@launch + } + try { + val stateResponse = com.dbchbin.ompgui.remote.relay.ChatRequests.sessionState(requester, sessionId) + runtimeState = stateResponse.optJSONObject("state") ?: stateResponse + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (_: Exception) { + // The command succeeded. Its follow-up state refresh is only + // enrichment and must not turn success into a red error banner. } } } @@ -511,8 +558,10 @@ fun ChatScreen( runtimeState = response.optJSONObject("state") ?: response } catch (cancelled: kotlinx.coroutines.CancellationException) { throw cancelled - } catch (failure: Exception) { - commandError = failure.message ?: context.getString(R.string.chat_error_session_state) + } catch (_: Exception) { + // Best-effort bootstrap enrichment. The snapshot already owns the + // connected chat; command failures initiated by the user still + // surface through runCommand. } } LaunchedEffect(recalledDraft?.id) { @@ -618,21 +667,34 @@ fun ChatScreen( ?.optString("toolCallId")?.takeIf { it.isNotBlank() } } }.toSet() } - val transcriptMessages = remember(messages, callIds) { - val turns = mutableListOf>() - for (message in messages) { - if ((message.role == "toolResult" || message.role == "tool") && message.toolCallId in callIds) continue - if (message.role == "user" || turns.lastOrNull()?.firstOrNull()?.role == "user" || turns.isEmpty()) { - turns.add(mutableListOf(message)) - } else { - turns.last().add(message) - } + var pageBoundaryEntryIds by remember(sessionId, branchLeafId) { mutableStateOf(emptySet()) } + val transcriptMessages = remember(messages, callIds, pageBoundaryEntryIds) { + groupTranscriptMessages(messages, callIds, pageBoundaryEntryIds) + } + val turnIndices = remember(transcriptMessages) { + transcriptMessages.mapIndexed { index, turn -> transcriptTurnKey(turn) to index }.toMap() + } + var earlierScrollAnchor by remember(sessionId) { mutableStateOf(null) } + var observedEarlierLoading by remember(sessionId) { mutableStateOf(false) } + fun captureEarlierScrollAnchor() { + followLocked = false + transcriptMessages.firstOrNull()?.firstOrNull()?.entryId?.let { boundary -> + pageBoundaryEntryIds = pageBoundaryEntryIds + boundary } - turns + val visibleTurn = listState.layoutInfo.visibleItemsInfo.firstOrNull { it.key != "live-history-loader" } ?: return + val key = visibleTurn.key as? String ?: return + if (key !in turnIndices) return + earlierScrollAnchor = TranscriptScrollAnchor( + turnKey = key, + // scrollToItem offsets are relative to the padded content origin; + // viewportStartOffset includes the negative top content padding. + scrollOffset = -visibleTurn.offset, + ) } suspend fun scrollToLatest() { if (transcriptMessages.isEmpty()) return - listState.scrollToItem(transcriptMessages.lastIndex) + val loaderCount = if (com.dbchbin.ompgui.remote.relay.ChatRequests.shouldShowLiveHistoryLoader(transcriptOffset)) 1 else 0 + listState.scrollToItem(transcriptMessages.lastIndex + loaderCount) // A final message can be taller than the viewport: its top is not the end. val layout = listState.layoutInfo val lastItem = layout.visibleItemsInfo.lastOrNull() ?: return @@ -652,6 +714,22 @@ fun ChatScreen( } } } + LaunchedEffect(earlierMessagesLoading) { + if (earlierMessagesLoading) { + followLocked = false + if (earlierScrollAnchor == null) captureEarlierScrollAnchor() + observedEarlierLoading = true + } else if (observedEarlierLoading) { + val anchor = earlierScrollAnchor + val index = anchor?.let { turnIndices[it.turnKey] } + if (anchor != null && index != null) { + val loaderCount = if (com.dbchbin.ompgui.remote.relay.ChatRequests.shouldShowLiveHistoryLoader(transcriptOffset)) 1 else 0 + listState.scrollToItem(index + loaderCount, anchor.scrollOffset) + } + earlierScrollAnchor = null + observedEarlierLoading = false + } + } LaunchedEffect(messages.size, messages.lastOrNull(), followLocked, listState.layoutInfo.viewportSize.height) { if (followLocked) scrollToLatest() } @@ -726,9 +804,49 @@ fun ChatScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (com.dbchbin.ompgui.remote.relay.ChatRequests.shouldShowLiveHistoryLoader(transcriptOffset)) { + item(key = "live-history-loader") { + val loadingLabel = stringResource(R.string.chat_loading_earlier) + Column( + modifier = Modifier + .fillMaxWidth() + .semantics { + liveRegion = LiveRegionMode.Polite + if (earlierMessagesLoading) stateDescription = loadingLabel + }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + OutlinedButton( + enabled = !earlierMessagesLoading, + onClick = { + captureEarlierScrollAnchor() + onLoadEarlierMessages() + }, + modifier = Modifier.heightIn(min = 48.dp), + ) { + Text( + text = if (earlierMessagesLoading) loadingLabel + else stringResource(R.string.chat_load_earlier), + color = if (earlierMessagesLoading) OmpColors.TextMuted else OmpColors.Accent, + fontSize = 13.sp, + ) + } + if (earlierMessagesError != null) { + Text( + stringResource(R.string.chat_earlier_failed), + color = OmpColors.StatusError, + fontSize = 13.sp, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + } + } + } itemsIndexed( items = transcriptMessages, - key = { index, turn -> turn.first().let { message -> message.entryId ?: "${message.role}_${message.toolCallId ?: message.timestamp ?: index}_$index" } }, + // Loaded page boundaries keep the previously visible turn intact. + key = { _, turn -> transcriptTurnKey(turn) }, ) { _, turn -> val message = turn.first() if (message.role == "user") { @@ -772,20 +890,35 @@ fun ChatScreen( .padding(bottom = 12.dp), verticalArrangement = Arrangement.spacedBy(4.dp), ) { + val imeVisible = WindowInsets.isImeVisible ChatExtensionHost( requester = requester, sessionId = sessionId, requests = extensionDialogs, notices = chatNotices, status = extensionStatus, widgets = extensionWidgets, onDismissNotice = onDismissChatNotice, onDismissRequest = onDismissExtensionDialog, + compact = imeVisible, ) - // Todo and subagent details open in modal workspaces. - TodoPanel(todos = todos) - SubagentPanel( - requester = requester, - sessionId = sessionId, - subagents = subagents, - ) + val queuedCount = if (historicalView) 0 else queuedMessageCount(messageQueue) + // Todo, subagent and queue details open in modal workspaces. With the + // keyboard up the headers collapse into one row of chips. One FlowRow keeps + // call sites stable so each panel's sheet state survives the IME toggling + // (tapping a header hides the keyboard). + if (todos.isNotEmpty() || subagents.isNotEmpty() || queuedCount > 0) FlowRow( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + maxItemsInEachRow = if (imeVisible) Int.MAX_VALUE else 1, + ) { + TodoPanel(todos = todos, compact = imeVisible) + SubagentPanel( + requester = requester, + sessionId = sessionId, + subagents = subagents, + compact = imeVisible, + ) + QueuePanel(count = queuedCount, onOpen = { openQueue() }, compact = imeVisible) + } RuntimePanel( expanded = runtimeExpanded && !historicalView, onExpandedChange = { runtimeExpanded = it }, @@ -998,7 +1131,7 @@ private fun ChatTopBar( DropdownMenuItem(text = { Text(stringResource(R.string.chat_menu_session_info)) }, leadingIcon = { Icon(Icons.Filled.Info, null, Modifier.size(20.dp)) }, onClick = { menuOpen = false; onOpenStats() }) DropdownMenuItem(text = { Text(stringResource(R.string.chat_menu_commands)) }, leadingIcon = { Icon(Icons.Filled.Terminal, null, Modifier.size(20.dp)) }, onClick = { menuOpen = false; onOpenCommands() }) DropdownMenuItem(text = { Text(stringResource(R.string.chat_menu_session_controls)) }, leadingIcon = { Icon(Icons.Filled.Tune, null, Modifier.size(20.dp)) }, enabled = runtimeEnabled, onClick = { menuOpen = false; onOpenRuntime() }) - DropdownMenuItem(text = { Text(stringResource(R.string.chat_queue_title)) }, enabled = runtimeEnabled, onClick = { menuOpen = false; onOpenQueue() }) + DropdownMenuItem(text = { Text(stringResource(R.string.chat_queue_title)) }, leadingIcon = { Icon(Icons.Filled.Queue, null, Modifier.size(20.dp)) }, enabled = runtimeEnabled, onClick = { menuOpen = false; onOpenQueue() }) HorizontalDivider(color = OmpColors.Border) DropdownMenuItem(text = { Text(stringResource(R.string.chat_menu_settings)) }, leadingIcon = { Icon(Icons.Filled.Settings, null, Modifier.size(20.dp)) }, onClick = { menuOpen = false; onOpenSettings() }) } @@ -1035,6 +1168,7 @@ private fun AssistantTurn( results: Map, ) { var expanded by remember(sessionId, leafId, messages.first().entryId) { mutableStateOf(false) } + val thinkingShown = LocalThinkingShown.current var hasActivity = false var pending = false var failed = false @@ -1050,7 +1184,7 @@ private fun AssistantTurn( for (index in 0 until content.length()) { val block = content.optJSONObject(index) ?: continue when (block.optString("type")) { - "thinking" -> hasActivity = true + "thinking" -> if (thinkingShown) hasActivity = true "toolCall" -> { hasActivity = true val result = results[block.optString("toolCallId")] @@ -1068,7 +1202,7 @@ private fun AssistantTurn( else -> stringResource(R.string.chat_tool_complete) } TranscriptDisclosure( - label = "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)} · $status", + label = if (thinkingShown) "${stringResource(R.string.thinking_title)} · ${stringResource(R.string.new_session_tools)} · $status" else "${stringResource(R.string.new_session_tools)} · $status", expanded = expanded, loading = pending, failed = failed, @@ -1116,7 +1250,8 @@ private fun AssistantMessage( private fun UserMessage(message: DisplayMessage, requester: RelayRequester, sessionId: String, leafId: String?, actionsEnabled: Boolean, onEdit: (JSONObject) -> Unit, onFork: () -> Unit) { val stamp = remember(message.timestamp) { formatTimestamp(message.timestamp) } BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { - val bubbleMax = maxWidth * 0.85f + // Phones: 85% of the row; tablets: cap for readable line length. + val bubbleMax = minOf(maxWidth * 0.85f, 720.dp) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.End, @@ -1175,6 +1310,8 @@ private fun ComposerCard( var utilitiesOpen by remember { mutableStateOf(false) } val utilitiesDesc = stringResource(R.string.chat_composer_utilities) val usageLabel = usageFraction?.let { "${(it.coerceIn(0.0, 1.0) * 100).toInt()}%" } ?: "—" + val usageState = stringResource(R.string.chat_composer_context_usage, usageLabel) + val haptics = LocalHapticFeedback.current val shape = androidx.compose.material3.MaterialTheme.shapes.medium Column( modifier = Modifier @@ -1242,7 +1379,12 @@ private fun ComposerCard( } Box { IconButton(onClick = { utilitiesOpen = true }, modifier = Modifier.size(48.dp)) { - Box(Modifier.size(28.dp).semantics { contentDescription = utilitiesDesc }, contentAlignment = Alignment.Center) { + // Percent lives in semantics and the utilities menu; text inside a + // 28dp ring overflows at large font scales. + Box(Modifier.size(28.dp).semantics { + contentDescription = utilitiesDesc + if (usageFraction != null) stateDescription = usageState + }, contentAlignment = Alignment.Center) { androidx.compose.foundation.Canvas(Modifier.fillMaxSize().padding(1.dp)) { val stroke = androidx.compose.ui.graphics.drawscope.Stroke(width = 2.dp.toPx()) drawCircle(color = OmpColors.Border, style = stroke) @@ -1252,7 +1394,6 @@ private fun ComposerCard( useCenter = false, style = stroke) } } - Text(usageLabel, fontSize = 9.sp, color = OmpColors.TextMuted, maxLines = 1) } } DropdownMenu(expanded = utilitiesOpen, onDismissRequest = { utilitiesOpen = false }) { @@ -1271,7 +1412,15 @@ private fun ComposerCard( else -> R.string.chat_submit_steer }) androidx.compose.material3.TextButton( - onClick = { if (stop) onAbort() else onSend() }, + onClick = { + if (stop) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + onAbort() + } else { + haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSend() + } + }, enabled = active, modifier = Modifier.sizeIn(minWidth = 48.dp, minHeight = 48.dp), contentPadding = PaddingValues(horizontal = 8.dp), diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ConnectionBanner.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ConnectionBanner.kt new file mode 100644 index 000000000..553fd8e62 --- /dev/null +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ConnectionBanner.kt @@ -0,0 +1,97 @@ +package com.dbchbin.ompgui.remote.ui + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.dbchbin.ompgui.remote.R +import com.dbchbin.ompgui.remote.net.ConnectionState +import kotlinx.coroutines.delay + +/** Localized relay connection status, shared by the session list banner and settings. */ +@Composable +fun connectionStatusText(connection: ConnectionState): String = when (connection) { + ConnectionState.Connected -> stringResource(R.string.chat_status_connected) + ConnectionState.Connecting -> stringResource(R.string.status_connecting) + ConnectionState.Failed -> stringResource(R.string.status_failed) + ConnectionState.Idle -> stringResource(R.string.status_idle) +} + +/** + * Offline banner: status, a live "retry in Ns" countdown while a backoff is scheduled, + * and a Reconnect action. Renders nothing while connected. + */ +@Composable +fun ConnectionBanner( + connection: ConnectionState, + nextRetryAtMillis: Long?, + onReconnect: () -> Unit, + modifier: Modifier = Modifier, +) { + if (connection == ConnectionState.Connected) return + val tint = if (connection == ConnectionState.Failed) OmpColors.StatusError else OmpColors.StatusWarning + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + color = tint.copy(alpha = 0.12f), + border = BorderStroke(1.dp, tint.copy(alpha = 0.4f)), + ) { + Row( + modifier = Modifier.padding(start = 12.dp, end = 4.dp).heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f).padding(vertical = 6.dp)) { + Text( + connectionStatusText(connection), + color = tint, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + if (nextRetryAtMillis != null) RetryCountdown(nextRetryAtMillis) + } + TextButton(onClick = onReconnect, modifier = Modifier.heightIn(min = 48.dp)) { + Text(stringResource(R.string.conn_ux_reconnect), color = tint) + } + } + } +} + +/** Own restart scope: the 1s clock recomposes only this line, not the whole banner. */ +@Composable +private fun RetryCountdown(nextRetryAtMillis: Long) { + // Keyed so a newly scheduled retry never renders with the previous attempt's clock. + var now by remember(nextRetryAtMillis) { mutableLongStateOf(System.currentTimeMillis()) } + LaunchedEffect(nextRetryAtMillis) { + while (true) { + now = System.currentTimeMillis() + if (now >= nextRetryAtMillis) break + delay(1_000L - (nextRetryAtMillis - now) % 1_000L) + } + } + val retrySeconds = ((nextRetryAtMillis - now + 999) / 1_000).coerceAtLeast(0) + if (retrySeconds > 0) { + Text( + stringResource(R.string.conn_ux_retry_in, retrySeconds.toInt()), + color = OmpColors.TextMuted, + fontSize = 12.sp, + ) + } +} diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionMcpSection.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionMcpSection.kt index f765f50a7..a090df295 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionMcpSection.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionMcpSection.kt @@ -45,6 +45,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import android.content.res.Resources import com.dbchbin.ompgui.remote.R +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp @@ -350,7 +351,7 @@ private fun McpEditor(requester: RelayRequester, cwd: String, initial: McpDraft, } TextButton(enabled = !busy, onClick = { change(draft.copy(args = draft.args + "")) }) { Text(stringResource(R.string.extension_mcp_add_argument)) } } else { - McpField(draft.url, { change(draft.copy(url = it)) }, stringResource(R.string.extension_mcp_server_url), !busy) + McpField(draft.url, { change(draft.copy(url = it)) }, stringResource(R.string.extension_mcp_server_url), !busy, keyboardType = KeyboardType.Uri) } HorizontalDivider(color = OmpColors.Border) Text(stringResource(R.string.extension_mcp_connection_options), style = MaterialTheme.typography.titleSmall, color = OmpColors.Text) @@ -391,8 +392,8 @@ private fun McpEditor(requester: RelayRequester, cwd: String, initial: McpDraft, } @Composable -private fun McpField(value: String, onChange: (String) -> Unit, label: String, enabled: Boolean) { - OutlinedTextField(value = value, onValueChange = onChange, label = { Text(label) }, enabled = enabled, singleLine = true, textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), modifier = Modifier.fillMaxWidth()) +private fun McpField(value: String, onChange: (String) -> Unit, label: String, enabled: Boolean, keyboardType: KeyboardType = KeyboardType.Text) { + OutlinedTextField(value = value, onValueChange = onChange, label = { Text(label) }, enabled = enabled, singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, keyboardType = keyboardType), textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), modifier = Modifier.fillMaxWidth()) } @Composable diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionSettingsPanel.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionSettingsPanel.kt index 85d59d3d6..a30cf62d3 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionSettingsPanel.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ExtensionSettingsPanel.kt @@ -17,7 +17,10 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.toggleable import androidx.compose.ui.semantics.Role +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -290,7 +293,7 @@ private fun ExtensionSkillsSection(requester: RelayRequester, cwd: String) { if (result.has("installs")) Text(result.getString("installs"), color = OmpColors.TextMuted) TextButton(onClick = { packageInput = result.getString("package") }, enabled = !operation.pending) { Text(stringResource(R.string.extension_skills_use_package)) } } - OutlinedTextField(packageInput, { packageInput = it }, label = { Text(stringResource(R.string.extension_skills_package_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(packageInput, { packageInput = it }, label = { Text(stringResource(R.string.extension_skills_package_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, keyboardType = KeyboardType.Ascii)) ExtensionScope(installScope, !operation.pending) { installScope = it } Button(shape = MaterialTheme.shapes.small, enabled = !operation.pending && packageInput.isNotBlank(), onClick = { confirmation = context.getString(R.string.extension_skills_confirm_install, packageInput.trim(), installScope) to JSONObject().put("package", packageInput.trim()).put("scope", installScope).put("action", "skills.install") @@ -383,7 +386,7 @@ private fun ExtensionPluginsSection(requester: RelayRequester, cwd: String) { TextButton(enabled = !operation.pending, onClick = { change("update", null, installScope) }) { Text(stringResource(R.string.extension_plugins_update_all, installScope)) } } else { Text(stringResource(R.string.extension_plugins_trust_hint), color = OmpColors.TextMuted) - OutlinedTextField(source, { source = it }, label = { Text(stringResource(R.string.extension_plugins_source_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true) + OutlinedTextField(source, { source = it }, label = { Text(stringResource(R.string.extension_plugins_source_label)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, keyboardType = KeyboardType.Text)) ExtensionScope(installScope, !operation.pending) { installScope = it } Button(shape = MaterialTheme.shapes.small, enabled = !operation.pending && source.isNotBlank(), onClick = { change("install", source.trim(), installScope) }) { Text(stringResource(R.string.extension_plugins_install)) } } diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileBrowserSheet.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileBrowserSheet.kt index 162296e20..c99396a87 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileBrowserSheet.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileBrowserSheet.kt @@ -301,9 +301,13 @@ fun FileBrowserSheet( Column(Modifier.weight(1f)) { Text(selected?.optString("name") ?: stringResource(R.string.file_browser_title), style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + val pathScroll = rememberScrollState() + // Keep the deepest segment visible: the tail is what identifies the location. + // maxValue is read in a snapshotFlow so layout changes don't recompose the sheet. + LaunchedEffect(directory) { snapshotFlow { pathScroll.maxValue }.collect { pathScroll.scrollTo(it) } } Text(directory.ifBlank { stringResource(R.string.file_browser_workspace) }, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = OmpColors.TextMuted, - modifier = Modifier.horizontalScroll(rememberScrollState()), maxLines = 1) + modifier = Modifier.horizontalScroll(pathScroll), maxLines = 1) } IconButton(modifier = Modifier.size(48.dp), onClick = { if (dirty || busy) { dismissAfterDiscard = true; discard = true } else onDismiss() @@ -378,8 +382,22 @@ fun FileBrowserSheet( Icon(if (row.optBoolean("dir")) Icons.Default.Folder else Icons.Default.Description, if (row.optBoolean("dir")) stringResource(R.string.file_browser_folder) else null, Modifier.size(18.dp), tint = OmpColors.TextMuted) Spacer(Modifier.width(10.dp)) - Text(row.optString("name", filePath), Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Start) + if (mode == "list") { + Text(row.optString("name", filePath), Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Start) + } else { + // Search/git rows can come from any depth: show the file name first and + // its parent (relative to the browsed directory) as muted context. + val root = directory.trimEnd('/') + "/" + val relative = if (filePath.startsWith(root)) filePath.removePrefix(root) else filePath + val parent = relative.trimEnd('/').substringBeforeLast('/', "") + Column(Modifier.weight(1f)) { + Text(relative.trimEnd('/').substringAfterLast('/'), maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Start) + if (parent.isNotEmpty()) Text(parent, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, color = OmpColors.TextMuted) + } + } if (mode == "git") Text(row.optString("status"), color = OmpColors.StatusWarning, style = MaterialTheme.typography.labelMedium) } } @@ -436,6 +454,19 @@ fun FileBrowserSheet( DropdownMenu(expanded = actionsExpanded, onDismissRequest = { actionsExpanded = false }) { DropdownMenuItem(text = { Text(stringResource(R.string.file_browser_open_external)) }, onClick = { actionsExpanded = false; runOperation { val uri = cached ?: download(); openFilePreview(context, uri, file.optString("mime", "application/octet-stream")) } }) DropdownMenuItem(text = { Text(stringResource(R.string.file_browser_share)) }, onClick = { actionsExpanded = false; runOperation { val uri = cached ?: download(); shareFile(context, uri, file.optString("mime", "application/octet-stream")) } }) + DropdownMenuItem(text = { Text(stringResource(R.string.file_ux_copy_path)) }, onClick = { + actionsExpanded = false + val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + clipboard.setPrimaryClip(android.content.ClipData.newPlainText(file.optString("name"), file.getString("path"))) + notice = context.getString(R.string.file_ux_path_copied) + }) + if (file.optString("previewKind") == "text") DropdownMenuItem(text = { Text(stringResource(R.string.file_ux_copy_content)) }, onClick = { + actionsExpanded = false + val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + // Server content; unsaved edits already have "Copy draft". + clipboard.setPrimaryClip(android.content.ClipData.newPlainText(file.optString("name"), base)) + notice = context.getString(if (complete) R.string.file_ux_content_copied else R.string.file_ux_partial_content_copied) + }) } } } diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileContentPreview.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileContentPreview.kt index 086469605..3c4bafa47 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileContentPreview.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/FileContentPreview.kt @@ -7,18 +7,37 @@ import android.media.MediaPlayer import android.net.Uri import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ChevronLeft import androidx.compose.material.icons.filled.ChevronRight +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.ui.Alignment import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlin.math.abs import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner @@ -107,12 +126,12 @@ fun FileContentPreview( kind == "text" && extension in setOf("md", "markdown", "mdown") -> { MarkdownText(text, Modifier.fillMaxWidth().padding(12.dp)) } - kind == "text" -> RichPreview(text, if (extension in setOf("html", "htm")) RichPreviewKind.Html else RichPreviewKind.Code, - Modifier.fillMaxWidth().height(420.dp), language = when (extension) { + kind == "text" -> RichPreviewViewer(text, if (extension in setOf("html", "htm")) RichPreviewKind.Html else RichPreviewKind.Code, + file.optString("name"), language = when (extension) { "kt", "kts" -> "kotlin"; "js", "mjs", "cjs" -> "javascript"; "ts" -> "typescript"; "py" -> "python" "rs" -> "rust"; "sh" -> "bash"; "yml" -> "yaml"; else -> extension }) - html != null -> RichPreview(checkNotNull(html), RichPreviewKind.Html, Modifier.fillMaxWidth().height(420.dp)) + html != null -> RichPreviewViewer(checkNotNull(html), RichPreviewKind.Html, file.optString("name")) uri != null && kind == "image" && mime != "image/svg+xml" -> RasterPreview(checkNotNull(uri), file.optString("name")) uri != null && kind == "pdf" -> PdfPreview(checkNotNull(uri)) uri != null && kind == "audio" -> AudioPreview(checkNotNull(uri)) @@ -123,6 +142,108 @@ fun FileContentPreview( } } +/** Sheet-sized rich viewer with a wrap toggle (code) and a fullscreen dialog sharing the same state. */ +@Composable +private fun RichPreviewViewer(content: String, kind: RichPreviewKind, name: String, language: String = "") { + var wrapLines by rememberSaveable { mutableStateOf(false) } + var fullscreen by rememberSaveable { mutableStateOf(false) } + val wrapToggle: @Composable () -> Unit = { + if (kind == RichPreviewKind.Code) FilterChip(selected = wrapLines, onClick = { wrapLines = !wrapLines }, + label = { Text(stringResource(R.string.file_ux_wrap_lines)) }, modifier = Modifier.heightIn(min = 48.dp)) + } + Row(Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End) { + wrapToggle() + IconButton(onClick = { fullscreen = true }, modifier = Modifier.size(48.dp)) { + Icon(Icons.Default.Fullscreen, stringResource(R.string.file_ux_fullscreen), tint = OmpColors.TextMuted) + } + } + // The inline WebView stays composed underneath so returning keeps its scroll position. + RichPreview(content, kind, Modifier.fillMaxWidth().height(420.dp), language = language, wrapLines = wrapLines) + if (fullscreen) Dialog(onDismissRequest = { fullscreen = false }, + properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false)) { + OmpDialogSystemBars(edgeToEdgeSheet = true) + Column(Modifier.fillMaxSize().background(OmpColors.Bg).windowInsetsPadding(WindowInsets.safeDrawing)) { + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(name, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f).padding(horizontal = 8.dp)) + wrapToggle() + IconButton(onClick = { fullscreen = false }, modifier = Modifier.size(48.dp)) { + Icon(Icons.Default.Close, stringResource(R.string.file_ux_close_fullscreen), tint = OmpColors.TextMuted) + } + } + HorizontalDivider(color = OmpColors.Border) + RichPreview(content, kind, Modifier.fillMaxWidth().weight(1f), language = language, wrapLines = wrapLines) + } + } +} + +private const val MaxZoom = 5f +private const val DoubleTapZoom = 2.5f + +/** + * Pinch-zoom (1x..5x), clamped pan and double-tap 1x/2.5x for a bitmap. State is keyed by the + * bitmap, so a new page/image always starts unzoomed. At 1x single-finger drags are left to the + * parent list; [onSwipe] (-1 previous, +1 next) fires for a clear one-finger horizontal fling. + */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +private fun ZoomableBitmap(bitmap: Bitmap, description: String, modifier: Modifier, onSwipe: ((Int) -> Unit)? = null) { + var scale by remember(bitmap) { mutableFloatStateOf(1f) } + var offset by remember(bitmap) { mutableStateOf(Offset.Zero) } + var viewport by remember { mutableStateOf(IntSize.Zero) } + val image = remember(bitmap) { bitmap.asImageBitmap() } + val swipeThreshold = with(LocalDensity.current) { 64.dp.toPx() } + val latestSwipe by rememberUpdatedState(onSwipe) + fun clamp(value: Offset, zoom: Float): Offset { + val maxX = viewport.width * (zoom - 1f) / 2f + val maxY = viewport.height * (zoom - 1f) / 2f + return Offset(value.x.coerceIn(-maxX, maxX), value.y.coerceIn(-maxY, maxY)) + } + val transform = rememberTransformableState { zoomChange, panChange, _ -> + val next = (scale * zoomChange).coerceIn(1f, MaxZoom) + offset = clamp(offset * (next / scale) + panChange, next) + scale = next + } + // Installed once per bitmap; zoom is checked when the gesture ends so pinch frames never + // recompose this function (scale is read only in layer/gesture lambdas). + val swipe = if (onSwipe == null) Modifier else Modifier.pointerInput(bitmap) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var total = Offset.Zero + var multiTouch = false + while (true) { + val event = awaitPointerEvent() + if (event.changes.count { it.pressed } > 1) multiTouch = true + val change = event.changes.firstOrNull { it.id == down.id } ?: break + total = change.position - down.position + if (!change.pressed) break + } + // Observe only: vertical list scrolling and pinch keep their own consumers. + if (scale == 1f && !multiTouch && abs(total.x) > swipeThreshold && abs(total.x) > 2 * abs(total.y)) { + latestSwipe?.invoke(if (total.x < 0) 1 else -1) + } + } + } + Image(image, description, modifier + .clipToBounds() + .onSizeChanged { viewport = it } + .pointerInput(bitmap) { + detectTapGestures(onDoubleTap = { tap -> + if (scale > 1f) { scale = 1f; offset = Offset.Zero } + else { + // Keep the tapped point under the finger while zooming in. + val center = Offset(viewport.width / 2f, viewport.height / 2f) + offset = clamp((center - tap) * (DoubleTapZoom - 1f), DoubleTapZoom) + scale = DoubleTapZoom + } + }) + } + .then(swipe) + .transformable(transform, canPan = { scale > 1f }) + .graphicsLayer { scaleX = scale; scaleY = scale; translationX = offset.x; translationY = offset.y }) +} + @Composable private fun RasterPreview(uri: Uri, name: String) { val context = LocalContext.current @@ -144,7 +265,7 @@ private fun RasterPreview(uri: Uri, name: String) { catch (error: Exception) { failure = error.message } } if (bitmap == null && failure == null) LinearProgressIndicator(Modifier.fillMaxWidth()) - bitmap?.let { Image(it.asImageBitmap(), name, Modifier.fillMaxWidth().heightIn(max = 420.dp).padding(12.dp)) } + bitmap?.let { ZoomableBitmap(it, name, Modifier.fillMaxWidth().heightIn(max = 420.dp).padding(12.dp)) } failure?.let { Text(it, color = MaterialTheme.colorScheme.error) } } @@ -186,7 +307,8 @@ private fun PdfPreview(uri: Uri) { } } if (bitmap == null && failure == null) LinearProgressIndicator(Modifier.fillMaxWidth()) - bitmap?.let { Image(it.asImageBitmap(), stringResource(R.string.file_preview_pdf_page, page + 1), Modifier.fillMaxWidth().height(420.dp).padding(4.dp)) } + bitmap?.let { ZoomableBitmap(it, stringResource(R.string.file_preview_pdf_page, page + 1), Modifier.fillMaxWidth().height(420.dp).padding(4.dp), + onSwipe = { delta -> if (page + delta in 0 until count) page += delta }) } failure?.let { Text(it, color = MaterialTheme.colorScheme.error) } } diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt index ca9dcf967..c41b1893a 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/MessageText.kt @@ -48,6 +48,8 @@ import androidx.compose.ui.unit.sp private val ircSenderPattern = Regex("agent `([^`\\r\\n<>]+)`") +val LocalThinkingShown = androidx.compose.runtime.staticCompositionLocalOf { true } + /** Message-only rendering; file previews and generic Markdown retain their original behavior. */ @Composable fun MessageText(text: String, modifier: Modifier = Modifier, plainText: Boolean = false) { diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt index 4eb24b685..3952dfe38 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanel.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.TextButton import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import com.dbchbin.ompgui.remote.R import kotlinx.coroutines.CancellationException import androidx.compose.foundation.layout.FlowRow @@ -87,7 +88,7 @@ import org.json.JSONObject * Server-disabled operations (API-key store/remove, logout) surface the exact * 501 capability codes plus terminal guidance instead of fake success. */ -enum class ModelSettingsSection { All, Defaults, Roles, Providers } +enum class ModelSettingsSection { All, Defaults, Roles, Providers, Hidden } @Composable fun ModelSettingsPanel( @@ -169,6 +170,10 @@ data class CatalogModel( val thinkingLevels: List = emptyList(), val supportsFastMode: Boolean = false, val contextWindow: Int? = null, + /** omp model kind; absent means chat. */ + val kind: String = "chat", + /** Native web-search backend of a chat model, if any. */ + val webSearch: String? = null, ) { val selector: String get() = "$provider/$id" } @@ -177,6 +182,8 @@ data class ConnectedProvider(val id: String, val name: String, val disabled: Boo data class ModelCatalogState( val models: List = emptyList(), + /** Non-chat models (image/search/tts/stt/judge/tiny) offered only to roles. */ + val kindModels: List = emptyList(), val defaultModel: String? = null, val connectedProviders: List = emptyList(), val unavailable: Boolean = false, @@ -185,9 +192,43 @@ data class ModelCatalogState( /** Native OMP role selectors, mirroring the desktop ModelRolesDetail. */ val nativeModelRoles: List = listOf( "default", "smol", "slow", "vision", "plan", - "designer", "commit", "tiny", "task", "advisor", + "commit", "tiny", "memory", "task", "advisor", ) +/** Native OMP kind roles (omp KIND_ROLE_IDS), shown below the chat roles. */ +val kindModelRoles: List = listOf("image", "web", "speech", "dictation", "judge") + +/** Mirrors omp's per-role `accepts` model filter (model-roles registry). */ +fun roleAcceptsModel(role: String, model: CatalogModel): Boolean = when (role) { + "tiny", "memory" -> model.kind == "tiny" || model.kind == "chat" + "image" -> model.kind == "image" + "web" -> model.kind == "search" || (model.kind == "chat" && model.webSearch != null) + "speech" -> model.kind == "tts" + "dictation" -> model.kind == "stt" + "judge" -> model.kind == "judge" || model.kind == "tiny" || model.kind == "chat" + else -> model.kind == "chat" +} + +/** omp display name for a built-in role; unknown (custom) roles have none. */ +fun modelRoleLabelRes(role: String): Int? = when (role) { + "default" -> R.string.role_default + "smol" -> R.string.role_smol + "slow" -> R.string.role_slow + "vision" -> R.string.role_vision + "plan" -> R.string.role_plan + "commit" -> R.string.role_commit + "tiny" -> R.string.role_tiny + "memory" -> R.string.role_memory + "task" -> R.string.role_task + "advisor" -> R.string.role_advisor + "image" -> R.string.role_image + "web" -> R.string.role_web + "speech" -> R.string.role_speech + "dictation" -> R.string.role_dictation + "judge" -> R.string.role_judge + else -> null +} + val modelApiOptions: List = listOf( "openai-completions", "openai-responses", @@ -221,36 +262,42 @@ fun sanitizeRolesForSave(roles: Map): Map { return out } -fun parseModelCatalog(data: JSONObject): ModelCatalogState { +private fun parseCatalogModels(array: JSONArray?): List { val models = mutableListOf() - val array = data.optJSONArray("models") - if (array != null) { - for (i in 0 until array.length()) { - val entry = array.optJSONObject(i) ?: continue - val provider = entry.optString("provider").trim() - val id = entry.optString("id").trim() - if (provider.isEmpty() || id.isEmpty()) continue - val name = entry.optString("name").ifBlank { id } - val levels = mutableListOf() - val rawLevels = entry.optJSONArray("thinkingLevels") - if (rawLevels != null) { - for (j in 0 until rawLevels.length()) { - val level = rawLevels.optString(j) - if (level.isNotBlank()) levels.add(level) - } + if (array == null) return models + for (i in 0 until array.length()) { + val entry = array.optJSONObject(i) ?: continue + val provider = entry.optString("provider").trim() + val id = entry.optString("id").trim() + if (provider.isEmpty() || id.isEmpty()) continue + val name = entry.optString("name").ifBlank { id } + val levels = mutableListOf() + val rawLevels = entry.optJSONArray("thinkingLevels") + if (rawLevels != null) { + for (j in 0 until rawLevels.length()) { + val level = rawLevels.optString(j) + if (level.isNotBlank()) levels.add(level) } - models.add( - CatalogModel( - provider = provider, - id = id, - name = name, - thinkingLevels = levels, - supportsFastMode = entry.optBoolean("supportsFastMode", false), - contextWindow = if (entry.has("contextWindow")) entry.optInt("contextWindow").takeIf { it > 0 } else null, - ), - ) } + models.add( + CatalogModel( + provider = provider, + id = id, + name = name, + thinkingLevels = levels, + supportsFastMode = entry.optBoolean("supportsFastMode", false), + contextWindow = if (entry.has("contextWindow")) entry.optInt("contextWindow").takeIf { it > 0 } else null, + kind = entry.optString("kind").ifBlank { "chat" }, + webSearch = entry.optString("webSearch").takeIf { entry.has("webSearch") && !entry.isNull("webSearch") && it.isNotBlank() }, + ), + ) } + return models +} + +fun parseModelCatalog(data: JSONObject): ModelCatalogState { + val models = parseCatalogModels(data.optJSONArray("models")) + val kindModels = parseCatalogModels(data.optJSONArray("kindModels")) val defaultObj = data.optJSONObject("defaultModel") val defaultModel = if (defaultObj != null) { val provider = defaultObj.optString("provider") @@ -277,6 +324,7 @@ fun parseModelCatalog(data: JSONObject): ModelCatalogState { } return ModelCatalogState( models = models, + kindModels = kindModels, defaultModel = defaultModel, connectedProviders = connected, unavailable = data.optBoolean("unavailable", false), @@ -540,7 +588,17 @@ private fun ModelRolesSection( if (!loaded && pending) { ModelStatusText(text = if (korean) "불러오는 중…" else "Loading…") } else { - nativeModelRoles.forEach { role -> + (nativeModelRoles + kindModelRoles).forEach { role -> + if (role == kindModelRoles.first()) { + HorizontalDivider(color = OmpColors.Border, thickness = 1.dp) + Text( + text = stringResource(R.string.role_section_kind), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = OmpColors.Text, + modifier = Modifier.padding(top = 8.dp), + ) + } HorizontalDivider(color = OmpColors.Border, thickness = 1.dp) val raw = roles[role].orEmpty() val modelPart = raw.substringBeforeLast(":", missingDelimiterValue = raw) @@ -548,7 +606,8 @@ private fun ModelRolesSection( Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { - Text(role, style = MaterialTheme.typography.labelLarge, color = OmpColors.Text) + Text(modelRoleLabelRes(role)?.let { stringResource(it) } ?: role, style = MaterialTheme.typography.labelLarge, color = OmpColors.Text) + Text(role, fontFamily = FontFamily.Monospace, fontSize = 12.sp, color = OmpColors.TextMuted) Text(raw.ifBlank { if (korean) "재정의 없음" else "No override" }, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } @@ -567,7 +626,7 @@ private fun ModelRolesSection( ) } else if (raw.isNotBlank()) { Text( - text = modelSelectorLabel(modelPart, catalog.models), + text = modelSelectorLabel(modelPart, catalog.models + catalog.kindModels), fontSize = 12.sp, color = OmpColors.TextMuted, ) @@ -576,8 +635,8 @@ private fun ModelRolesSection( ModelTextField(raw, { roles = roles + (role to it) }, "provider/model[:effort]") ModelSearchField(pickerQuery, { pickerQuery = it }) val q = pickerQuery.trim() - val options = remember(catalog.models, q) { - catalog.models.filter { q.isEmpty() || it.selector.contains(q, ignoreCase = true) || it.name.contains(q, ignoreCase = true) } + val options = remember(catalog.models, catalog.kindModels, q, role) { + (catalog.models + catalog.kindModels).filter { roleAcceptsModel(role, it) && (q.isEmpty() || it.selector.contains(q, ignoreCase = true) || it.name.contains(q, ignoreCase = true)) } } if (options.isEmpty()) ModelStatusText(if (korean) "검색 결과가 없습니다" else "No matching models") LazyColumn(Modifier.fillMaxWidth().heightIn(max = 320.dp)) { diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/NativeSettings.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/NativeSettings.kt new file mode 100644 index 000000000..e7dd8487b --- /dev/null +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/NativeSettings.kt @@ -0,0 +1,212 @@ +package com.dbchbin.ompgui.remote.ui + +import org.json.JSONArray +import org.json.JSONObject + +internal data class NativeSettingText(val en: String, val ko: String) { + fun localized(korean: Boolean): String = if (korean && ko.isNotBlank()) ko else en +} + +internal data class NativeSettingDefinition( + val path: String, + val category: String, + val group: String, + val label: NativeSettingText, + val description: NativeSettingText, + val kind: String, + val defaultValue: Any?, + val choices: List, + val min: Double?, + val max: Double?, + val step: Double?, + val integer: Boolean, + val items: String?, + val recordValues: String?, + val secret: Boolean, + val admin: Boolean, + val confirmation: Boolean, + val scope: String, +) + +internal data class NativeSettingIssue(val path: String, val message: String) + +internal data class NativeSettingsSnapshot( + val path: String, + val scope: String, + val cwd: String?, + val settings: JSONObject, + val effectiveSettings: JSONObject, + val catalog: List, + val secretStatus: Map, + val issues: List, +) + +internal fun parseNativeSettingsSnapshot(json: JSONObject): NativeSettingsSnapshot { + val scope = json.optString("scope", "global") + require(scope == "global" || scope == "project") { "Invalid settings scope" } + val path = json.optString("path").takeIf { it.isNotBlank() } + ?: throw IllegalArgumentException("Settings response is missing its host path") + val settings = json.optJSONObject("settings") + ?: throw IllegalArgumentException("Settings response is missing persisted overrides") + val effective = json.optJSONObject("effectiveSettings") + ?: throw IllegalArgumentException("Settings response is missing effective settings") + val catalogJson = json.optJSONArray("catalog") + ?: throw IllegalArgumentException("Settings response is missing the settings catalog") + val catalog = buildList { + for (index in 0 until catalogJson.length()) { + val item = catalogJson.optJSONObject(index) ?: continue + val settingPath = item.optString("path").takeIf { it.isNotBlank() } ?: continue + val category = item.optString("category") + val kind = item.optString("kind") + val settingScope = item.optString("scope") + if (category !in setOf("models", "intelligence", "agents", "tools", "safety", "system") || + kind !in setOf("boolean", "number", "string", "enum", "array", "object") || + settingScope !in setOf("global", "both")) continue + val label = item.optJSONObject("label") ?: JSONObject() + val description = item.optJSONObject("description") ?: JSONObject() + val choicesJson = item.optJSONArray("choices") + val choices = buildList { + if (choicesJson != null) for (choiceIndex in 0 until choicesJson.length()) { + choicesJson.optString(choiceIndex).takeIf { it.isNotBlank() }?.let(::add) + } + } + add( + NativeSettingDefinition( + path = settingPath, + category = category, + group = item.optString("group").ifBlank { settingPath.substringBefore('.') }, + label = NativeSettingText(label.optString("en").ifBlank { settingPath }, label.optString("ko")), + description = NativeSettingText(description.optString("en"), description.optString("ko")), + kind = kind, + defaultValue = item.opt("defaultValue")?.takeUnless { it == JSONObject.NULL }, + choices = choices, + min = item.opt("min")?.let(::jsonNumber), + max = item.opt("max")?.let(::jsonNumber), + step = item.opt("step")?.let(::jsonNumber), + integer = item.optBoolean("integer"), + items = item.optString("items").takeIf { it in setOf("string", "number", "object") }, + recordValues = item.optString("recordValues").takeIf { it.isNotBlank() }, + secret = item.optBoolean("secret"), + admin = item.optBoolean("admin"), + confirmation = item.optBoolean("confirmation"), + scope = settingScope, + ), + ) + } + } + require(catalog.isNotEmpty()) { "Settings catalog is empty or unsupported" } + val secretJson = json.optJSONObject("secretStatus") ?: JSONObject() + val secretStatus = buildMap { + for (key in secretJson.keys()) put(key, secretJson.optBoolean(key)) + } + val issuesJson = json.optJSONArray("issues") ?: JSONArray() + val issues = buildList { + for (index in 0 until issuesJson.length()) { + val issue = issuesJson.optJSONObject(index) ?: continue + val issuePath = issue.optString("path").takeIf { it.isNotBlank() } ?: continue + val message = issue.optString("message").takeIf { it.isNotBlank() } ?: continue + add(NativeSettingIssue(issuePath, message)) + } + } + return NativeSettingsSnapshot(path, scope, json.optString("cwd").takeIf { it.isNotBlank() }, settings, effective, catalog, secretStatus, issues) +} + +internal fun settingIssueForPath(issues: List, path: String): NativeSettingIssue? = + issues.firstOrNull { issue -> + issue.path == path || issue.path.startsWith("$path.") || path.startsWith("${issue.path}.") + } + +private fun jsonNumber(value: Any): Double? = when (value) { + is Number -> value.toDouble() + is String -> value.toDoubleOrNull() + else -> null +} + +internal fun nestedSetting(root: JSONObject, path: String): Any? { + var node: Any = root + for (segment in path.split('.')) { + if (node !is JSONObject || !node.has(segment)) return null + node = node.opt(segment) ?: return null + if (node == JSONObject.NULL) return null + } + return node +} + +internal fun nestedSettingsPatch(path: String, value: Any): JSONObject { + val root = JSONObject() + var node = root + val segments = path.split('.') + require(segments.isNotEmpty() && segments.none { it.isBlank() }) { "Invalid setting path" } + for (segment in segments.dropLast(1)) { + val child = JSONObject() + node.put(segment, child) + node = child + } + node.put(segments.last(), value) + return root +} + +internal fun parseSettingDraft(definition: NativeSettingDefinition, draft: String): Any { + return when (definition.kind) { + "number" -> { + val value = draft.trim().toDoubleOrNull() ?: throw IllegalArgumentException("Enter a number") + if (definition.integer && value % 1.0 != 0.0) throw IllegalArgumentException("Enter a whole number") + definition.min?.let { require(value >= it) { "Minimum: ${formatJsonNumber(it)}" } } + definition.max?.let { require(value <= it) { "Maximum: ${formatJsonNumber(it)}" } } + definition.step?.takeIf { it > 0.0 }?.let { step -> + val units = (value - (definition.min ?: 0.0)) / step + require(kotlin.math.abs(units - kotlin.math.round(units)) < 1e-9) { "Use increments of ${formatJsonNumber(step)}" } + } + if (definition.integer) value.toLong() else value + } + "array" -> JSONArray(draft).also { array -> + for (index in 0 until array.length()) when (definition.items) { + "number" -> require(array.opt(index) is Number) { "Every item must be a number" } + "object" -> require(array.opt(index) is JSONObject) { "Every item must be an object" } + else -> { + require(array.opt(index) is String) { "Every item must be text" } + if (definition.choices.isNotEmpty()) require(array.getString(index) in definition.choices) { "Unsupported item: ${array.getString(index)}" } + } + } + } + "object" -> JSONObject(draft).also { objectValue -> + when (definition.path) { + "providers.maxInFlightRequests" -> for (key in objectValue.keys()) require(objectValue.opt(key) is Number) { "$key must be a number" } + "modelTags" -> for (key in objectValue.keys()) { + val tag = objectValue.optJSONObject(key) ?: throw IllegalArgumentException("$key must be an object") + require(tag.opt("name") is String && tag.optString("name").isNotBlank()) { "$key.name is required" } + if (tag.has("color")) require(tag.opt("color") is String) { "$key.color must be text" } + if (tag.has("hidden")) require(tag.opt("hidden") is Boolean) { "$key.hidden must be true or false" } + } + "images.urls.options" -> for (key in objectValue.keys()) require(objectValue.opt(key) is JSONObject) { "$key must be an object" } + } + for (key in objectValue.keys()) { + val entry = objectValue.opt(key) + when (definition.recordValues) { + "string" -> require(entry is String) { "$key must be text" } + "boolean-string" -> require(entry is Boolean || entry is String) { "$key must be true, false, or text" } + "string-array" -> { + require(entry is JSONArray) { "$key must be an array" } + for (index in 0 until entry.length()) require(entry.opt(index) is String) { "$key must contain only text" } + } + "string-or-string-array" -> { + require(entry is String || entry is JSONArray) { "$key must be text or a text array" } + if (entry is JSONArray) for (index in 0 until entry.length()) require(entry.opt(index) is String) { "$key must contain only text" } + } + "approval" -> require(entry is String && entry in setOf("allow", "prompt", "deny")) { "$key must be allow, prompt, or deny" } + } + } + } + else -> draft + } +} + +internal fun formatSettingValue(value: Any?): String = when (value) { + null, JSONObject.NULL -> "" + is JSONObject -> value.toString(2) + is JSONArray -> value.toString(2) + is Double -> formatJsonNumber(value) + else -> value.toString() +} + +private fun formatJsonNumber(value: Double): String = if (value % 1.0 == 0.0) value.toLong().toString() else value.toString() diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt index 7e9b0c9cb..710c68d39 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/OmpguiRemoteApp.kt @@ -11,10 +11,14 @@ import java.util.Locale import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -43,9 +47,11 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { val backDispatcherOwner = requireNotNull(androidx.activity.compose.LocalOnBackPressedDispatcherOwner.current) val preferences = remember(context) { AppPreferences.prefs(context) } var language by remember { mutableStateOf(AppPreferences.getLanguage(context, Locale.getDefault().language)) } + var thinkingShown by remember { mutableStateOf(AppPreferences.isThinkingShown(context)) } DisposableEffect(preferences) { val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, changedKey -> if (changedKey == AppPreferences.KEY_LANGUAGE) language = AppPreferences.getLanguage(context, Locale.getDefault().language) + if (changedKey == AppPreferences.KEY_SHOW_THINKING) thinkingShown = AppPreferences.isThinkingShown(context) } preferences.registerOnSharedPreferenceChangeListener(listener) onDispose { preferences.unregisterOnSharedPreferenceChangeListener(listener) } @@ -89,6 +95,7 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { LocalMarkdownNavigation provides MarkdownNavigation(state.sessionCwd) { path -> state.sessionCwd?.let { cwd -> filePreview = cwd to path } }, + LocalThinkingShown provides thinkingShown, ) { key(language) { RemoteTheme { @@ -112,17 +119,8 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { } } Surface { - when (val screen = state.screen) { - is RemoteScreen.Pairing -> PairingScreen( - uri = state.pairingUri, - password = state.password, - error = state.error, - connecting = state.connection == ConnectionState.Connecting, - onUriChange = viewModel::setPairingUri, - onPasswordChange = viewModel::setPassword, - onConnect = viewModel::pair, - ) - is RemoteScreen.Sessions -> SessionListScreen( + val sessionList: @Composable () -> Unit = { + SessionListScreen( requester = viewModel.requester, initialDraft = newSessionDraft, onSlashSelected = { newSessionDraft = it }, @@ -132,7 +130,10 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { runningIds = state.runningIds, connection = state.connection, error = state.error, - refreshing = state.connection == ConnectionState.Connecting, + refreshing = state.sessionsRefreshing || state.connection == ConnectionState.Connecting, + sessionsLoaded = state.sessionsLoaded, + nextRetryAtMillis = state.nextRetryAtMillis, + onReconnect = viewModel::reconnectNow, onRefresh = viewModel::refreshSessions, onOpen = ::openSession, onUnpair = { drafts.clear(); newSessionDraft = ""; filePreview = null; viewModel.unpair() }, @@ -163,7 +164,8 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { onAddProject = viewModel::addProject, onRemoveProject = viewModel::removeProject, ) - is RemoteScreen.Chat -> { + } + val chatPane: @Composable (RemoteScreen.Chat) -> Unit = { screen -> val thinkingLevel = state.sessionThinkingLevel ?: settings?.optString("defaultThinkingLevel")?.takeIf { it.isNotBlank() } ?: "auto" @@ -198,6 +200,10 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { onOpenPalette = { viewModel.fetchSlash(); chatPaletteOpen = true }, title = state.chatTitle.ifBlank { screen.sessionId }, messages = state.messages, + transcriptOffset = state.transcriptOffset, + earlierMessagesLoading = state.earlierMessagesLoading, + earlierMessagesError = state.earlierMessagesError, + onLoadEarlierMessages = viewModel::loadEarlierMessages, draft = state.draft, running = state.running, connection = state.connection, @@ -268,6 +274,31 @@ fun OmpguiRemoteApp(viewModel: RemoteViewModel) { onDismiss = { chatUsageOpen = false }, ) } + } + // Window width from the configuration: it recomposes on every size/density change, + // whereas constraints-only measurement lagged until the next input after `wm size`. + val twoPane = LocalConfiguration.current.screenWidthDp.dp >= AdaptiveTwoPaneMinWidth + Box(Modifier.fillMaxSize()) { + val screen = state.screen + if (screen !is RemoteScreen.Pairing) { + // One call site for Sessions and Chat in both widths, so the list and chat keep + // their state when a session opens and when the window crosses the two-pane width. + AdaptiveListDetail( + twoPane = twoPane, + detailOpen = screen is RemoteScreen.Chat, + list = sessionList, + detail = { if (screen is RemoteScreen.Chat) chatPane(screen) else AdaptiveEmptyDetail() }, + ) + } else { + PairingScreen( + uri = state.pairingUri, + password = state.password, + error = state.error, + connecting = state.connection == ConnectionState.Connecting, + onUriChange = viewModel::setPairingUri, + onPasswordChange = viewModel::setPassword, + onConnect = viewModel::pair, + ) } } if (chatPaletteOpen) { diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/PairingScreen.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/PairingScreen.kt index 5784d0906..2c06ed4b3 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/PairingScreen.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/PairingScreen.kt @@ -25,11 +25,14 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Terminal +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -40,14 +43,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation @@ -69,6 +76,9 @@ fun PairingScreen( val context = LocalContext.current var showScanner by remember { mutableStateOf(false) } var needCameraMessage by remember { mutableStateOf(false) } + var passwordVisible by remember { mutableStateOf(false) } + val focusManager = LocalFocusManager.current + val connectEnabled = uri.isNotBlank() && !connecting val cameraPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission(), ) { granted -> @@ -142,6 +152,15 @@ fun PairingScreen( monospace = true, minLines = 3, singleLine = false, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Next, + ), + keyboardActions = KeyboardActions( + onNext = { focusManager.moveFocus(FocusDirection.Down) }, + ), ) OmpField( label = stringResource(R.string.pair_password_label), @@ -150,8 +169,45 @@ fun PairingScreen( monospace = false, minLines = 1, singleLine = true, - visualTransformation = PasswordVisualTransformation(), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (passwordVisible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { if (connectEnabled) onConnect() }, + ), + trailing = { + Icon( + imageVector = if (passwordVisible) { + Icons.Filled.VisibilityOff + } else { + Icons.Filled.Visibility + }, + contentDescription = stringResource( + if (passwordVisible) { + R.string.input_ux_hide_password + } else { + R.string.input_ux_show_password + }, + ), + tint = OmpColors.TextMuted, + modifier = Modifier + // 48dp touch target; the field grows slightly to fit it. + .size(48.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(role = androidx.compose.ui.semantics.Role.Button) { + passwordVisible = !passwordVisible + } + .padding(12.dp), + ) + }, ) } if (!error.isNullOrBlank()) { @@ -197,7 +253,7 @@ fun PairingScreen( } OmpButton( outlined = false, - enabled = uri.isNotBlank() && !connecting, + enabled = connectEnabled, onClick = onConnect, label = if (connecting) { stringResource(R.string.pair_connecting) @@ -230,6 +286,8 @@ private fun OmpField( singleLine: Boolean, visualTransformation: VisualTransformation = VisualTransformation.None, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + trailing: @Composable (() -> Unit)? = null, ) { var focused by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { @@ -258,6 +316,7 @@ private fun OmpField( minLines = minLines, visualTransformation = visualTransformation, keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, textStyle = TextStyle( color = OmpColors.Text, fontSize = 13.sp, @@ -266,20 +325,26 @@ private fun OmpField( cursorBrush = SolidColor(OmpColors.Text), modifier = Modifier.fillMaxWidth(), decorationBox = { inner -> - Box { - if (value.isEmpty()) { - Text( - text = label, - color = OmpColors.TextDim, - fontSize = 13.sp, - fontFamily = if (monospace) { - FontFamily.Monospace - } else { - FontFamily.Default - }, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Box(modifier = Modifier.weight(1f)) { + if (value.isEmpty()) { + Text( + text = label, + color = OmpColors.TextDim, + fontSize = 13.sp, + fontFamily = if (monospace) { + FontFamily.Monospace + } else { + FontFamily.Default + }, + ) + } + inner() + } + if (trailing != null) { + Spacer(modifier = Modifier.width(8.dp)) + trailing() } - inner() } }, ) diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RemoteViewModel.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RemoteViewModel.kt index 2b76eb86d..0f15d34fd 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RemoteViewModel.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RemoteViewModel.kt @@ -45,9 +45,21 @@ data class RemoteUiState( val connection: ConnectionState = ConnectionState.Idle, val error: String? = null, val sessions: List = emptyList(), + /** False until the first sessions frame after (re)connecting; distinguishes loading from empty. */ + val sessionsLoaded: Boolean = false, + /** True from a sessions.list request until its sessions frame, disconnect, or failure. */ + val sessionsRefreshing: Boolean = false, + /** Wall-clock time (System.currentTimeMillis) of the next scheduled reconnect, when backing off. */ + val nextRetryAtMillis: Long? = null, val runningIds: Set = emptySet(), val chatTitle: String = "", val messages: List = emptyList(), + val transcriptTotal: Int = 0, + val transcriptOffset: Int = 0, + val transcriptNextOffset: Int = 0, + val transcriptHasMore: Boolean = false, + val earlierMessagesLoading: Boolean = false, + val earlierMessagesError: String? = null, val running: Boolean = false, val draft: String = "", val paired: Boolean = false, @@ -137,6 +149,8 @@ class RemoteViewModel( fun refreshSessions() = client().refreshSessions() + fun reconnectNow() = client().reconnectNow() + fun openModelPicker() = client().openModelPicker() fun closeModelPicker() = client().closeModelPicker() @@ -151,6 +165,8 @@ class RemoteViewModel( fun refreshMessageQueue() = client().refreshMessageQueue() + fun loadEarlierMessages() = client().loadEarlierMessages() + suspend fun recallQueuedMessage(id: String): Boolean = client().recallQueuedMessage(id) fun deleteQueuedMessage(id: String) = client().deleteQueuedMessage(id) diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RichPreview.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RichPreview.kt index 660ad47d5..acd1f0ea7 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RichPreview.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/RichPreview.kt @@ -40,6 +40,7 @@ fun RichPreview( modifier: Modifier = Modifier, language: String = "", compactCode: Boolean = false, + wrapLines: Boolean = false, ) { val context = LocalContext.current val textZoom = (LocalDensity.current.fontScale * 100).toInt() @@ -111,9 +112,14 @@ fun RichPreview( } } } + // Wrap is embedded only as the initial body class; later toggles flip the class in place so the + // WebView keeps its scroll position instead of reloading the whole (possibly large) document. + val latestWrap = rememberUpdatedState(wrapLines) val document = remember(content, kind, language, compactCode, OmpColors.dark, OmpColors.warm) { - richPreviewDocument(content, kind, language, compactCode) + val initialWrap = latestWrap.value + richPreviewDocument(content, kind, language, compactCode, initialWrap) to initialWrap } + val appliedWrap = remember(webView) { java.util.concurrent.atomic.AtomicBoolean(wrapLines) } DisposableEffect(webView, lifecycle) { val observer = LifecycleEventObserver { _, event -> when (event) { @@ -132,9 +138,10 @@ fun RichPreview( webView.destroy() } } - // Code never wraps: match its 13px × 1.6 CSS line height, body padding + // Unwrapped code: match its 13px × 1.6 CSS line height, body padding // and 8px scrollbar clearance without allocating a line list. - // Caller constraints still win (the file viewer deliberately reserves a viewport). + // Caller constraints still win (the file viewer deliberately reserves a viewport, + // which also covers wrapped code whose visual line count is unknown here). val previewModifier = if (kind == RichPreviewKind.Code) { val lineCount = remember(content) { 1 + content.count { it == '\n' } } val verticalInsets = if (compactCode) 20f else 32f @@ -148,18 +155,33 @@ fun RichPreview( modifier = previewModifier, update = { view -> if (view.settings.textZoom != textZoom) view.settings.textZoom = textZoom - val documentKey = document to navigation?.cwd - if (view.tag != documentKey) { - view.tag = documentKey + fun load(html: String) { // loadDataWithBaseURL's synthetic main request also reaches interception; // denying it replaces the document with Chromium's HTTP error page. // Give each document an exact local URL instead of exempting arbitrary data URLs. val url = android.net.Uri.parse(PreviewOrigin).buildUpon() .path(navigation?.cwd?.trimEnd('/').orEmpty() + "/") .appendQueryParameter("document", UUID.randomUUID().toString()).build().toString() - localDocument.set(url to document.toByteArray(Charsets.UTF_8)) + localDocument.set(url to html.toByteArray(Charsets.UTF_8)) view.loadUrl(url) } + val documentKey = document.first to navigation?.cwd + if (view.tag != documentKey) { + view.tag = documentKey + appliedWrap.set(wrapLines) + // The remembered document bakes in the wrap state it was built with; rebuild + // only when a later toggle raced a content/cwd change. + load(if (document.second == wrapLines) document.first else richPreviewDocument(content, kind, language, compactCode, wrapLines)) + } else if (appliedWrap.get() != wrapLines) { + appliedWrap.set(wrapLines) + val target = wrapLines + view.evaluateJavascript("document.body.classList.toggle('wrap',$target)") { result -> + // Page not ready or script unavailable: reload once with the requested class. + if (result != target.toString() && appliedWrap.get() == target && view.tag == documentKey) { + load(richPreviewDocument(content, kind, language, compactCode, target)) + } + } + } }, ) } @@ -168,7 +190,7 @@ fun RichPreview( private fun escapePreviewHtml(value: String): String = value.replace("&", "&") .replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'") -internal fun richPreviewDocument(content: String, kind: RichPreviewKind, language: String, compactCode: Boolean = false): String { +internal fun richPreviewDocument(content: String, kind: RichPreviewKind, language: String, compactCode: Boolean = false, wrapLines: Boolean = false): String { val nonce = UUID.randomUUID().toString() val background = "#%06x".format((if (kind == RichPreviewKind.Code) OmpColors.CodeBg else OmpColors.BgPanel).toArgb() and 0xffffff) val foreground = "#%06x".format(OmpColors.Text.toArgb() and 0xffffff) @@ -178,14 +200,14 @@ internal fun richPreviewDocument(content: String, kind: RichPreviewKind, languag val warning = "#%06x".format(OmpColors.StatusWarning.toArgb() and 0xffffff) val border = "#%06x".format(OmpColors.Border.toArgb() and 0xffffff) val panel = "#%06x".format(OmpColors.BgPanel.toArgb() and 0xffffff) - val style = "html{background:$background;color-scheme:${if (OmpColors.dark) "dark" else "light"}}body{margin:0;padding:${if (compactCode && kind == RichPreviewKind.Code) "6px 12px" else "12px"};background:$background;color:$foreground;font:14px/1.6 system-ui,sans-serif;overflow-wrap:anywhere}pre{margin:0;padding:${if (kind == RichPreviewKind.Html) "12px" else "0"};white-space:pre;overflow:auto;overflow-wrap:normal;tab-size:4;font:13px/1.6 monospace;background:$background}code{font-family:monospace}img,svg{max-width:100%;height:auto}a{color:$accent}h1,h2,h3{line-height:1.3}h1{font-size:22px}h2{font-size:18px}h3{font-size:16px}table{display:block;max-width:100%;overflow:auto;border-collapse:collapse}td,th{padding:6px 10px;border:1px solid $border}blockquote{margin:12px 0;padding:0 12px;border-left:2px solid $border;color:$muted}#status{font-size:12px;color:$muted}#diagram-output:not(:empty){padding:12px;background:$panel;border:1px solid $border;border-radius:8px;margin-bottom:12px;overflow:auto}*{animation:none!important;transition:none!important;scroll-behavior:auto!important}.token.comment,.token.prolog{color:$muted}.token.keyword,.token.operator{color:$accent}.token.string,.token.attr-value{color:$success}.token.number,.token.boolean{color:$warning}.token.function,.token.tag{color:$accent}iframe{border:0;width:100%;height:100vh}" + val style = "html{background:$background;color-scheme:${if (OmpColors.dark) "dark" else "light"}}body{margin:0;padding:${if (compactCode && kind == RichPreviewKind.Code) "6px 12px" else "12px"};background:$background;color:$foreground;font:14px/1.6 system-ui,sans-serif;overflow-wrap:anywhere}pre{margin:0;padding:${if (kind == RichPreviewKind.Html) "12px" else "0"};white-space:pre;overflow:auto;overflow-wrap:normal;tab-size:4;font:13px/1.6 monospace;background:$background}body.wrap pre{white-space:pre-wrap;overflow-wrap:anywhere}code{font-family:monospace}img,svg{max-width:100%;height:auto}a{color:$accent}h1,h2,h3{line-height:1.3}h1{font-size:22px}h2{font-size:18px}h3{font-size:16px}table{display:block;max-width:100%;overflow:auto;border-collapse:collapse}td,th{padding:6px 10px;border:1px solid $border}blockquote{margin:12px 0;padding:0 12px;border-left:2px solid $border;color:$muted}#status{font-size:12px;color:$muted}#diagram-output:not(:empty){padding:12px;background:$panel;border:1px solid $border;border-radius:8px;margin-bottom:12px;overflow:auto}*{animation:none!important;transition:none!important;scroll-behavior:auto!important}.token.comment,.token.prolog{color:$muted}.token.keyword,.token.operator{color:$accent}.token.string,.token.attr-value{color:$success}.token.number,.token.boolean{color:$warning}.token.function,.token.tag{color:$accent}iframe{border:0;width:100%;height:100vh}" val policy = "default-src 'none'; script-src ${if (kind == RichPreviewKind.Html) "'none'" else "'nonce-$nonce'"}; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'" val label = when (kind) { RichPreviewKind.Code -> "Highlighted code" RichPreviewKind.Mermaid -> "Mermaid diagram" RichPreviewKind.Html -> "Document preview" } - val head = "$label" + val head = "$label" if (kind == RichPreviewKind.Html) return head + content + "" // JSON strings additionally escape HTML delimiters: a closing script tag can never escape data. val source = JSONObject.quote(content).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029") diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SessionListScreen.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SessionListScreen.kt index f4dc7e248..b19c8b21f 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SessionListScreen.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SessionListScreen.kt @@ -1,5 +1,7 @@ package com.dbchbin.ompgui.remote.ui +import androidx.activity.compose.BackHandler +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable @@ -151,6 +153,9 @@ fun SessionListScreen( onOpenSessionFilePreview: (String, String) -> Unit, onAddProject: (String) -> Unit = {}, onRemoveProject: suspend (String) -> Unit = {}, + sessionsLoaded: Boolean = true, + nextRetryAtMillis: Long? = null, + onReconnect: () -> Unit = onRefresh, ) { val context = LocalContext.current var overflowOpen by remember { mutableStateOf(false) } @@ -181,6 +186,7 @@ fun SessionListScreen( var confirmDelete by remember { mutableStateOf(false) } var confirmArchive by remember { mutableStateOf(false) } var selectedIds by remember { mutableStateOf(emptySet()) } + BackHandler(enabled = selectedIds.isNotEmpty()) { selectedIds = emptySet() } var pinnedIds by rememberSaveable { mutableStateOf(emptySet()) } var autonamePending by remember { mutableStateOf(null) } var listActionError by remember { mutableStateOf(null) } @@ -322,18 +328,12 @@ fun SessionListScreen( Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.new_session), color = OmpColors.Text) } - if (connection != ConnectionState.Connected) { - Text( - text = when (connection) { - ConnectionState.Connecting -> stringResource(R.string.status_connecting) - ConnectionState.Failed -> stringResource(R.string.status_failed) - else -> stringResource(R.string.status_idle) - }, - color = OmpColors.TextMuted, - fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } + ConnectionBanner( + connection = connection, + nextRetryAtMillis = nextRetryAtMillis, + onReconnect = onReconnect, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) if (!error.isNullOrBlank()) { Text( error, @@ -413,13 +413,26 @@ fun SessionListScreen( .fillMaxWidth() .weight(1f), ) { - if (groups.isEmpty()) { - Text( - stringResource(if (searchActive) R.string.session_search_no_matches else R.string.sessions_empty), - color = OmpColors.TextMuted, - fontSize = 14.sp, - modifier = Modifier.padding(24.dp), - ) + if (!sessionsLoaded && sessions.isEmpty() && connection != ConnectionState.Failed) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator( + color = OmpColors.Accent, + modifier = Modifier.semantics { contentDescription = context.getString(R.string.conn_ux_loading_sessions) }, + ) + } + } else if (groups.isEmpty()) { + Column(Modifier.padding(24.dp)) { + Text( + stringResource(if (searchActive) R.string.session_search_no_matches else R.string.sessions_empty), + color = OmpColors.TextMuted, + fontSize = 14.sp, + ) + if (connection == ConnectionState.Failed) { + OutlinedButton(onClick = onReconnect, modifier = Modifier.padding(top = 12.dp).heightIn(min = 48.dp)) { + Text(stringResource(R.string.conn_ux_retry), color = OmpColors.Text) + } + } + } } else { LazyColumn( state = listState, diff --git a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt index cf524d85f..fe3de8d0c 100644 --- a/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt +++ b/android/app/src/main/java/com/dbchbin/ompgui/remote/ui/SettingsSheet.kt @@ -1,32 +1,35 @@ package com.dbchbin.ompgui.remote.ui +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.BorderStroke -import androidx.compose.ui.Alignment -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowForward -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.ExpandMore -import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.filled.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.activity.compose.BackHandler +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.LocalPinnableContainer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.dbchbin.ompgui.remote.R import com.dbchbin.ompgui.remote.net.ConnectionState @@ -38,38 +41,31 @@ import kotlinx.coroutines.launch import org.json.JSONArray import org.json.JSONObject -private data class SettingField(val path: String, val category: String, val kind: String = "boolean", val choices: List = emptyList(), val min: Int = 0, val max: Int = Int.MAX_VALUE) +private data class SettingsMutation( + val path: String, + val value: Any? = null, + val reset: Boolean = false, + val secret: String? = null, + val clearSecret: Boolean = false, +) -private val settingFields = buildList { - add(SettingField("defaultThinkingLevel", "Models", "choice", listOf("auto", "minimal", "low", "medium", "high", "xhigh", "max"))) - add(SettingField("textVerbosity", "Models", "choice", listOf("low", "medium", "high"))) - add(SettingField("personality", "Models", "choice", listOf("default", "friendly", "pragmatic", "none"))) - for (path in listOf("hideThinkingBlock", "externalThinking", "registryHasScopedEntries")) add(SettingField(path, "Models")) - for (path in listOf("enabledModels", "disabledProviders", "modelProviderOrder")) add(SettingField(path, "Models", "array")) - add(SettingField("tools.approvalMode", "Safety", "choice", listOf("always-ask", "write", "yolo"))) - add(SettingField("tools.approval.bash", "Safety", "choice", listOf("allow", "prompt", "deny"))) - add(SettingField("tools.approval.extension", "Safety", "choice", listOf("allow", "prompt"))) - for (path in listOf("advisor.enabled", "advisor.subagents", "retry.enabled", "retry.modelFallback", "compaction.enabled", "compaction.midTurnEnabled", "compaction.autoContinue", "compaction.remoteEnabled", "autolearn.enabled", "autolearn.autoContinue", "mnemopi.autoRecall", "mnemopi.autoRetain", "mnemopi.noEmbeddings")) add(SettingField(path, "Intelligence")) - add(SettingField("advisor.syncBacklog", "Intelligence", "choice", listOf("off", "1", "3", "5"))) - add(SettingField("advisor.immuneTurns", "Intelligence", "integer", min = 0, max = 20)) - add(SettingField("retry.maxRetries", "Intelligence", "integer", min = 0, max = 20)) - add(SettingField("retry.fallbackRevertPolicy", "Intelligence", "choice", listOf("cooldown-expiry", "never"))) - add(SettingField("retry.fallbackChains", "Models", "object")) - add(SettingField("compaction.strategy", "Intelligence", "choice", listOf("snapcompact", "handoff", "context-full", "shake", "off"))) - add(SettingField("compaction.keepRecentTokens", "Intelligence", "integer", min = 1000, max = 1000000)) - add(SettingField("autolearn.minToolCalls", "Intelligence", "integer", min = 0, max = 100)) - add(SettingField("memory.backend", "Intelligence", "choice", listOf("off", "local", "mnemopi", "hindsight"))) - add(SettingField("mnemopi.scoping", "Intelligence", "choice", listOf("global", "per-project", "per-project-tagged"))) - add(SettingField("task.eager", "Agents", "choice", listOf("default", "preferred", "always"))) - add(SettingField("task.prewalk", "Agents")) - add(SettingField("task.disabledAgents", "Agents", "array")) - for (path in listOf("task.agentModelOverrides", "task.agentPrewalk", "task.agentAdvisor")) add(SettingField(path, "Agents", "object")) - for (path in listOf("mcp.enableProjectConfig", "mcp.renderMarkdownResults", "mcp.notifications", "browser.enabled", "browser.relay", "browser.headless", "computer.enabled", "web_search.enabled", "github.enabled", "security.enabled", "checkpoint.enabled")) add(SettingField(path, "Tools")) - add(SettingField("mcp.notificationDebounceMs", "Tools", "integer", min = 0, max = 60000)) - add(SettingField("computer.display", "Tools", "string")) -} +private data class LocalField( + val path: String, + val kind: String = "boolean", + val choices: List = emptyList(), +) + +private data class CatalogDraft( + val value: String, + val error: String? = null, + val secret: String = "", + val arrayAddition: String = "", + val dirty: Boolean = false, +) + +private data class CatalogDraftKey(val scope: String, val cwd: String, val path: String) -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable fun SettingsSheet( requester: RelayRequester, @@ -78,368 +74,236 @@ fun SettingsSheet( currentModel: ModelRef?, onUnpair: () -> Unit, onDismiss: () -> Unit, - settings: JSONObject? = null, + @Suppress("UNUSED_PARAMETER") settings: JSONObject? = null, deviceId: String = "", settingsCwd: String = "", ) { val context = LocalContext.current - val scope = rememberCoroutineScope() - var snapshot by remember { mutableStateOf(settings) } + val coroutineScope = rememberCoroutineScope() + val korean = context.resources.configuration.locales[0].language == "ko" + var selectedScope by rememberSaveable(settingsCwd) { mutableStateOf("global") } + var snapshot by remember { mutableStateOf(null) } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } var notice by remember { mutableStateOf(null) } - var query by rememberSaveable { mutableStateOf("") } var reload by remember { mutableIntStateOf(0) } - var category by rememberSaveable { mutableStateOf("Browse") } + var query by rememberSaveable { mutableStateOf("") } + var category by rememberSaveable { mutableStateOf("browse") } var confirmation by remember { mutableStateOf Unit>?>(null) } var showUsage by remember { mutableStateOf(false) } - val sectionState = rememberSaveableStateHolder() - val scrollPositions = remember { mutableMapOf() } - val scrollKey = if (query.isBlank()) category else "search" - val contentScroll = scrollPositions.getOrPut(scrollKey) { androidx.compose.foundation.ScrollState(0) } - var modelsVisited by remember { mutableStateOf(false) } - val modelCategory = category == "Models" || category == "Roles" || category == "Providers" - val categories = listOf("Browse", "General", "Roles", "Safety", "Models", "Providers", "Intelligence", "Agents", "Tools", "System") - val categoryTitles = listOf( - stringResource(R.string.settings_category_browse), - stringResource(R.string.settings_category_general), - stringResource(R.string.settings_category_roles), - stringResource(R.string.settings_category_safety), - stringResource(R.string.settings_category_models), - stringResource(R.string.settings_category_providers), - stringResource(R.string.settings_category_intelligence), - stringResource(R.string.settings_category_agents), - stringResource(R.string.settings_category_tools), - stringResource(R.string.settings_category_system), - ) - val categoryDescriptions = listOf( - stringResource(R.string.settings_browse_hint), - stringResource(R.string.settings_category_desc_general), - stringResource(R.string.settings_category_desc_roles), - stringResource(R.string.settings_category_desc_safety), - stringResource(R.string.settings_category_desc_models), - stringResource(R.string.settings_category_desc_providers), - stringResource(R.string.settings_category_desc_intelligence), - stringResource(R.string.settings_category_desc_agents), - stringResource(R.string.settings_category_desc_tools), - stringResource(R.string.settings_category_desc_system), - ) - val contentCategories = categories.drop(1) - val contentTitles = categoryTitles.drop(1) - val contentDescriptions = categoryDescriptions.drop(1) - val selectedCategoryIndex = categories.indexOf(category).coerceAtLeast(0) - val selectedCategoryDescription = categoryDescriptions.getOrElse(selectedCategoryIndex) { "" } - val groupAdvisor = stringResource(R.string.settings_group_advisor) - val groupCompaction = stringResource(R.string.settings_group_compaction) - val groupMemory = stringResource(R.string.settings_group_memory) - val groupRetry = stringResource(R.string.settings_group_retry) - val groupTask = stringResource(R.string.settings_group_task) - val groupMcp = stringResource(R.string.settings_group_mcp) - val groupModels = stringResource(R.string.settings_group_models) - val groupToolsSafety = stringResource(R.string.settings_group_tools_safety) - val groupAppearance = stringResource(R.string.settings_group_appearance) - val groupInterface = stringResource(R.string.settings_group_interface) - val tabAgentInventory = stringResource(R.string.settings_tab_agent_inventory) - val tabTaskDefaults = stringResource(R.string.settings_tab_task_defaults) - val settingsSubtabAria = stringResource(R.string.settings_subtab_aria) - val settingsNavAria = stringResource(R.string.settings_nav_aria) - val settingsAllSections = stringResource(R.string.settings_all_sections) - val settingsBrowseTitle = stringResource(R.string.settings_browse_title) - LaunchedEffect(modelCategory) { if (modelCategory) modelsVisited = true } - // Migrate stale saveable values if any future ids change; keep unknown as Browse. - LaunchedEffect(category) { - if (category !in categories) category = "Browse" + var modifiedOnly by rememberSaveable { mutableStateOf(false) } + // Hoisted out of the lazy list so collapsing survives items scrolling out of composition. + var expandedGroups by rememberSaveable { mutableStateOf(listOf()) } + // Deliberately not saveable: this owner survives filtering/collapse/category changes, + // while write-only credentials never enter Android saved-instance-state Bundles. + val catalogDrafts = remember { mutableStateMapOf() } + + fun hasDirtyDrafts(scope: String = selectedScope, cwd: String = settingsCwd) = + catalogDrafts.any { (key, draft) -> draft.dirty && key.scope == scope && key.cwd == cwd } + fun discardDrafts(scope: String = selectedScope, cwd: String = settingsCwd) { + catalogDrafts.keys.filter { it.scope == scope && it.cwd == cwd }.forEach(catalogDrafts::remove) } - val save: (String, Any) -> Unit = { path, value -> - if (!busy) scope.launch { - busy = true - error = null - notice = null - try { - val patch = JSONObject() - var node = patch - val segments = path.split('.') - for (segment in segments.dropLast(1)) { - val child = JSONObject() - node.put(segment, child) - node = child - } - node.put(segments.last(), value) - val result = requester.request("system", "settings.update", JSONObject().put("settings", patch)) - snapshot = result.getJSONObject("settings") - notice = if (result.optJSONObject("application")?.optString("mode") == "runtime-refresh") { - context.getString(R.string.settings_saved_runtime) - } else { - context.getString(R.string.settings_saved_new_sessions) - } - } catch (failure: CancellationException) { throw failure - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_update_failed) - } finally { busy = false } - } + + val fixedCategories = listOf("browse", "general", "roles", "providers", "models", "intelligence", "agents", "tools", "safety", "administrator", "system") + + fun requestArgs(): JSONObject = JSONObject().put("scope", selectedScope).apply { + if (selectedScope == "project") put("cwd", settingsCwd) + } + fun switchScope(next: String) { + if (next == selectedScope) return + val switch = { discardDrafts(); snapshot = null; selectedScope = next } + if (hasDirtyDrafts()) { + confirmation = context.getString(R.string.settings_unsaved_scope_change) to switch + } else switch() + } + fun refresh() { + val action: () -> Unit = { reload++; Unit } + if (hasDirtyDrafts()) confirmation = context.getString(R.string.settings_unsaved_refresh) to action else action() } - LaunchedEffect(requester, reload) { + fun requestDismiss() { + val action = { discardDrafts(); onDismiss() } + if (hasDirtyDrafts()) confirmation = context.getString(R.string.settings_unsaved_dismiss) to action else action() + } + suspend fun load() { busy = true error = null - try { snapshot = requester.request("system", "settings.get", JSONObject()).getJSONObject("settings") - } catch (failure: CancellationException) { throw failure - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_load_failed) - } finally { busy = false } + try { + snapshot = parseNativeSettingsSnapshot(requester.request("system", "settings.get", requestArgs())) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + error = failure.message ?: context.getString(R.string.settings_load_failed) + } finally { + busy = false + } } - OmpModalSheet( - onDismissRequest = onDismiss, - fullHeight = true, - containerColor = OmpColors.Bg, - contentColor = OmpColors.Text, - ) { - // Hierarchy only — Dialog owns system Back dismiss when this is inactive. - BackHandler(enabled = !showUsage && (query.isNotBlank() || category != "Browse")) { - if (query.isNotBlank()) query = "" - else category = "Browse" + fun mutate(mutation: SettingsMutation, confirmed: Boolean = false, onSuccess: () -> Unit = {}) { + if (busy) return + val definition = snapshot?.catalog?.firstOrNull { it.path == mutation.path } ?: return + val action = { + coroutineScope.launch { + busy = true + error = null + notice = null + try { + val args = requestArgs() + when { + mutation.reset -> args.put("reset", JSONArray().put(mutation.path)) + definition.secret -> args.put("secrets", JSONObject().put(mutation.path, if (mutation.clearSecret) JSONObject.NULL else mutation.secret)) + else -> args.put("settings", nestedSettingsPatch(mutation.path, requireNotNull(mutation.value))) + } + if (definition.confirmation) args.put("confirm", JSONArray().put(mutation.path)) + val result = requester.request("system", "settings.update", args) + snapshot = parseNativeSettingsSnapshot(result) + notice = context.getString(R.string.settings_saved_new_sessions) + onSuccess() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Exception) { + error = failure.message ?: context.getString(R.string.settings_update_failed) + } finally { + busy = false + } + } + } + if (definition.confirmation && !confirmed) { + val proposed = when { + mutation.reset -> if (selectedScope == "project") context.getString(R.string.settings_state_inherited) else context.getString(R.string.settings_state_default) + definition.secret && mutation.clearSecret -> context.getString(R.string.settings_secret_delete) + definition.secret -> context.getString(R.string.settings_secret_masked_replacement) + else -> formatSettingValue(mutation.value) + } + confirmation = context.getString(R.string.settings_confirm_scoped_change, if (selectedScope == "project") context.getString(R.string.settings_scope_project) else context.getString(R.string.settings_scope_global), mutation.path, proposed) to { + mutate(mutation, confirmed = true, onSuccess = onSuccess) + } + } else action() + } + + LaunchedEffect(requester, selectedScope, settingsCwd, reload) { load() } + LaunchedEffect(category) { if (category !in fixedCategories) category = "browse" } + + OmpModalSheet(onDismissRequest = ::requestDismiss, fullHeight = true, containerColor = OmpColors.Bg, contentColor = OmpColors.Text) { + BackHandler(enabled = !showUsage && (query.isNotBlank() || category != "browse")) { + if (query.isNotBlank()) query = "" else category = "browse" } Column(Modifier.fillMaxWidth().weight(1f)) { - Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp).padding(horizontal = 16.dp), verticalAlignment = Alignment.CenterVertically) { Text(stringResource(R.string.settings_title), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) - IconButton(enabled = !busy, onClick = { notice = null; reload++ }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Refresh, stringResource(R.string.settings_refresh), modifier = Modifier.size(20.dp)) } - IconButton(onClick = onDismiss, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Close, stringResource(R.string.settings_close), modifier = Modifier.size(20.dp)) } + IconButton(enabled = !busy, onClick = { refresh() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Refresh, stringResource(R.string.settings_refresh)) } + IconButton(onClick = ::requestDismiss, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Close, stringResource(R.string.settings_close)) } } HorizontalDivider(color = OmpColors.Border) - - Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp)) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - placeholder = { Text(stringResource(R.string.settings_search)) }, - modifier = Modifier.fillMaxWidth().heightIn(min = 40.dp), - singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium, - shape = MaterialTheme.shapes.small, - trailingIcon = if (query.isNotBlank()) { - { - IconButton(onClick = { query = "" }, modifier = Modifier.size(40.dp)) { - Icon(Icons.Default.Close, stringResource(R.string.settings_clear_search), modifier = Modifier.size(16.dp)) - } - } - } else null, - ) - } - - val showBrowseOverview = query.isBlank() && category == "Browse" - if (!showBrowseOverview) { - val quickTabIndex = contentCategories.indexOf(category).coerceAtLeast(0) - ScrollableTabRow( - selectedTabIndex = quickTabIndex, - edgePadding = 0.dp, - containerColor = OmpColors.Bg, - contentColor = OmpColors.Text, - modifier = Modifier.semantics { contentDescription = settingsNavAria }, - indicator = { tabPositions -> - if (tabPositions.isNotEmpty() && quickTabIndex in tabPositions.indices) { - TabRowDefaults.SecondaryIndicator( - Modifier.tabIndicatorOffset(tabPositions[quickTabIndex]), - color = OmpColors.Accent, - ) - } - }, - ) { - contentCategories.forEachIndexed { index, name -> - val title = contentTitles[index] - val description = contentDescriptions[index] - Tab( - selected = category == name, - onClick = { category = name; query = ""; notice = null }, - modifier = Modifier - .heightIn(min = 48.dp) - .semantics { contentDescription = "$title. $description" }, - text = { - Text( - title, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - ) - } + Column(Modifier.padding(horizontal = 16.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField(query, { query = it }, modifier = Modifier.fillMaxWidth(), singleLine = true, placeholder = { Text(stringResource(R.string.settings_search)) }, trailingIcon = if (query.isBlank()) null else {{ IconButton(onClick = { query = "" }) { Icon(Icons.Default.Close, stringResource(R.string.settings_clear_search)) } }}) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = selectedScope == "global", onClick = { switchScope("global") }, enabled = !busy, label = { Text(stringResource(R.string.settings_scope_global)) }, modifier = Modifier.heightIn(min = 48.dp)) + FilterChip(selected = selectedScope == "project", onClick = { switchScope("project") }, enabled = !busy && settingsCwd.isNotBlank(), label = { Text(stringResource(R.string.settings_scope_project)) }, modifier = Modifier.heightIn(min = 48.dp)) + if (category != "browse" || query.isNotBlank()) FilterChip( + selected = modifiedOnly, + onClick = { modifiedOnly = !modifiedOnly }, + label = { Text(stringResource(R.string.settings_ux_modified_only)) }, + leadingIcon = if (modifiedOnly) {{ Icon(Icons.Default.Check, null, Modifier.size(FilterChipDefaults.IconSize)) }} else null, + modifier = Modifier.heightIn(min = 48.dp), + ) } - HorizontalDivider(color = OmpColors.Border) - } - - Column( - Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } notice?.let { Text(it, color = OmpColors.TextMuted, style = MaterialTheme.typography.bodySmall) } - if (query.isBlank()) { - Row( - Modifier.fillMaxWidth(), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - if (showBrowseOverview) settingsBrowseTitle else categoryTitles[selectedCategoryIndex], - style = MaterialTheme.typography.titleSmall, - color = OmpColors.Text, - ) - Text( - if (showBrowseOverview) stringResource(R.string.settings_browse_hint) else selectedCategoryDescription, - style = MaterialTheme.typography.bodySmall, - color = OmpColors.TextMuted, - ) - } - if (!showBrowseOverview) { - TextButton( - onClick = { category = "Browse"; query = ""; notice = null }, - modifier = Modifier.heightIn(min = 48.dp), - ) { - Text(settingsAllSections) - } - } + } + if (category != "browse" || query.isNotBlank()) { + ScrollableTabRow(selectedTabIndex = fixedCategories.drop(1).indexOf(category).coerceAtLeast(0), edgePadding = 0.dp, containerColor = OmpColors.Bg) { + fixedCategories.drop(1).forEach { id -> Tab(selected = category == id, onClick = { category = id; query = "" }, modifier = Modifier.heightIn(min = 48.dp), text = { Text(settingsCategoryTitle(id), maxLines = 1, overflow = TextOverflow.Ellipsis) }) } + } + } + val catalogView = category != "browse" || query.isNotBlank() + // Filtering/grouping the catalog allocates per definition; recompute only when its inputs + // change, not on every busy/notice/expand recomposition of the sheet. + val categoryFields = remember(snapshot, selectedScope, query, category, korean, catalogView) { + if (!catalogView) return@remember emptyList() + snapshot?.catalog.orEmpty().filter { definition -> + if (selectedScope == "global" && definition.path in GloballyManagedSettings) return@filter false + if (query.isNotBlank()) { + definition.path.contains(query, true) || definition.group.contains(query, true) || + definition.label.localized(korean).contains(query, true) || definition.description.localized(korean).contains(query, true) + } else when (category) { + "administrator" -> definition.admin + "models", "intelligence", "agents", "tools", "safety" -> definition.category == category && !definition.admin + "system" -> definition.category == "system" && !definition.admin + else -> false } } } - Column(Modifier.weight(1f).fillMaxWidth().verticalScroll(contentScroll).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - sectionState.SaveableStateProvider(if (query.isBlank()) category else "search") { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (showBrowseOverview) { - SettingsCategoryOverview( - categories = contentCategories, - titles = contentTitles, - descriptions = contentDescriptions, - onSelect = { selected -> - category = selected - query = "" - notice = null - }, + val fields = remember(categoryFields, modifiedOnly, snapshot) { + if (modifiedOnly) categoryFields.filter { catalogSettingModified(it, snapshot) } else categoryFields + } + val fieldGroups = remember(fields) { fields.groupBy { it.group } } + LazyColumn(Modifier.weight(1f).fillMaxWidth(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + item(key = "settings-scope-info") { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + if (selectedScope == "project") stringResource(R.string.settings_scope_project_path, snapshot?.cwd ?: settingsCwd) + else stringResource(R.string.settings_scope_global_path, snapshot?.path ?: stringResource(R.string.settings_path_loading)), + style = MaterialTheme.typography.bodySmall, + color = OmpColors.TextMuted, ) + if (selectedScope == "project") Text(stringResource(R.string.settings_scope_config_path, snapshot?.path ?: stringResource(R.string.settings_path_loading)), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + Text(stringResource(R.string.settings_apply_new_sessions), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + snapshot?.issues?.forEach { issue -> Text(stringResource(R.string.settings_stored_value_issue, issue.path, issue.message), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } } - var showTaskDefaults by rememberSaveable(category) { mutableStateOf(false) } - if (query.isBlank() && category == "Agents") { - val tabs = listOf(tabAgentInventory, tabTaskDefaults) - ScrollableTabRow( - selectedTabIndex = if (showTaskDefaults) 1 else 0, - edgePadding = 0.dp, - containerColor = OmpColors.Bg, - contentColor = OmpColors.Text, - modifier = Modifier.semantics { contentDescription = settingsSubtabAria }, - ) { - tabs.forEachIndexed { index, title -> - Tab( - selected = showTaskDefaults == (index == 1), - onClick = { showTaskDefaults = index == 1 }, - modifier = Modifier.heightIn(min = 48.dp), - text = { Text(title, maxLines = 1, overflow = TextOverflow.Ellipsis) }, - ) + } + if (category == "browse" && query.isBlank()) { + items(fixedCategories.drop(1), key = { "settings-category:$it" }) { id -> + Surface(Modifier.fillMaxWidth().heightIn(min = 64.dp).clickable(role = Role.Button) { category = id }, color = OmpColors.BgPanel, shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, OmpColors.Border)) { + Row(Modifier.padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { Text(settingsCategoryTitle(id), style = MaterialTheme.typography.titleSmall); Text(settingsCategoryDescription(id), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } + Icon(Icons.AutoMirrored.Filled.ArrowForward, null) } } } - val localMatches = query.isBlank() && category == "General" || query.isNotBlank() && listOf("general", "theme", "palette", "language", "appearance", "completion chime", "submit", "collapse tool calls", "interface").any { it.contains(query, true) } - if (localMatches) LocalPreferencesSection(groupAppearance = groupAppearance, groupInterface = groupInterface) - val groupLabels = SettingsGroupLabels( - advisor = groupAdvisor, - compaction = groupCompaction, - memory = groupMemory, - retry = groupRetry, - task = groupTask, - mcp = groupMcp, - models = groupModels, - toolsSafety = groupToolsSafety, - ) - val fields = settingFields.filter { - if (query.isBlank()) { - when (category) { - "Browse" -> false - "Agents" -> showTaskDefaults && it.category == "Agents" - // Tools fields render inside ExtensionSettingsPanel Optional Tools. - "Tools" -> false - else -> it.category == category - } - } else { - val matchedCategories = contentCategories.filterIndexed { index, _ -> - contentTitles[index].contains(query, true) || - contentDescriptions[index].contains(query, true) - } - it.path.contains(query, true) || - it.category.contains(query, true) || - groupLabels.forField(it).contains(query, true) || - it.category in matchedCategories - } + } else { + if (query.isBlank()) item(key = "settings-category-header:$category") { + Column { Text(settingsCategoryTitle(category), style = MaterialTheme.typography.titleSmall); Text(settingsCategoryDescription(category), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } } - val rolesCategoryIndex = categories.indexOf("Roles") - val rolesMatch = query.isNotBlank() && ( - categoryTitles[rolesCategoryIndex].contains(query, true) || - categoryDescriptions[rolesCategoryIndex].contains(query, true) || - "modelRoles".contains(query, true) || - nativeModelRoles.any { it.contains(query, true) } - ) - if (rolesMatch) { - SettingsCategoryOverview( - categories = listOf("Roles"), - titles = listOf(categoryTitles[rolesCategoryIndex]), - descriptions = listOf(categoryDescriptions[rolesCategoryIndex]), - onSelect = { selected -> - category = selected - query = "" - notice = null - }, - ) + val localMatches = queryMatches(query, "general theme palette language completion submit collapse thinking interface") + if (category == "general" || localMatches) item(key = "settings-local-preferences") { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { LocalPreferencesSection() } } - if (query.isNotBlank() && fields.isEmpty() && !localMatches && !rolesMatch) Text(stringResource(R.string.settings_no_matches), color = OmpColors.TextMuted) - SettingFieldGroups( - fields = fields, - snapshot = snapshot, - busy = busy, - groupLabels = groupLabels, - onConfirm = { message, action -> confirmation = message to action }, - save = save, + catalogSettingsItems( + fieldGroups, fields.size, snapshot, selectedScope, settingsCwd, busy, korean, catalogDrafts, + expandedGroups = expandedGroups, + toggleGroup = { group -> expandedGroups = if (group in expandedGroups) expandedGroups - group else expandedGroups + group }, + mutate = ::mutate, ) - if (query.isBlank()) when (category) { - "Models" -> currentModel?.let { Text(stringResource(R.string.settings_current_model, it.displayName(), it.provider), style = MaterialTheme.typography.bodySmall) } - "Agents" -> if (!showTaskDefaults) ExtensionAgentsSection(requester, settingsCwd) - "Tools" -> ExtensionSettingsPanel(requester, settingsCwd) { - SettingFieldGroups( - fields = settingFields.filter { it.category == "Tools" }, - snapshot = snapshot, - busy = busy, - groupLabels = groupLabels, - onConfirm = { message, action -> confirmation = message to action }, - save = save, - ) - } - "System" -> { - Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleSmall) - for ((label, value) in listOf( - stringResource(R.string.settings_relay) to serverUrl, - stringResource(R.string.settings_device) to deviceId, - stringResource(R.string.settings_status) to connection.toString(), - )) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(label, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - SelectionContainer { Text(value, style = MaterialTheme.typography.bodyMedium) } + if (modifiedOnly && categoryFields.isNotEmpty() && fields.isEmpty()) item(key = "settings-no-modified") { Text(stringResource(R.string.settings_ux_no_modified), color = OmpColors.TextMuted) } + if (query.isNotBlank() && categoryFields.isEmpty() && !localMatches) item(key = "settings-no-matches") { Text(stringResource(R.string.settings_no_matches), color = OmpColors.TextMuted) } + if (query.isBlank()) item(key = "settings-auxiliary:$category") { + // Pinned: manager panels own dialogs and in-flight requests that must not be + // disposed just because the lazy list scrolled them off screen. + KeepComposedInLazyList { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + when (category) { + "models" -> { currentModel?.let { Text(stringResource(R.string.settings_current_model, it.displayName(), it.provider), style = MaterialTheme.typography.bodySmall) }; Text(stringResource(R.string.settings_global_model_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } + "roles" -> Text(stringResource(R.string.settings_global_model_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + "providers" -> Text(stringResource(R.string.settings_global_provider_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + "agents" -> { Text(stringResource(R.string.settings_separate_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted); ExtensionAgentsSection(requester, settingsCwd) } + "tools" -> { Text(stringResource(R.string.settings_separate_manager_hint), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted); ExtensionSettingsPanel(requester, settingsCwd) } + "system" -> { ConnectionAndUsage(serverUrl, deviceId, connection) { showUsage = true }; SystemControls(requester, deviceId, onUnpair) } } } - OutlinedButton(onClick = { showUsage = true }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small) { Text(stringResource(R.string.settings_usage)) } - SystemControls(requester, deviceId, onUnpair) } } - } } - if (modelsVisited || modelCategory) { - val visible = modelCategory && query.isBlank() - val visibility = if (visible) Modifier else Modifier.clearAndSetSemantics {}.layout { _, _ -> layout(0, 0) {} } - Column(Modifier.fillMaxWidth().then(visibility)) { - ModelSettingsPanel( - requester, - settingsCwd, - selectedSection = when (category) { - "Roles" -> ModelSettingsSection.Roles - "Providers" -> ModelSettingsSection.Providers - else -> ModelSettingsSection.Defaults - }, - ) - } + val modelSection = if (query.isNotBlank()) ModelSettingsSection.Hidden else when (category) { + "models" -> ModelSettingsSection.Defaults + "roles" -> ModelSettingsSection.Roles + "providers" -> ModelSettingsSection.Providers + else -> ModelSettingsSection.Hidden + } + // One composition identity owns provider credentials and OAuth polling for + // the entire sheet lifetime, including browse and search transitions. The item + // key is constant and the item is pinned so scrolling never disposes it. + item(key = "settings-model-panel") { + KeepComposedInLazyList { ModelSettingsPanel(requester, settingsCwd, modelSection) } } } } @@ -449,226 +313,218 @@ fun SettingsSheet( } @Composable -private fun SettingsCategoryOverview( - categories: List, - titles: List, - descriptions: List, - onSelect: (String) -> Unit, -) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - categories.forEachIndexed { index, id -> - val categoryTitle = titles[index] - val categoryDescription = descriptions[index] - Surface( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 64.dp) - .clickable(role = Role.Button, onClick = { onSelect(id) }) - .semantics { contentDescription = "$categoryTitle. $categoryDescription" }, - color = OmpColors.BgPanel, - shape = MaterialTheme.shapes.small, - border = BorderStroke(1.dp, OmpColors.Border), - ) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(categoryTitle, style = MaterialTheme.typography.titleSmall, color = OmpColors.Text) - Text(categoryDescription, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - } - Icon( - Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = null, - tint = OmpColors.TextDim, - modifier = Modifier.size(18.dp), - ) - } - } - } +private fun settingsCategoryTitle(id: String): String = stringResource(when (id) { + "browse" -> R.string.settings_category_browse + "general" -> R.string.settings_category_general + "roles" -> R.string.settings_category_roles + "providers" -> R.string.settings_category_providers + "models" -> R.string.settings_category_models + "intelligence" -> R.string.settings_category_intelligence + "agents" -> R.string.settings_category_agents + "tools" -> R.string.settings_category_tools + "safety" -> R.string.settings_category_safety + "administrator" -> R.string.settings_category_administrator + else -> R.string.settings_category_system +}) + +@Composable +private fun settingsCategoryDescription(id: String): String = stringResource(when (id) { + "browse" -> R.string.settings_browse_hint + "general" -> R.string.settings_category_desc_general + "roles" -> R.string.settings_category_desc_roles + "providers" -> R.string.settings_category_desc_providers + "models" -> R.string.settings_category_desc_models + "intelligence" -> R.string.settings_category_desc_intelligence + "agents" -> R.string.settings_category_desc_agents + "tools" -> R.string.settings_category_desc_tools + "safety" -> R.string.settings_category_desc_safety + "administrator" -> R.string.settings_category_desc_administrator + else -> R.string.settings_category_desc_system +}) + +private fun queryMatches(query: String, haystack: String) = query.isNotBlank() && haystack.contains(query, true) + +/** Global keys owned by the dedicated model/provider/agent managers instead of the generic catalog editor. */ +private val GloballyManagedSettings = setOf( + "enabledModels", "disabledProviders", "modelProviderOrder", "modelRoles", + "task.disabledAgents", "task.agentModelOverrides", "task.agentPrewalk", "task.agentAdvisor", +) + +/** Pins a lazy item so off-screen scrolling never disposes its state, dialogs, or in-flight work. */ +@Composable +private fun KeepComposedInLazyList(content: @Composable () -> Unit) { + val pinnable = LocalPinnableContainer.current + DisposableEffect(pinnable) { + val handle = pinnable?.pin() + onDispose { handle?.release() } } + content() } -private data class SettingsGroupLabels( - val advisor: String, - val compaction: String, - val memory: String, - val retry: String, - val task: String, - val mcp: String, - val models: String, - val toolsSafety: String, -) { - fun forField(field: SettingField): String = when (field.path.substringBefore('.', field.category)) { - "advisor" -> advisor - "compaction" -> compaction - "memory", "autolearn", "mnemopi" -> memory - "retry" -> retry - "task" -> task - "mcp" -> mcp - "Models" -> models - "tools" -> toolsSafety - else -> field.path.substringBefore('.', field.category).replaceFirstChar { char -> char.uppercase() } - } +/** Same layer state the editor's state chip reports: a stored value, a stored issue, or a configured secret. */ +private fun catalogSettingModified(definition: NativeSettingDefinition, snapshot: NativeSettingsSnapshot?): Boolean { + snapshot ?: return false + return nestedSetting(snapshot.settings, definition.path) != null || + settingIssueForPath(snapshot.issues, definition.path) != null || + (definition.secret && snapshot.secretStatus[definition.path] == true) } -@Composable -private fun SettingFieldGroups( - fields: List, - snapshot: JSONObject?, +@OptIn(ExperimentalFoundationApi::class) +private fun LazyListScope.catalogSettingsItems( + groups: Map>, + fieldCount: Int, + snapshot: NativeSettingsSnapshot?, + scope: String, + cwd: String, busy: Boolean, - groupLabels: SettingsGroupLabels, - onConfirm: (String, () -> Unit) -> Unit, - save: (String, Any) -> Unit, + korean: Boolean, + drafts: MutableMap, + expandedGroups: List, + toggleGroup: (String) -> Unit, + mutate: (SettingsMutation, Boolean, () -> Unit) -> Unit, ) { - val context = LocalContext.current - for ((group, groupFields) in fields.groupBy(groupLabels::forField)) { - Text(group, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) - for (field in groupFields) { - var node: JSONObject? = snapshot - val segments = field.path.split('.') - for (segment in segments.dropLast(1)) node = node?.optJSONObject(segment) - val value = node?.opt(segments.last())?.takeUnless { it == JSONObject.NULL } - key(field.path) { - SettingEditor(field, value, !busy && snapshot != null) { newValue -> - if (field.path == "computer.enabled" && newValue == true || field.path == "tools.approvalMode" && newValue == "yolo" || field.path == "tools.approval.bash" && newValue == "allow") { - onConfirm(context.getString(R.string.settings_confirm_security_grant, field.path, newValue)) { save(field.path, newValue) } - } else save(field.path, newValue) + val forceExpanded = fieldCount <= 12 + groups.forEach { (group, grouped) -> + val expanded = forceExpanded || group in expandedGroups + stickyHeader(key = "settings-group:$group") { + Surface(Modifier.fillMaxWidth(), color = OmpColors.BgPanel, shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, OmpColors.Border)) { + Row(Modifier.fillMaxWidth().clickable(role = Role.Button) { toggleGroup(group) }.heightIn(min = 48.dp).padding(horizontal = 12.dp), verticalAlignment = Alignment.CenterVertically) { + Text(group, Modifier.weight(1f), style = MaterialTheme.typography.titleSmall) + Text(grouped.size.toString(), color = OmpColors.TextMuted) + Icon(if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, null) } } } + if (expanded) items(grouped, key = { "settings-field:$scope:$cwd:${it.path}" }) { definition -> + CatalogSettingEditor(definition, snapshot, scope, cwd, busy, korean, drafts) { mutation, success -> mutate(mutation, false, success) } + } } } @Composable -private fun SettingEditor(field: SettingField, value: Any?, enabled: Boolean, save: (Any) -> Unit) { - val context = LocalContext.current - var draft by rememberSaveable(field.path, value?.toString()) { mutableStateOf(value?.toString() ?: "") } - var error by remember(field.path) { mutableStateOf(null) } - val label = when (field.path) { - "Theme" -> stringResource(R.string.settings_label_theme) - "Palette" -> stringResource(R.string.settings_label_palette) - "Language" -> stringResource(R.string.settings_label_language) - "Completion chime" -> stringResource(R.string.settings_label_chime) - "Submit during run" -> stringResource(R.string.settings_label_submit) - "Collapse tool calls" -> stringResource(R.string.settings_label_collapse) - else -> field.path.substringAfterLast('.').replace(Regex("([a-z])([A-Z])"), "$1 $2").replaceFirstChar { it.uppercase() } +private fun SettingStateChip(text: String, color: androidx.compose.ui.graphics.Color) { + Surface(shape = MaterialTheme.shapes.extraSmall, color = OmpColors.Bg, border = BorderStroke(1.dp, color.copy(alpha = 0.5f))) { + Text(text, Modifier.padding(horizontal = 6.dp, vertical = 1.dp), style = MaterialTheme.typography.labelSmall, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis) } - val description = when (field.path) { - "Theme" -> stringResource(R.string.settings_desc_theme) - "Palette" -> stringResource(R.string.settings_desc_palette) - "Language" -> stringResource(R.string.settings_desc_language) - "Completion chime" -> stringResource(R.string.settings_desc_chime) - "Submit during run" -> stringResource(R.string.settings_desc_submit) - "Collapse tool calls" -> stringResource(R.string.settings_desc_collapse) - "tools.approvalMode" -> stringResource(R.string.settings_desc_approval_mode) - "tools.approval.bash" -> stringResource(R.string.settings_desc_approval_bash) - "tools.approval.extension" -> stringResource(R.string.settings_desc_approval_extension) - "defaultThinkingLevel" -> stringResource(R.string.settings_desc_thinking_level) - "textVerbosity" -> stringResource(R.string.settings_desc_verbosity) - "personality" -> stringResource(R.string.settings_desc_personality) - "hideThinkingBlock" -> stringResource(R.string.settings_desc_hide_thinking) - "externalThinking" -> stringResource(R.string.settings_desc_external_thinking) - "advisor.enabled" -> stringResource(R.string.settings_desc_advisor_enabled) - "advisor.subagents" -> stringResource(R.string.settings_desc_advisor_subagents) - "advisor.syncBacklog" -> stringResource(R.string.settings_desc_advisor_sync) - "compaction.enabled" -> stringResource(R.string.settings_desc_compaction_enabled) - "compaction.autoContinue" -> stringResource(R.string.settings_desc_compaction_auto) - "compaction.strategy" -> stringResource(R.string.settings_desc_compaction_strategy) - "compaction.midTurnEnabled" -> stringResource(R.string.settings_desc_compaction_mid) - "memory.backend" -> stringResource(R.string.settings_desc_memory_backend) - "autolearn.enabled" -> stringResource(R.string.settings_desc_autolearn_enabled) - "autolearn.autoContinue" -> stringResource(R.string.settings_desc_autolearn_auto) - "mnemopi.scoping" -> stringResource(R.string.settings_desc_mnemopi_scoping) - "mnemopi.autoRecall" -> stringResource(R.string.settings_desc_mnemopi_recall) - "mnemopi.autoRetain" -> stringResource(R.string.settings_desc_mnemopi_retain) - "retry.enabled" -> stringResource(R.string.settings_desc_retry_enabled) - "retry.maxRetries" -> stringResource(R.string.settings_desc_retry_max) - "retry.modelFallback" -> stringResource(R.string.settings_desc_retry_fallback) - "retry.fallbackRevertPolicy" -> stringResource(R.string.settings_desc_retry_revert) - "retry.fallbackChains" -> stringResource(R.string.settings_desc_retry_chains) - "mcp.enableProjectConfig" -> stringResource(R.string.settings_desc_mcp_project) - "mcp.renderMarkdownResults" -> stringResource(R.string.settings_desc_mcp_markdown) - "mcp.notifications" -> stringResource(R.string.settings_desc_mcp_notifications) - else -> null +} + +@Composable +private fun CatalogSettingEditor( + definition: NativeSettingDefinition, + snapshot: NativeSettingsSnapshot?, + scope: String, + cwd: String, + busy: Boolean, + korean: Boolean, + drafts: MutableMap, + submit: (SettingsMutation, () -> Unit) -> Unit, +) { + val override = snapshot?.let { nestedSetting(it.settings, definition.path) } + val effective = snapshot?.let { nestedSetting(it.effectiveSettings, definition.path) } ?: definition.defaultValue + val storedIssue = snapshot?.let { settingIssueForPath(it.issues, definition.path) } + val editable = snapshot != null && !busy && (scope == "global" || definition.scope == "both") + val label = definition.label.localized(korean) + val draftKey = CatalogDraftKey(scope, cwd, definition.path) + val baseline = formatSettingValue(override ?: effective) + val draft = drafts[draftKey] ?: CatalogDraft(value = baseline) + var menuOpen by remember { mutableStateOf(false) } + fun updateDraft(next: CatalogDraft) { drafts[draftKey] = next } + fun clearDraft() { drafts.remove(draftKey) } + LaunchedEffect(baseline) { + if (!draft.dirty && draft.value != baseline) updateDraft(draft.copy(value = baseline, error = null)) } - Column(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { HorizontalDivider(color = OmpColors.Border) - Column(Modifier.padding(vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { - Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(label, style = MaterialTheme.typography.titleSmall) - description?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } - if (field.category != "General") Text(field.path, style = MaterialTheme.typography.labelSmall, color = OmpColors.TextDim) - if (value == null) Text(stringResource(R.string.settings_uses_server_default), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - if (field.kind == "integer") Text("${field.min}–${field.max}", style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - } - if (field.kind == "boolean" && value is Boolean) { - Switch(checked = value, onCheckedChange = { save(it) }, enabled = enabled, modifier = Modifier.heightIn(min = 48.dp).semantics { contentDescription = label }) - } - } - when { - field.kind == "integer" -> { - Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) { - OutlinedTextField(draft, { draft = it; error = null }, enabled = enabled, modifier = Modifier.fillMaxWidth().semantics { contentDescription = label }, singleLine = true, textStyle = MaterialTheme.typography.bodyMedium, keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number), isError = error != null) - TextButton(modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), enabled = enabled && draft != (value?.toString() ?: ""), onClick = { - try { - val parsed = draft.toInt().also { require(it in field.min..field.max) { context.getString(R.string.settings_integer_range, field.min, field.max) } } - save(parsed) - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_invalid_value) } - }) { Text(stringResource(R.string.settings_save)) } + Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(label, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(definition.path, Modifier.weight(1f, fill = false), style = MaterialTheme.typography.labelSmall, color = OmpColors.TextDim, maxLines = 1, overflow = TextOverflow.Ellipsis) + when { + storedIssue != null -> SettingStateChip(stringResource(R.string.settings_ux_chip_issue), MaterialTheme.colorScheme.error) + override != null -> SettingStateChip(stringResource(if (scope == "project") R.string.settings_ux_chip_project else R.string.settings_ux_chip_modified), OmpColors.Accent) + scope == "project" -> SettingStateChip(stringResource(R.string.settings_ux_chip_inherited), OmpColors.TextMuted) + else -> SettingStateChip(stringResource(R.string.settings_ux_chip_default), OmpColors.TextMuted) } } - (field.kind == "boolean" && value !is Boolean) || field.kind == "choice" -> { - val options = if (field.kind == "boolean") listOf("true", "false") else field.choices - var expanded by remember { mutableStateOf(false) } - Box(Modifier.fillMaxWidth()) { - OutlinedButton(onClick = { expanded = true }, enabled = enabled, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), shape = MaterialTheme.shapes.small, border = BorderStroke(1.dp, OmpColors.Border)) { - Text(when { - field.path == "Submit during run" && value == AppPreferences.SUBMIT_STEER -> stringResource(R.string.chat_submit_steer) - field.path == "Submit during run" && value == AppPreferences.SUBMIT_QUEUE -> stringResource(R.string.chat_submit_queue) - else -> value?.toString() ?: stringResource(R.string.settings_default) - }, Modifier.weight(1f), color = OmpColors.Text) - Icon(Icons.Default.ExpandMore, stringResource(R.string.settings_choose, field.path), modifier = Modifier.size(18.dp)) - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - options.forEach { option -> - DropdownMenuItem(text = { Text(when { - field.path == "Submit during run" && option == AppPreferences.SUBMIT_STEER -> stringResource(R.string.chat_submit_steer) - field.path == "Submit during run" && option == AppPreferences.SUBMIT_QUEUE -> stringResource(R.string.chat_submit_queue) - else -> option - }) }, onClick = { expanded = false; save(if (field.kind == "boolean") option.toBoolean() else option) }) - } - } - } + val description = definition.description.localized(korean) + if (description.isNotBlank()) { + var descriptionExpanded by rememberSaveable(definition.path) { mutableStateOf(false) } + Text( + description, + Modifier.clickable(onClickLabel = stringResource(if (descriptionExpanded) R.string.settings_ux_collapse_description else R.string.settings_ux_expand_description)) { descriptionExpanded = !descriptionExpanded }, + style = MaterialTheme.typography.bodySmall, + color = OmpColors.TextMuted, + maxLines = if (descriptionExpanded) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + ) } + if (storedIssue != null) Text(storedIssue.message, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + if (scope == "project" && definition.scope == "global") Text(stringResource(R.string.settings_global_only), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) } + if (definition.kind == "boolean" && !definition.secret) Switch(checked = (override ?: effective) == true, enabled = editable, onCheckedChange = { checked -> submit(SettingsMutation(definition.path, checked)) {} }, modifier = Modifier.heightIn(min = 48.dp).semantics { contentDescription = label }) + } + when { + definition.secret -> { + Text(if (snapshot?.secretStatus?.get(definition.path) == true) stringResource(R.string.settings_secret_configured) else stringResource(R.string.settings_secret_not_configured), style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) + OutlinedTextField(draft.secret, { updateDraft(draft.copy(secret = it, error = null, dirty = it.isNotBlank())) }, enabled = editable, modifier = Modifier.fillMaxWidth(), label = { Text(stringResource(R.string.settings_secret_replacement)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), isError = draft.error != null) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(enabled = editable && snapshot?.secretStatus?.get(definition.path) == true, modifier = Modifier.heightIn(min = 48.dp), onClick = { submit(SettingsMutation(definition.path, clearSecret = true), ::clearDraft) }) { Text(stringResource(R.string.settings_clear)) } + TextButton(enabled = editable && draft.secret.isNotBlank(), modifier = Modifier.heightIn(min = 48.dp), onClick = { submit(SettingsMutation(definition.path, secret = draft.secret), ::clearDraft) }) { Text(stringResource(R.string.settings_save)) } + } } - if (field.kind != "boolean" && field.kind != "choice" && field.kind != "integer") { - OutlinedTextField(draft, { draft = it; error = null }, enabled = enabled, modifier = Modifier.fillMaxWidth(), label = { Text(when (field.kind) { "array" -> stringResource(R.string.settings_json_array); "object" -> stringResource(R.string.settings_json_object); else -> stringResource(R.string.settings_value) }) }, isError = error != null) - TextButton(modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), enabled = enabled && draft != (value?.toString() ?: ""), onClick = { - try { - val parsed: Any = when (field.kind) { - "array" -> JSONArray(draft).also { array -> for (index in 0 until array.length()) require(array.get(index) is String && array.getString(index).isNotBlank()) { context.getString(R.string.settings_array_entries) } } - "object" -> JSONObject(draft) - else -> draft - } - save(parsed) - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_invalid_value) } - }) { Text(stringResource(R.string.settings_save)) } + definition.kind == "enum" -> Box(Modifier.fillMaxWidth()) { + OutlinedButton(onClick = { menuOpen = true }, enabled = editable, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text((override ?: effective)?.toString() ?: stringResource(R.string.settings_default), Modifier.weight(1f)); Icon(Icons.Default.ExpandMore, null) } + DropdownMenu(menuOpen, { menuOpen = false }) { definition.choices.forEach { choice -> DropdownMenuItem(text = { Text(choice) }, onClick = { menuOpen = false; submit(SettingsMutation(definition.path, choice)) {} }) } } + } + definition.kind == "array" && definition.items != "object" -> OrderedArrayEditor( + definition = definition, + value = draft.value, + addition = draft.arrayAddition, + enabled = editable, + onChange = { value, addition -> updateDraft(draft.copy(value = value, arrayAddition = addition, error = null, dirty = value != baseline || addition.isNotBlank())) }, + onSave = { + try { val parsed = parseSettingDraft(definition, draft.value); submit(SettingsMutation(definition.path, parsed), ::clearDraft) } + catch (failure: Exception) { updateDraft(draft.copy(error = failure.message)) } + }, + ) + definition.kind != "boolean" -> { + OutlinedTextField(draft.value, { updateDraft(draft.copy(value = it, error = null, dirty = it != baseline)) }, enabled = editable, modifier = Modifier.fillMaxWidth(), minLines = if (definition.kind in setOf("object", "array")) 3 else 1, maxLines = if (definition.kind in setOf("object", "array")) 10 else 4, singleLine = definition.kind in setOf("number", "string"), keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, keyboardType = if (definition.kind == "number") KeyboardType.Decimal else KeyboardType.Text), isError = draft.error != null, label = { Text(if (definition.kind in setOf("object", "array")) stringResource(R.string.settings_json_value) else stringResource(R.string.settings_value)) }) + TextButton(enabled = editable && draft.value != baseline, modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), onClick = { + try { val parsed = parseSettingDraft(definition, draft.value); submit(SettingsMutation(definition.path, parsed), ::clearDraft) } + catch (failure: Exception) { updateDraft(draft.copy(error = failure.message)) } + }) { Text(stringResource(R.string.settings_save)) } } - error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } } + draft.error?.let { Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) } + if ((override != null || storedIssue != null) && !definition.secret) TextButton(enabled = editable, modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), onClick = { submit(SettingsMutation(definition.path, reset = true), ::clearDraft) }) { Text(stringResource(R.string.settings_reset_inherit)) } } } @Composable -private fun LocalPreferencesSection( - groupAppearance: String, - groupInterface: String, -) { +private fun OrderedArrayEditor(definition: NativeSettingDefinition, value: String, addition: String, enabled: Boolean, onChange: (String, String) -> Unit, onSave: () -> Unit) { + val initial = remember(value) { runCatching { JSONArray(value) }.getOrElse { JSONArray() } } + val values = remember(definition.path, value) { mutableStateListOf().apply { for (index in 0 until initial.length()) add(initial.opt(index)?.toString().orEmpty()) } } + fun sync(nextAddition: String = addition) = onChange(JSONArray().apply { values.forEach { put(if (definition.items == "number") it.toDoubleOrNull() ?: it else it) } }.toString(2), nextAddition) + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + values.forEachIndexed { index, item -> Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { + Text(item, Modifier.weight(1f), maxLines = 2, overflow = TextOverflow.Ellipsis) + IconButton(enabled = enabled && index > 0, onClick = { values.add(index - 1, values.removeAt(index)); sync() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.KeyboardArrowUp, stringResource(R.string.settings_move_up)) } + IconButton(enabled = enabled && index < values.lastIndex, onClick = { values.add(index + 1, values.removeAt(index)); sync() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.KeyboardArrowDown, stringResource(R.string.settings_move_down)) } + IconButton(enabled = enabled, onClick = { values.removeAt(index); sync() }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Delete, stringResource(R.string.settings_remove)) } + } } + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField(addition, { onChange(value, it) }, enabled = enabled, modifier = Modifier.weight(1f), singleLine = true, label = { Text(stringResource(R.string.settings_add_item)) }, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { if (enabled && addition.isNotBlank()) { values.add(addition); sync("") } })) + IconButton(enabled = enabled && addition.isNotBlank(), onClick = { values.add(addition); sync("") }, modifier = Modifier.size(48.dp)) { Icon(Icons.Default.Add, stringResource(R.string.settings_add_item)) } + } + TextButton(enabled = enabled, modifier = Modifier.align(Alignment.End).heightIn(min = 48.dp), onClick = onSave) { Text(stringResource(R.string.settings_save)) } + } +} + +@Composable +private fun LocalPreferencesSection() { val context = LocalContext.current var theme by remember { mutableStateOf(AppPreferences.getTheme(context)) } var palette by remember { mutableStateOf(AppPreferences.getPalette(context)) } @@ -676,14 +532,26 @@ private fun LocalPreferencesSection( var chime by remember { mutableStateOf(AppPreferences.isSoundChime(context)) } var submit by remember { mutableStateOf(AppPreferences.getSubmitBehavior(context)) } var collapsed by remember { mutableStateOf(AppPreferences.isToolCallsCollapsed(context)) } - Text(groupAppearance, style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) - SettingEditor(SettingField("Theme", "General", "choice", listOf("system", "light", "dark")), theme, true) { theme = it.toString(); AppPreferences.setTheme(context, theme) } - SettingEditor(SettingField("Palette", "General", "choice", listOf("warm", "omp")), palette, true) { palette = it.toString(); AppPreferences.setPalette(context, palette) } - SettingEditor(SettingField("Language", "General", "choice", listOf("English", "한국어")), language, true) { language = it.toString(); AppPreferences.setLanguage(context, language) } - Text(groupInterface, modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) - SettingEditor(SettingField("Completion chime", "General"), chime, true) { chime = it == true; AppPreferences.setSoundChime(context, chime) } - SettingEditor(SettingField("Submit during run", "General", "choice", listOf("steer", "queue")), submit, true) { submit = it.toString(); AppPreferences.setSubmitBehavior(context, submit) } - SettingEditor(SettingField("Collapse tool calls", "General"), collapsed, true) { collapsed = it == true; AppPreferences.setToolCallsCollapsed(context, collapsed) } + var showThinking by remember { mutableStateOf(AppPreferences.isThinkingShown(context)) } + Text(stringResource(R.string.settings_group_appearance), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) + LocalChoice(LocalField("Theme", "enum", listOf("system", "light", "dark")), theme) { theme = it; AppPreferences.setTheme(context, it) } + LocalChoice(LocalField("Palette", "enum", listOf("warm", "omp")), palette) { palette = it; AppPreferences.setPalette(context, it) } + LocalChoice(LocalField("Language", "enum", listOf("English", "한국어")), language) { language = it; AppPreferences.setLanguage(context, it) } + Text(stringResource(R.string.settings_group_interface), style = MaterialTheme.typography.titleSmall, color = OmpColors.TextMuted) + LocalToggle(stringResource(R.string.settings_label_chime), chime) { chime = it; AppPreferences.setSoundChime(context, it) } + LocalChoice(LocalField("Submit", "enum", listOf("steer", "queue")), submit) { submit = it; AppPreferences.setSubmitBehavior(context, it) } + LocalToggle(stringResource(R.string.settings_label_collapse), collapsed) { collapsed = it; AppPreferences.setToolCallsCollapsed(context, it) } + LocalToggle(stringResource(R.string.settings_label_show_thinking), showThinking) { showThinking = it; AppPreferences.setThinkingShown(context, it) } +} + +@Composable private fun LocalToggle(label: String, value: Boolean, change: (Boolean) -> Unit) { Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { Text(label, Modifier.weight(1f)); Switch(value, change, modifier = Modifier.heightIn(min = 48.dp)) } } +@Composable private fun LocalChoice(field: LocalField, value: String, change: (String) -> Unit) { var open by remember { mutableStateOf(false) }; Box(Modifier.fillMaxWidth()) { OutlinedButton({ open = true }, Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text(field.path, Modifier.weight(1f)); Text(value); Icon(Icons.Default.ExpandMore, null) }; DropdownMenu(open, { open = false }) { field.choices.forEach { choice -> DropdownMenuItem({ Text(choice) }, { open = false; change(choice) }) } } } } + +@Composable +private fun ConnectionAndUsage(serverUrl: String, deviceId: String, connection: ConnectionState, showUsage: () -> Unit) { + Text(stringResource(R.string.settings_connection), style = MaterialTheme.typography.titleSmall) + listOf(stringResource(R.string.settings_relay) to serverUrl, stringResource(R.string.settings_device) to deviceId, stringResource(R.string.settings_status) to connectionStatusText(connection)).forEach { (label, value) -> Column { Text(label, style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted); SelectionContainer { Text(value) } } } + OutlinedButton(showUsage, Modifier.fillMaxWidth().heightIn(min = 48.dp)) { Text(stringResource(R.string.settings_usage)) } } @Composable @@ -695,59 +563,16 @@ private fun SystemControls(requester: RelayRequester, deviceId: String, onUnpair var result by remember { mutableStateOf(null) } var devices by remember { mutableStateOf(null) } var confirmation by remember { mutableStateOf Unit>?>(null) } - val request: (String, JSONObject) -> Unit = { action, args -> - scope.launch { - busy = true; error = null - try { - result = requester.request("system", action, args) - if (action == "devices.list") devices = result?.getJSONArray("devices") - if (action == "devices.revoke") { - if (args.optString("deviceId") == deviceId) onUnpair() - else devices = requester.request("system", "devices.list", JSONObject()).getJSONArray("devices") - } - } catch (failure: CancellationException) { throw failure - } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_request_failed) - } finally { busy = false } - } - } - Text(stringResource(R.string.settings_system_updates), modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall) + val request: (String, JSONObject) -> Unit = { action, args -> scope.launch { busy = true; error = null; try { result = requester.request("system", action, args); if (action == "devices.list") devices = result?.getJSONArray("devices"); if (action == "devices.revoke") { if (args.optString("deviceId") == deviceId) onUnpair() else devices = requester.request("system", "devices.list", JSONObject()).getJSONArray("devices") } } catch (cancelled: CancellationException) { throw cancelled } catch (failure: Exception) { error = failure.message ?: context.getString(R.string.settings_request_failed) } finally { busy = false } } } + Text(stringResource(R.string.settings_system_updates), style = MaterialTheme.typography.titleSmall) if (busy) LinearProgressIndicator(Modifier.fillMaxWidth()) error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - for ((label, action) in listOf( - stringResource(R.string.settings_omp_version) to "omp.check", - stringResource(R.string.settings_app_version) to "app.check", - stringResource(R.string.settings_paired_devices) to "devices.list", - )) { - Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium) - TextButton(modifier = Modifier.width(128.dp).heightIn(min = 48.dp), enabled = !busy, onClick = { request(action, JSONObject().apply { if (action == "app.check") put("force", true) }) }) { - Text(if (action == "devices.list") stringResource(R.string.settings_refresh_action) else stringResource(R.string.settings_check_version)) - } - } - } - result?.let { response -> - var expanded by remember(response) { mutableStateOf(true) } - TextButton(onClick = { expanded = !expanded }) { Text(if (expanded) stringResource(R.string.settings_hide_response) else stringResource(R.string.settings_show_response)) } - if (expanded) Surface(color = OmpColors.BgPanel, shape = MaterialTheme.shapes.small) { - SelectionContainer { Text(response.toString(2), Modifier.padding(12.dp), style = MaterialTheme.typography.bodySmall) } - } - } + listOf(stringResource(R.string.settings_omp_version) to "omp.check", stringResource(R.string.settings_app_version) to "app.check", stringResource(R.string.settings_paired_devices) to "devices.list").forEach { (label, action) -> Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { Text(label, Modifier.weight(1f)); TextButton(enabled = !busy, onClick = { request(action, JSONObject().apply { if (action == "app.check") put("force", true) }) }, modifier = Modifier.heightIn(min = 48.dp)) { Text(if (action == "devices.list") stringResource(R.string.settings_refresh_action) else stringResource(R.string.settings_check_version)) } } } + result?.let { response -> var expanded by remember(response) { mutableStateOf(false) }; TextButton({ expanded = !expanded }, Modifier.heightIn(min = 48.dp)) { Text(if (expanded) stringResource(R.string.settings_hide_response) else stringResource(R.string.settings_show_response)) }; if (expanded) SelectionContainer { Text(response.toString(2), style = MaterialTheme.typography.bodySmall) } } Text(stringResource(R.string.settings_updates_hint), style = MaterialTheme.typography.bodySmall) - devices?.let { list -> - Text(stringResource(R.string.settings_paired_devices), style = MaterialTheme.typography.titleSmall) - for (index in 0 until list.length()) { - val device = list.optJSONObject(index) ?: continue - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically) { - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text(device.optString("label"), style = MaterialTheme.typography.bodyMedium) - Text("${device.optString("id")}\n${context.getString(R.string.settings_last_seen, device.opt("lastSeenAt"))}", style = MaterialTheme.typography.bodySmall, color = OmpColors.TextMuted) - } - TextButton(modifier = Modifier.width(128.dp).heightIn(min = 48.dp), enabled = !busy, onClick = { confirmation = context.getString(R.string.settings_revoke_confirm, device.optString("label")) to { request("devices.revoke", JSONObject().put("deviceId", device.getString("id"))) } }) { Text(stringResource(R.string.settings_revoke_device)) } - } - } } + devices?.let { list -> for (index in 0 until list.length()) { val device = list.optJSONObject(index) ?: continue; Row(Modifier.fillMaxWidth().heightIn(min = 48.dp), verticalAlignment = Alignment.CenterVertically) { Column(Modifier.weight(1f)) { Text(device.optString("label")); Text(device.optString("id"), style = MaterialTheme.typography.bodySmall) }; TextButton(onClick = { confirmation = context.getString(R.string.settings_revoke_confirm, device.optString("label")) to { request("devices.revoke", JSONObject().put("deviceId", device.getString("id"))) } }) { Text(stringResource(R.string.settings_revoke_device)) } } } } HorizontalDivider(color = OmpColors.Border) - Text(stringResource(R.string.settings_session_actions), modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), style = MaterialTheme.typography.titleSmall) - OutlinedButton(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, enabled = !busy, onClick = { confirmation = context.getString(R.string.settings_restart_confirm) to { request("omp.restart", JSONObject().put("confirm", true)) } }) { Text(stringResource(R.string.settings_restart_omp)) } - OutlinedButton(modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.small, enabled = !busy, onClick = { confirmation = context.getString(R.string.settings_unpair_confirm) to onUnpair }) { Text(stringResource(R.string.settings_unpair_device)) } - confirmation?.let { pending -> AlertDialog(onDismissRequest = { confirmation = null }, title = { Text(stringResource(R.string.settings_confirm_title)) }, text = { OmpDialogSystemBars(); Text(pending.first) }, confirmButton = { TextButton(onClick = { confirmation = null; pending.second() }) { Text(stringResource(R.string.settings_confirm)) } }, dismissButton = { TextButton(onClick = { confirmation = null }) { Text(stringResource(R.string.settings_cancel)) } }) } + OutlinedButton(onClick = { confirmation = context.getString(R.string.settings_restart_confirm) to { request("omp.restart", JSONObject().put("confirm", true)) } }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), enabled = !busy) { Text(stringResource(R.string.settings_restart_omp)) } + OutlinedButton(onClick = { confirmation = context.getString(R.string.settings_unpair_confirm) to onUnpair }, modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), enabled = !busy) { Text(stringResource(R.string.settings_unpair_device)) } + confirmation?.let { pending -> AlertDialog({ confirmation = null }, { TextButton({ confirmation = null; pending.second() }) { Text(stringResource(R.string.settings_confirm)) } }, title = { Text(stringResource(R.string.settings_confirm_title)) }, text = { OmpDialogSystemBars(); Text(pending.first) }, dismissButton = { TextButton({ confirmation = null }) { Text(stringResource(R.string.settings_cancel)) } }) } } diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml index 09ef16293..e44f48d7a 100644 --- a/android/app/src/main/res/values-ja/strings.xml +++ b/android/app/src/main/res/values-ja/strings.xml @@ -29,6 +29,8 @@ 接続失敗: %1$s 無効なサーバー応答 キュー + キュー %1$d + メッセージキューを開く サーバー上の待機メッセージを管理します。呼び戻すとキューから削除され、入力欄で編集できます。 ネイティブキュー: %1$d件(読み取り専用) 処理中または未接続のためキュー操作はできません。 @@ -59,6 +61,8 @@ Mac のペアリングページのワンタイムリンクを貼り付けてください。Phone は Tailscale Funnel 経由で接続し、デバイストークンはこの Phone に残ります。 ペアリングリンク ワークスペースパスワード(設定時) + パスワードを表示 + パスワードを隠す 接続 接続中… この端末にペアリングを保存できませんでした。 @@ -70,6 +74,10 @@ Mac に接続中… 切断されました 待機 + %1$d秒後に再試行 + 再接続 + 再試行 + セッションを読み込み中 戻る 送信 停止 @@ -119,6 +127,9 @@ Session state unavailable Historical branch — read only. Fork a user entry in History to continue separately. Jump to latest + 以前のメッセージを読み込む + 以前のメッセージを読み込み中… + 以前のメッセージを取得できません サブエージェント %1$d %1$d 実行中 実行中 @@ -483,6 +494,14 @@ This removes the project server and its saved environment variables and headers. File changed during download. Waiting for refresh. Loading PDF… Next page + 行を折り返す + 全画面表示 + 全画面表示を閉じる + パスをコピー + 内容をコピー + パスをコピーしました + 内容をコピーしました + 読み込み済みの一部をコピーしました No inline preview for this file type. Use Open, Share or Save as. Page %1$d of %2$d Pause audio @@ -643,6 +662,48 @@ This removes the project server and its saved environment variables and headers. サブエージェント、モデルルーティング、prewalk、Advisor オプションツール、MCP サーバー、スキル、OMP プラグイン アプリ更新、ランタイムバージョン、接続、使用量 + 管理者 + 保護されたホストアクセス、認証情報、その他の高リスク設定 + グローバル + プロジェクト + ホスト設定: %1$s + プロジェクト: %1$s + 設定ファイル: %1$s + 読み込み中… + カタログ値は新しいセッションの有効な設定です。環境変数とコマンドラインの上書きが優先される場合があります。 + グローバル設定から継承 + スキーマの既定値 + 変更済みのみ + この表示に変更済みの設定はありません + 変更済み + プロジェクト + 継承 + 既定 + 問題 + 説明をすべて表示 + 説明を折りたたむ + 保存済み設定 %1$s: %2$s + グローバル専用です。編集するにはグローバルに切り替えてください。 + 継承に戻す + 設定済み(値は返されません) + 未設定 + 置換するシークレット + 消去 + 検証済み JSON + 項目を追加 + 削除 + 上へ移動 + 下へ移動 + この端末でモデルの思考を表示 + スコープを切り替えますか?このスコープの未保存の下書きは適用されず破棄されます。 + ホストから再読み込みしますか?未保存の下書きは保持され、適用されません。 + 設定を閉じますか?未保存の下書きは適用されず破棄されます。 + %1$s の変更を確認\nパス: %2$s\n変更後: %3$s + 設定済みシークレットを削除 + •••••••• に置換 + 以下のモデルレジストリはグローバルです。スコープ選択は上のカタログ上書きにのみ適用されます。 + 以下のプロバイダーアカウント、API キー、モデルはグローバルです。スコープ選択で保存場所は変わりません。 + この管理画面は表示されている独自のグローバル/プロジェクト保存先を使用します。カタログのスコープ選択は影響しません。 設定セクション カスタマイズするセクションを選んでください。 すべてのセクション @@ -874,4 +935,22 @@ This removes the project server and its saved environment variables and headers. 入力を編集 現在の入力と添付ファイルを置き換えるか、新しいセッションを開きます。自動送信はしません。 メッセージの画像をすべて復元できませんでした。入力は変更されていません。 + 開いているセッションはありません + 一覧からセッションを選択するとここに表示されます。 + デフォルト + 高速 + 思考 + ビジョン + アーキテクト + コミット + 極小 + メモリ + サブタスク + アドバイザー + 画像生成 + ウェブ検索 + 音声合成 + 音声入力 + 判定 + 種類別ロール diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index b9d3ee5f9..6555e39e8 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -29,6 +29,8 @@ 연결 실패: %1$s 잘못된 서버 응답 대기열 + 대기열 %1$d + 메시지 대기열 열기 서버의 대기 메시지를 관리합니다. 불러오면 대기열에서 삭제하고 작성창에서 편집합니다. 네이티브 대기열: %1$d개 (읽기 전용) 작업 중이거나 연결이 끊겨 대기열 작업을 사용할 수 없습니다. @@ -59,6 +61,8 @@ 맥 페어링 페이지의 일회용 링크를 붙여넣으세요. 폰은 Tailscale Funnel로 붙고, 기기 토큰은 이 폰에 남습니다. 페어링 링크 워크스페이스 비밀번호 (있는 경우) + 비밀번호 표시 + 비밀번호 숨기기 연결 연결 중… 이 폰에 페어링을 저장하지 못했습니다. @@ -70,6 +74,10 @@ 맥에 연결하는 중… 연결 끊김 대기 + %1$d초 후 재시도 + 다시 연결 + 재시도 + 세션 불러오는 중 뒤로 보내기 중단 @@ -156,7 +164,7 @@ 연결 사용량 및 용량 저장됨; 런타임이 새로고침되었습니다 - 저장됨; 새 세션부터 적용됩니다 + 저장됨; 새 세션부터 적용됩니다. 환경 변수와 명령줄 재정의가 우선할 수 있습니다. 설정 업데이트에 실패했습니다 설정을 불러오지 못했습니다 보안 변경 확인 @@ -186,6 +194,48 @@ 서브에이전트, 모델 라우팅, prewalk, Advisor 선택 도구, MCP 서버, 스킬, OMP 플러그인 앱 업데이트, 런타임 버전, 연결, 사용량 + 관리자 + 보호된 호스트 접근, 자격 증명 및 기타 고위험 설정 + 전역 + 프로젝트 + 호스트 설정: %1$s + 프로젝트: %1$s + 설정 파일: %1$s + 불러오는 중… + 카탈로그 값은 새 세션의 유효 설정이며 환경 변수와 명령줄 재정의가 우선할 수 있습니다. + 전역 설정에서 상속됨 + 스키마 기본값 + 변경된 항목만 + 이 화면에 변경된 설정이 없습니다 + 변경됨 + 프로젝트 + 상속됨 + 기본값 + 문제 + 전체 설명 보기 + 설명 접기 + 저장된 설정 %1$s: %2$s + 전역 전용입니다. 수정하려면 전역으로 전환하세요. + 상속으로 재설정 + 구성됨 (값은 반환되지 않음) + 구성되지 않음 + 대체 비밀값 + 지우기 + 검증된 JSON + 항목 추가 + 항목 제거 + 위로 이동 + 아래로 이동 + 이 기기에서 모델 Thinking 표시 + 범위를 전환할까요? 이 범위의 저장하지 않은 초안은 적용되지 않고 삭제됩니다. + 호스트에서 새로고침할까요? 저장하지 않은 초안은 보존되며 적용되지 않습니다. + 설정을 닫을까요? 저장하지 않은 초안은 적용되지 않고 삭제됩니다. + %1$s 변경 확인\n경로: %2$s\n제안 값: %3$s + 구성된 비밀값 삭제 + ••••••••로 교체 + 아래 모델 레지스트리는 전역입니다. 범위 선택기는 위의 카탈로그 재정의에만 적용됩니다. + 아래 프로바이더 계정, API 키 및 모델은 전역입니다. 범위 선택기로 위치가 바뀌지 않습니다. + 이 관리자는 자체 표시된 전역/프로젝트 위치를 사용하며 카탈로그 범위 선택기의 영향을 받지 않습니다. 설정 섹션 사용자 지정할 섹션을 선택하세요. 모든 섹션 @@ -289,6 +339,9 @@ 전송 중 / 수락 대기… 전송 취소 최신으로 이동 + 이전 메시지 불러오기 + 이전 메시지를 불러오는 중… + 이전 메시지를 불러올 수 없습니다 서브에이전트 %1$d %1$d 실행 중 실행 중 @@ -546,6 +599,14 @@ PDF 렌더링 실패 이전 페이지 다음 페이지 + 줄 바꿈 + 전체 화면 + 전체 화면 닫기 + 경로 복사 + 내용 복사 + 경로가 복사됨 + 내용이 복사됨 + 불러온 일부 내용이 복사됨 %1$d / %2$d페이지 PDF 로드 중… PDF %1$d페이지 @@ -874,4 +935,22 @@ 입력 내용 편집 현재 입력과 첨부 파일을 바꾸거나 새 세션을 엽니다. 자동으로 전송하지 않습니다. 메시지의 모든 이미지를 복원할 수 없습니다. 입력 내용은 변경되지 않았습니다. + 열린 세션 없음 + 목록에서 세션을 선택하면 여기에 표시됩니다. + 기본 + 빠름 + 사고 + 비전 + 설계자 + 커밋 + 초소형 + 메모리 + 하위 작업 + 어드바이저 + 이미지 생성 + 웹 검색 + 음성 합성 + 받아쓰기 + 판정 + 종류별 역할 diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 8bc565a53..ca96752c5 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -29,6 +29,8 @@ 连接失败:%1$s 服务器响应无效 队列 + 队列 %1$d + 打开消息队列 管理服务器上的待发送消息。取回后,消息将从队列移除并在输入框中编辑。 原生队列:%1$d 条(只读) 正在处理或连接已断开,队列操作暂不可用。 @@ -59,6 +61,8 @@ 粘贴 Mac 配对页上的一次性链接。手机通过 Tailscale Funnel 连接,设备令牌保存在此手机上。 配对链接 工作区密码(如已设置) + 显示密码 + 隐藏密码 连接 正在连接… 无法在此手机上保存配对。 @@ -70,6 +74,10 @@ 正在连接 Mac… 已断开 空闲 + %1$d 秒后重试 + 重新连接 + 重试 + 正在加载会话 返回 发送 停止 @@ -119,6 +127,9 @@ Session state unavailable Historical branch — read only. Fork a user entry in History to continue separately. Jump to latest + 加载更早的消息 + 正在加载更早的消息… + 无法加载更早的消息 子代理 %1$d %1$d 运行中 运行中 @@ -483,6 +494,14 @@ This removes the project server and its saved environment variables and headers. File changed during download. Waiting for refresh. Loading PDF… Next page + 自动换行 + 全屏 + 退出全屏 + 复制路径 + 复制内容 + 路径已复制 + 内容已复制 + 已复制已加载的部分内容 No inline preview for this file type. Use Open, Share or Save as. Page %1$d of %2$d Pause audio @@ -643,6 +662,48 @@ This removes the project server and its saved environment variables and headers. 子代理、模型路由、prewalk 与 Advisor 可选工具、MCP 服务器、技能与 OMP 插件 应用更新、运行时版本、连接与用量 + 管理员 + 受保护的主机访问、凭据及其他高风险设置 + 全局 + 项目 + 主机设置:%1$s + 项目:%1$s + 设置文件:%1$s + 加载中… + 目录值是新会话的有效设置;环境变量和命令行覆盖项可能优先。 + 继承自全局设置 + 架构默认值 + 仅已修改 + 此视图中没有已修改的设置 + 已修改 + 项目 + 继承 + 默认 + 问题 + 显示完整说明 + 收起说明 + 已存储设置 %1$s:%2$s + 仅限全局;请切换到“全局”后编辑。 + 重置为继承 + 已配置(绝不返回其值) + 未配置 + 替换密钥 + 清除 + 已验证的 JSON + 添加项目 + 移除 + 上移 + 下移 + 在此设备上显示模型思考 + 切换范围?此范围内未保存的草稿将不会应用并会被丢弃。 + 从主机刷新?未保存的草稿会保留,但不会应用。 + 关闭设置?未保存的草稿将不会应用并会被丢弃。 + 确认%1$s更改\n路径:%2$s\n拟议值:%3$s + 删除已配置的密钥 + 替换为 •••••••• + 下方模型注册表为全局设置。范围选择器仅适用于上方的目录覆盖项。 + 下方提供商账户、API 密钥和模型均为全局设置。范围选择器不会改变其位置。 + 此管理器使用其自身显示的全局/项目位置;目录范围选择器不会改变它。 设置分区 选择要自定义的分区。 全部分区 @@ -874,4 +935,22 @@ This removes the project server and its saved environment variables and headers. 编辑输入 此操作将替换当前输入和附件,或打开新会话。不会自动发送。 无法恢复消息中的所有图片。输入内容未被替换。 + 未打开会话 + 从列表中选择一个会话即可在此查看。 + 默认 + 快速 + 思考 + 视觉 + 架构师 + 提交 + 微型 + 记忆 + 子任务 + 顾问 + 图像生成 + 网页搜索 + 语音合成 + 听写 + 评判 + 类型角色 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2f1a7bcc1..8a74ea5e2 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -29,6 +29,8 @@ Connection failed: %1$s Invalid server response Queue + Queue %1$d + Open message queue Manage server-owned messages. Recall removes a message from the queue and opens it in the composer. Native queue: %1$d (read-only) Queue actions unavailable while busy or disconnected. @@ -59,6 +61,8 @@ Paste the one-time link from the Mac pairing page. The phone connects over Tailscale Funnel and keeps a device token on this phone. Pairing link Workspace password (if set) + Show password + Hide password Connect Connecting… Could not save pairing on this phone. @@ -70,6 +74,10 @@ Connecting to Mac… Disconnected Idle + Retrying in %1$ds + Reconnect + Retry + Loading sessions Back Send Stop @@ -156,7 +164,7 @@ Connection Usage & capacity Saved; runtime refreshed - Saved; applies to new sessions + Saved; applies to new sessions. Environment and command-line overrides may take precedence. Settings update failed Settings load failed Confirm security change @@ -186,6 +194,48 @@ Configured subagents, model routing, prewalk, and advisor Optional tools, MCP servers, managed skills, and OMP plugins App updates, runtime version, connection, and usage + Administrator + Protected host access, credentials, and other high-risk settings + Global + Project + Host settings: %1$s + Project: %1$s + Settings file: %1$s + loading… + Catalog values are effective settings for new sessions; environment and command-line overrides may take precedence. + Inherited from global settings + Schema default + Modified only + No modified settings in this view + modified + project + inherited + default + issue + Show full description + Collapse description + Stored setting %1$s: %2$s + Global only; switch to Global to edit. + Reset to inherit + Configured (value is never returned) + Not configured + Replacement secret + Clear + Validated JSON + Add item + Remove item + Move up + Move down + Show model thinking on this device + Switch scope? Unsaved drafts in this scope will be discarded without being applied. + Refresh from the host? Unsaved drafts will be preserved and not applied. + Close settings? Unsaved drafts will be discarded without being applied. + Confirm %1$s change\nPath: %2$s\nProposed: %3$s + Delete configured secret + Replace with •••••••• + The model registry below is global. The scope selector applies only to catalog overrides above. + Provider accounts, API keys, and models below are global. The scope selector does not relocate them. + This manager uses its own displayed global/project location; the catalog scope selector does not change it. Settings sections Choose a section to customize. All sections @@ -289,6 +339,9 @@ Sending / awaiting acceptance… Cancel send Jump to latest + Load earlier messages + Loading earlier messages… + Earlier messages unavailable Subagents %1$d %1$d live Running @@ -546,6 +599,14 @@ PDF rendering failed Previous page Next page + Wrap lines + Fullscreen + Close fullscreen + Copy path + Copy content + Path copied + Content copied + Loaded part of the content copied Page %1$d of %2$d Loading PDF… PDF page %1$d @@ -874,4 +935,22 @@ This removes the project server and its saved environment variables and headers. Edit input This action replaces the current input and attachments, or opens a new session. Nothing is sent automatically. Could not restore all message images. Your input has not been replaced. + No session open + Select a session from the list to view it here. + Default + Fast + Thinking + Vision + Architect + Commit + Tiny + Memory + Subtask + Advisor + Image generation + Web search + Speech + Dictation + Judge + Kind roles diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/net/RelayClientBranchTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/net/RelayClientBranchTest.kt new file mode 100644 index 000000000..59f56cdbf --- /dev/null +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/net/RelayClientBranchTest.kt @@ -0,0 +1,14 @@ +package com.dbchbin.ompgui.remote.net + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class RelayClientBranchTest { + @Test + fun requestedLeafWinsUntilServerSnapshotConfirmsIt() { + assertEquals("requested", resolvedBranchLeafId("requested", "persisted")) + assertEquals("persisted", resolvedBranchLeafId(null, "persisted")) + assertNull(resolvedBranchLeafId(null, null)) + } +} diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/ChatRequestsTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/ChatRequestsTest.kt index c78d0ef8d..681917d34 100644 --- a/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/ChatRequestsTest.kt +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/ChatRequestsTest.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.runBlocking import org.json.JSONArray import org.json.JSONObject import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test @@ -43,4 +45,39 @@ class ChatRequestsTest { val text = " original input\n".repeat(15_000) assertEquals(text, ChatRequests.messageText(JSONObject().put("content", text))) } + + @Test fun liveHistoryPagesAreBoundedOnlineAndVisibleOnlyBeforeOffset() { + val range = ChatRequests.previousLivePage(73)!! + assertEquals(23, range.first) + assertEquals(50, range.count()) + val args = ChatRequests.liveHistoryArgs("session", "leaf", range.first, range.count()) + assertTrue(args.getBoolean("online")) + assertEquals(50, args.getInt("limit")) + assertEquals(50, ChatRequests.historyArgs("session").getInt("limit")) + try { + ChatRequests.historyArgs("session", limit = 51) + fail("History requests must remain bounded to 50 entries") + } catch (_: IllegalArgumentException) { } + assertTrue(ChatRequests.shouldShowLiveHistoryLoader(1)) + assertFalse(ChatRequests.shouldShowLiveHistoryLoader(0)) + } + + @Test fun earlierMessagesUseEntryIdsForDeduplicationAndRejectStaleOwnership() { + val page = ChatRequests.parseHistoryPage(JSONObject() + .put("messages", JSONArray() + .put(JSONObject().put("role", "user").put("text", "first")) + .put(JSONObject().put("role", "assistant").put("text", "duplicate"))) + .put("entryIds", JSONArray().put("entry-1").put("entry-2")) + .put("offset", 0)) + val current = listOf(DisplayMessage(role = "assistant", text = "current", entryId = "entry-2")) + assertEquals( + listOf("entry-1", "entry-2"), + ChatRequests.prependEarlierMessages(current, ChatRequests.liveHistoryMessages(page)).map { it.entryId }, + ) + assertTrue(ChatRequests.isCurrentLiveHistoryRequest(4, 4, "session", "session", "leaf", "leaf", "leaf")) + assertFalse(ChatRequests.isCurrentLiveHistoryRequest(4, 5, "session", "session", "leaf", "leaf", "leaf")) + assertFalse(ChatRequests.isCurrentLiveHistoryRequest(4, 4, "session", "other", "leaf", "leaf", "leaf")) + assertFalse(ChatRequests.isCurrentLiveHistoryRequest(4, 4, "session", "session", "leaf", "other", "leaf")) + assertFalse(ChatRequests.isCurrentLiveHistoryRequest(4, 4, "session", "session", "leaf", "leaf", "other")) + } } diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayFramesTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayFramesTest.kt index 16ded131f..b84f55329 100644 --- a/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayFramesTest.kt +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayFramesTest.kt @@ -132,6 +132,81 @@ class RelayFramesTest { assertEquals(1, snapshot.messages.size) assertEquals("user", snapshot.messages[0].role) assertTrue(snapshot.agent.ready) + assertEquals(1, snapshot.transcript.total) + assertEquals(0, snapshot.transcript.offset) + assertEquals(1, snapshot.transcript.nextOffset) + assertFalse(snapshot.transcript.hasMore) + } + + @Test + fun snapshotPagingUsesBoundedMetadataAndSafeLegacyDefaults() { + val paged = parseServerFrame( + """{"op":"session.snapshot","id":"sess-1","total":120,"offset":70,"nextOffset":120,"hasMore":false,"messages":[{"role":"user","entryId":"entry-70","text":"oldest visible"}]}""", + ) as ServerFrame.Snapshot + assertEquals(120, paged.transcript.total) + assertEquals(70, paged.transcript.offset) + assertEquals(120, paged.transcript.nextOffset) + assertFalse(paged.transcript.hasMore) + + val malformed = parseTranscriptPageInfo( + JSONObject().put("total", -1).put("offset", -5).put("nextOffset", 999), + messageCount = 2, + ) + assertEquals(2, malformed.total) + assertEquals(0, malformed.offset) + assertEquals(2, malformed.nextOffset) + } + + @Test + fun fullBoundedSnapshotAndPreviousPageFormAContiguousTranscript() { + val snapshotMessages = JSONArray() + for (index in 20 until 120) { + snapshotMessages.put( + JSONObject().put("role", "user").put("entryId", "entry-$index").put("text", "message $index"), + ) + } + val snapshot = parseServerFrame( + JSONObject().put("op", "session.snapshot").put("id", "session") + .put("messages", snapshotMessages).put("total", 120).put("offset", 20) + .put("nextOffset", 120).put("hasMore", false).toString(), + ) as ServerFrame.Snapshot + assertEquals(100, snapshot.messages.size) + assertEquals("entry-20", snapshot.messages.first().entryId) + assertEquals(0 until 20, ChatRequests.previousLivePage(snapshot.transcript.offset)) + + val earlierMessages = JSONArray() + val earlierIds = JSONArray() + for (index in 0 until 20) { + earlierMessages.put(JSONObject().put("role", "user").put("text", "message $index")) + earlierIds.put("entry-$index") + } + val earlier = ChatRequests.liveHistoryMessages( + ChatRequests.parseHistoryPage( + JSONObject().put("messages", earlierMessages).put("entryIds", earlierIds) + .put("total", 120).put("offset", 0).put("limit", 20), + ), + ) + assertEquals( + (0 until 120).map { "entry-$it" }, + ChatRequests.prependEarlierMessages(snapshot.messages, earlier).map { it.entryId }, + ) + + val legacy = parseServerFrame( + JSONObject().put("op", "session.snapshot").put("id", "legacy") + .put("messages", snapshotMessages).toString(), + ) as ServerFrame.Snapshot + assertEquals(100, legacy.messages.size) + assertEquals(100, legacy.transcript.total) + assertEquals(0, legacy.transcript.offset) + assertEquals(100, legacy.transcript.nextOffset) + + snapshotMessages.put(JSONObject().put("role", "user").put("entryId", "entry-120").put("text", "too many")) + assertNull( + parseServerFrame( + JSONObject().put("op", "session.snapshot").put("id", "session") + .put("messages", snapshotMessages).toString(), + ), + ) } @Test diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayUiErrorsTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayUiErrorsTest.kt index c952ebe8f..442d9aaff 100644 --- a/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayUiErrorsTest.kt +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/relay/RelayUiErrorsTest.kt @@ -26,4 +26,16 @@ class RelayUiErrorsTest { RelayUiErrors.clearKindsOnLeaveChat(), ) } + + @Test + fun healthySnapshotClearsStaleSessionRequestsButBackgroundEnrichmentCannotOwnBanner() { + assertEquals( + setOf(RelayUiErrorKind.Request, RelayUiErrorKind.Session), + RelayUiErrors.clearKindsOnHealthySnapshot(), + ) + assertFalse(RelayUiErrors.shouldSurfaceCommandFailure("get_state")) + assertFalse(RelayUiErrors.shouldSurfaceCommandFailure("get_subagents")) + assertTrue(RelayUiErrors.shouldSurfaceCommandFailure("set_model")) + assertTrue(RelayUiErrors.shouldSurfaceCommandFailure("abort")) + } } diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/ChatScreenTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/ChatScreenTest.kt new file mode 100644 index 000000000..1c36bd310 --- /dev/null +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/ChatScreenTest.kt @@ -0,0 +1,36 @@ +package com.dbchbin.ompgui.remote.ui + +import com.dbchbin.ompgui.remote.relay.DisplayMessage +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatScreenTest { + @Test + fun turnKeyUsesStableEntryIdentityAcrossPrepend() { + val turn = listOf( + DisplayMessage(role = "assistant", text = "working", entryId = "entry-41"), + DisplayMessage(role = "assistant", text = "done", entryId = "entry-42"), + ) + + assertEquals("entry:entry-42", transcriptTurnKey(turn)) + assertEquals("entry:entry-42", transcriptTurnKey((listOf( + DisplayMessage(role = "user", text = "earlier", entryId = "entry-1"), + ) + turn).takeLast(turn.size))) + } + + @Test + fun loadedPageBoundaryKeepsConsecutiveAssistantTurnsSeparate() { + val messages = listOf( + DisplayMessage(role = "assistant", text = "earlier 1", entryId = "entry-1"), + DisplayMessage(role = "assistant", text = "earlier 2", entryId = "entry-2"), + DisplayMessage(role = "assistant", text = "visible 1", entryId = "entry-3"), + DisplayMessage(role = "assistant", text = "visible 2", entryId = "entry-4"), + ) + + val turns = groupTranscriptMessages(messages, emptySet(), setOf("entry-3")) + + assertEquals(listOf(listOf("entry-1", "entry-2"), listOf("entry-3", "entry-4")), + turns.map { turn -> turn.map(DisplayMessage::entryId) }) + assertEquals("entry:entry-4", transcriptTurnKey(turns.last())) + } +} diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanelTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanelTest.kt index e577bb0ae..db72d763b 100644 --- a/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanelTest.kt +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/ModelSettingsPanelTest.kt @@ -82,6 +82,36 @@ class ModelSettingsPanelTest { assertFalse(catalog.unavailable) } + @Test + fun kindRolesOnlyOfferModelsOmpAccepts() { + val data = JSONObject() + .put( + "models", + JSONArray() + .put(JSONObject().put("provider", "p").put("id", "chat")) + .put(JSONObject().put("provider", "p").put("id", "web-chat").put("webSearch", "gemini")), + ) + .put( + "kindModels", + JSONArray() + .put(JSONObject().put("provider", "p").put("id", "tiny").put("kind", "tiny")) + .put(JSONObject().put("provider", "p").put("id", "img").put("kind", "image")) + .put(JSONObject().put("provider", "p").put("id", "tts").put("kind", "tts")) + .put(JSONObject().put("provider", "p").put("id", "search").put("kind", "search")), + ) + val catalog = parseModelCatalog(data) + assertEquals(listOf("chat", "web-chat"), catalog.models.map { it.id }) + val models = catalog.models + catalog.kindModels + fun accepted(role: String) = models.filter { roleAcceptsModel(role, it) }.map { it.id } + assertEquals(listOf("chat", "web-chat"), accepted("default")) + assertEquals(listOf("chat", "web-chat", "tiny"), accepted("memory")) + assertEquals(listOf("img"), accepted("image")) + assertEquals(listOf("web-chat", "search"), accepted("web")) + assertEquals(listOf("tts"), accepted("speech")) + assertEquals(emptyList(), accepted("dictation")) + assertEquals(listOf("chat", "web-chat", "tiny"), accepted("judge")) + } + @Test fun parsesRolesSkippingBlankSelectors() { val data = JSONObject().put( diff --git a/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/NativeSettingsTest.kt b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/NativeSettingsTest.kt new file mode 100644 index 000000000..d0b74c0ff --- /dev/null +++ b/android/app/src/test/java/com/dbchbin/ompgui/remote/ui/NativeSettingsTest.kt @@ -0,0 +1,108 @@ +package com.dbchbin.ompgui.remote.ui + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NativeSettingsTest { + @Test + fun parsesServerCatalogAndKeepsSecretPresenceOnly() { + val response = JSONObject() + .put("path", "/tmp/home/.omp/settings.yml") + .put("scope", "project") + .put("cwd", "/tmp/project") + .put("settings", JSONObject().put("retry", JSONObject().put("maxRetries", 4))) + .put("effectiveSettings", JSONObject().put("retry", JSONObject().put("maxRetries", 4))) + .put("secretStatus", JSONObject().put("auth.broker.token", true)) + .put("issues", JSONArray().put(JSONObject().put("path", "retry.maxRetries").put("message", "Stored value was omitted"))) + .put("catalog", JSONArray() + .put(definition("retry.maxRetries", "number").put("integer", true).put("min", 0).put("max", 20)) + .put(definition("auth.broker.token", "string").put("secret", true).put("admin", true).put("scope", "global"))) + + val snapshot = parseNativeSettingsSnapshot(response) + + assertEquals("project", snapshot.scope) + assertEquals(4, nestedSetting(snapshot.settings, "retry.maxRetries")) + assertTrue(snapshot.secretStatus.getValue("auth.broker.token")) + assertNull(nestedSetting(snapshot.settings, "auth.broker.token")) + assertTrue(snapshot.catalog.last().secret) + assertTrue(snapshot.catalog.last().admin) + assertEquals("global", snapshot.catalog.last().scope) + assertEquals("retry.maxRetries", snapshot.issues.single().path) + assertEquals("Stored value was omitted", snapshot.issues.single().message) + } + + @Test + fun ignoresMalformedCatalogRowsButRejectsMissingCatalog() { + val valid = definition("memory.backend", "enum").put("choices", JSONArray(listOf("off", "mnemopi"))) + val response = JSONObject() + .put("path", "/tmp/settings.yml") + .put("scope", "global") + .put("settings", JSONObject()) + .put("effectiveSettings", JSONObject()) + .put("catalog", JSONArray().put(JSONObject().put("path", "bad")).put(valid)) + + val snapshot = parseNativeSettingsSnapshot(response) + assertEquals(listOf("memory.backend"), snapshot.catalog.map { it.path }) + assertEquals(listOf("off", "mnemopi"), snapshot.catalog.single().choices) + + response.remove("catalog") + assertTrue(runCatching { parseNativeSettingsSnapshot(response) }.isFailure) + } + + @Test + fun relatesPersistedIssuesToCatalogParentsAndChildrenOnly() { + val issues = listOf( + NativeSettingIssue("modelRoles.reviewer", "Invalid role"), + NativeSettingIssue("providers", "Invalid provider map"), + ) + + assertEquals("modelRoles.reviewer", settingIssueForPath(issues, "modelRoles")?.path) + assertEquals("providers", settingIssueForPath(issues, "providers.openai")?.path) + assertNull(settingIssueForPath(issues, "retry.maxRetries")) + } + + @Test + fun createsMinimalNestedPatchWithoutSiblingSettings() { + val patch = nestedSettingsPatch("retry.maxRetries", 7) + assertEquals(7, patch.getJSONObject("retry").getInt("maxRetries")) + assertEquals(1, patch.length()) + assertEquals(1, patch.getJSONObject("retry").length()) + assertFalse(patch.has("settings")) + } + + @Test + fun validatesNumericBoundsAndArrayItemTypes() { + val number = parsedDefinition(definition("retry.maxRetries", "number").put("integer", true).put("min", 0).put("max", 20)) + assertEquals(12L, parseSettingDraft(number, "12")) + assertTrue(runCatching { parseSettingDraft(number, "12.5") }.isFailure) + assertTrue(runCatching { parseSettingDraft(number, "21") }.isFailure) + + val array = parsedDefinition(definition("task.disabledAgents", "array").put("items", "string")) + assertEquals(2, (parseSettingDraft(array, "[\"one\",\"two\"]") as JSONArray).length()) + assertTrue(runCatching { parseSettingDraft(array, "[1]") }.isFailure) + } + + private fun parsedDefinition(value: JSONObject): NativeSettingDefinition { + val response = JSONObject() + .put("path", "/tmp/settings.yml") + .put("scope", "global") + .put("settings", JSONObject()) + .put("effectiveSettings", JSONObject()) + .put("catalog", JSONArray().put(value)) + return parseNativeSettingsSnapshot(response).catalog.single() + } + + private fun definition(path: String, kind: String) = JSONObject() + .put("path", path) + .put("category", if (path.startsWith("retry") || path.startsWith("memory")) "intelligence" else if (path.startsWith("task")) "agents" else "system") + .put("group", path.substringBefore('.')) + .put("label", JSONObject().put("en", path).put("ko", path)) + .put("description", JSONObject().put("en", "Description").put("ko", "설명")) + .put("kind", kind) + .put("scope", "both") +} diff --git a/app/api/auth/login/[provider]/route.ts b/app/api/auth/login/[provider]/route.ts index 052eaedab..b873d6179 100644 --- a/app/api/auth/login/[provider]/route.ts +++ b/app/api/auth/login/[provider]/route.ts @@ -168,7 +168,7 @@ export async function GET( const ready = await child.waitReady(READY_TIMEOUT_MS); await child.negotiateProtocol(ready); await child.sendCommand({ type: "login", providerId: provider }, LOGIN_TIMEOUT_MS); - enableProvider(provider); + await enableProvider(provider); invalidateModelsCache(); disposeUtilityRpc(); send({ type: "success" }); diff --git a/app/api/model-roles/route.ts b/app/api/model-roles/route.ts index aca4af4ad..5239ffb25 100644 --- a/app/api/model-roles/route.ts +++ b/app/api/model-roles/route.ts @@ -21,7 +21,7 @@ export async function PUT(request: Request) { const roles = Object.fromEntries(Object.entries(body.roles).filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[0].trim().length > 0 && entry[1].trim().length > 0, )); - writeModelRoles(roles); + await writeModelRoles(roles); invalidateModelsCache(); return NextResponse.json({ success: true, roles }); } catch (error) { diff --git a/app/api/models/route.ts b/app/api/models/route.ts index 8f3e66d45..b37b57336 100644 --- a/app/api/models/route.ts +++ b/app/api/models/route.ts @@ -2,7 +2,7 @@ import { statSync } from "fs"; import { invalidateModelsCache, loadModelsWithCache, withSafeModelLoadFailure, type ModelsData } from "@/lib/models-cache"; import { disposeUtilityRpc, runUtilityCommand, type OmpModel } from "@/lib/omp/rpc-utility"; import { getModelsConfigPath } from "@/lib/omp/paths"; -import { readDisabledProviders } from "@/lib/omp/model-roles"; +import { listKindModels, readDisabledProviders } from "@/lib/omp/model-roles"; export const dynamic = "force-dynamic"; @@ -61,6 +61,8 @@ function supportsFastMode(model: OmpModel): boolean { } async function loadModels(): Promise { + // RPC lists chat models only; kind roles need the CLI's all-kind catalog. + const kindModelsPromise = listKindModels(); const availableResponse = await runUtilityCommand<{ models?: unknown }>( { type: "get_available_models" }, 120_000, @@ -87,6 +89,8 @@ async function loadModels(): Promise { provider: m.provider, thinkingLevels: thinkingLevelsFor(m), supportsFastMode: supportsFastMode(m), + ...(typeof m.kind === "string" ? { kind: m.kind } : {}), + ...(typeof m.webSearch === "string" ? { webSearch: m.webSearch } : {}), ...(typeof m.contextWindow === "number" && Number.isFinite(m.contextWindow) && m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}), @@ -131,7 +135,7 @@ async function loadModels(): Promise { // Default model is cosmetic — the models list is still useful without it. } - return { models: Object.fromEntries(nameMap), modelList, defaultModel, thinkingLevels, connectedProviders }; + return { models: Object.fromEntries(nameMap), modelList, kindModels: await kindModelsPromise, defaultModel, thinkingLevels, connectedProviders }; } const EMPTY_MODELS: ModelsData = { diff --git a/app/api/omp-settings/route.ts b/app/api/omp-settings/route.ts index c11552566..3fb326abe 100644 --- a/app/api/omp-settings/route.ts +++ b/app/api/omp-settings/route.ts @@ -1,61 +1,28 @@ import { NextResponse } from "next/server"; -import { invalidateModelsCache } from "@/lib/models-cache"; -import { disposeUtilityRpc, runUtilityCommand, type OmpModel } from "@/lib/omp/rpc-utility"; -import { mergeNativeSettings, readNativeSettings, writeNativeSettings, type NativeSettings } from "@/lib/omp/settings-config"; -import { assertNoAmbiguousModelScopes } from "@/lib/model-scope"; +import { parseJsonWithinLimit, RequestBodyTooLargeError } from "@/lib/bounded-form-data"; +import { applyNativeSettings, getNativeSettings, nativeSettingsErrorPayload } from "@/lib/omp/settings-service"; export const dynamic = "force-dynamic"; +const MAX_SETTINGS_REQUEST_BYTES = 256 * 1024; -export function GET() { +export async function GET(request: Request) { try { - return NextResponse.json(readNativeSettings()); + const url = new URL(request.url); + const scope = url.searchParams.get("scope") ?? undefined; + const cwd = url.searchParams.get("cwd") ?? undefined; + return NextResponse.json(await getNativeSettings({ ...(scope ? { scope } : {}), ...(cwd ? { cwd } : {}) })); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 400 }); + return NextResponse.json(nativeSettingsErrorPayload(error), { status: 400 }); } } export async function PUT(request: Request) { try { - const body = await request.json() as { settings?: NativeSettings } | null; - if (!body || !body.settings || typeof body.settings !== "object" || Array.isArray(body.settings)) { - return NextResponse.json({ error: "settings must be an object" }, { status: 400 }); - } - if (body.settings.enabledModels !== undefined) { - // The utility process is best-effort here: settings remain editable when - // omp is unavailable, but an available catalog rejects ambiguous bare IDs. - try { - const response = await runUtilityCommand<{ models?: unknown }>({ type: "get_available_models" }, 120_000); - if (Array.isArray(response.models)) { - const models = response.models.filter((model): model is OmpModel => ( - typeof model === "object" && model !== null - && typeof (model as OmpModel).id === "string" - && typeof (model as OmpModel).provider === "string" - )); - assertNoAmbiguousModelScopes(body.settings.enabledModels, models); - } - } catch (error) { - if (error instanceof Error && error.message.startsWith("Ambiguous enabledModels entry")) throw error; - } - } - const current = readNativeSettings(); - const next = mergeNativeSettings(current.settings, body.settings); - writeNativeSettings(next); - const registryInvalidated = body.settings.enabledModels !== undefined - || body.settings.disabledProviders !== undefined - || body.settings.modelProviderOrder !== undefined; - if (registryInvalidated) { - invalidateModelsCache(); - disposeUtilityRpc(); - } - return NextResponse.json({ - success: true, - settings: readNativeSettings().settings, - application: { - mode: registryInvalidated ? "runtime-refresh" : "new-session", - restartRequired: false, - }, - }); + const body = await parseJsonWithinLimit(request, MAX_SETTINGS_REQUEST_BYTES); + return NextResponse.json(await applyNativeSettings(body)); } catch (error) { - return NextResponse.json({ error: String(error) }, { status: 400 }); + return NextResponse.json(nativeSettingsErrorPayload(error), { + status: error instanceof RequestBodyTooLargeError ? 413 : 400, + }); } } diff --git a/app/api/providers/enable/route.ts b/app/api/providers/enable/route.ts index 03cf53913..d7c5c1ca6 100644 --- a/app/api/providers/enable/route.ts +++ b/app/api/providers/enable/route.ts @@ -9,7 +9,7 @@ export async function POST(request: Request) { if (typeof body.provider !== "string" || !body.provider.trim()) { return NextResponse.json({ error: "provider is required" }, { status: 400 }); } - enableProvider(body.provider); + await enableProvider(body.provider); invalidateModelsCache(); disposeUtilityRpc(); return NextResponse.json({ success: true }); diff --git a/components/AgentsConfig.tsx b/components/AgentsConfig.tsx index 2d9a7d6a0..aa9e69d9e 100644 --- a/components/AgentsConfig.tsx +++ b/components/AgentsConfig.tsx @@ -14,6 +14,28 @@ type Props = { }; type ScopeFilter = "all" | AgentSource; +type AgentOverrideType = "model" | "prewalk" | "advisor"; + +function canonicalSelector(value: unknown): string | undefined { + if (value === true) return "on"; + if (value === false) return "off"; + return typeof value === "string" ? value : undefined; +} + +function selectorEnabled(value: unknown): boolean { + const selector = canonicalSelector(value); + return selector !== undefined && selector !== "off"; +} + +function canonicalSelectorMap(value: unknown): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; + const result: Record = {}; + for (const [name, selector] of Object.entries(value)) { + const canonical = canonicalSelector(selector); + if (canonical !== undefined) result[name] = canonical; + } + return result; +} export function AgentsConfig({ cwd, onSaved }: Props) { const { t } = useI18n(); @@ -123,8 +145,8 @@ export function AgentsConfig({ cwd, onSaved }: Props) { const updateAgentOverride = async ( agentName: string, - overrideType: "model" | "prewalk" | "advisor", - value: string | boolean | undefined, + overrideType: AgentOverrideType, + value: string | undefined, ) => { try { const settingsRes = await fetch("/api/omp-settings"); @@ -141,7 +163,7 @@ export function AgentsConfig({ cwd, onSaved }: Props) { } task.agentModelOverrides = overrides; } else if (overrideType === "prewalk") { - const prewalks = { ...(task.agentPrewalk || {}) }; + const prewalks = canonicalSelectorMap(task.agentPrewalk); if (value === undefined) { delete prewalks[agentName]; } else { @@ -149,7 +171,7 @@ export function AgentsConfig({ cwd, onSaved }: Props) { } task.agentPrewalk = prewalks; } else if (overrideType === "advisor") { - const advisors = { ...(task.agentAdvisor || {}) }; + const advisors = canonicalSelectorMap(task.agentAdvisor); if (value === undefined) { delete advisors[agentName]; } else { @@ -412,10 +434,12 @@ function AgentCard({ onToggleDisabled: (disabled: boolean) => void; onEdit: () => void; onDelete: () => void; - onUpdateOverride: (type: "model" | "prewalk" | "advisor", val: string | boolean | undefined) => void; + onUpdateOverride: (type: AgentOverrideType, val: string | undefined) => void; }) { const { t } = useI18n(); const isCustom = agent.source === "user" || agent.source === "project"; + const advisorEnabled = selectorEnabled(agent.advisorOverride); + const prewalkEnabled = selectorEnabled(agent.prewalkOverride); const isReadOnly = agent.tools?.every((tool) => tool === "read" || tool === "grep" || tool === "glob" || tool.startsWith("web_")); const scopeBadgeColor = @@ -532,33 +556,35 @@ function AgentCard({
diff --git a/components/AppShell.tsx b/components/AppShell.tsx index fdf924dce..e7d8e4258 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -1877,7 +1877,7 @@ export function AppShell() { >