From ce278157fc6cf535a2c904cb8e6944b693a7e286 Mon Sep 17 00:00:00 2001 From: xianhongtao Date: Fri, 16 Oct 2026 10:54:37 +0900 Subject: [PATCH 01/12] fix(gui): allow hidden models in Combo selectors (carry #7195) Carried from #7195. Co-authored-by: xianhongtao --- .../src/content/docs/fr/guides/combos.md | 2 +- docs-site/src/content/docs/guides/combos.md | 5 +- .../src/content/docs/ja/guides/combos.md | 2 +- .../src/content/docs/ko/guides/combos.md | 2 +- .../src/content/docs/ru/guides/combos.md | 2 +- .../src/content/docs/tr/guides/combos.md | 5 +- .../src/content/docs/zh-cn/guides/combos.md | 2 +- .../src/content/docs/zh-tw/guides/combos.md | 2 +- gui/src/pages/Combos.tsx | 4 +- .../combo-capability-hidden-targets.test.tsx | 69 +++++++++++++++++-- gui/tests/page-loading-contract.test.tsx | 2 +- structure/dashboard-and-usage.md | 12 ++-- 12 files changed, 85 insertions(+), 24 deletions(-) diff --git a/docs-site/src/content/docs/fr/guides/combos.md b/docs-site/src/content/docs/fr/guides/combos.md index c35725b8c34..7076e7df862 100644 --- a/docs-site/src/content/docs/fr/guides/combos.md +++ b/docs-site/src/content/docs/fr/guides/combos.md @@ -277,7 +277,7 @@ flux de travail des tâches. ### Tableau de bord Ouvrez le tableau de bord local et choisissez **Modèles → Combos**. L'espace de travail crée, modifie, renomme et supprime -combos, et son sélecteur de cible exclut les modèles désactivés et les combos imbriqués. +les combos. Les sélecteurs de modèles incluent les modèles masqués : la visibilité ne concerne que les listes externes, pas la sélection interne des combos. Sélectionner un modèle masqué ne le rend pas visible. Le sélecteur de cible exclut toujours les fournisseurs désactivés, les combos imbriqués et les fournisseurs JEV réservés au service de décision. Chaque cible affiche aussi un badge de quota en direct : **Disponible**, **Quota épuisé** ou **Quota inconnu**. L’éditeur bloque Enregistrer et Créer pour une raison de quota uniquement lorsque chaque cible utilisable dispose d’une confirmation serveur encore valide indiquant que la limite d’inférence liée à ses identifiants configurés est épuisée. Les quotas de compte, de modèle, de recherche et de MCP fournis uniquement à titre d’affichage, ainsi que les informations de routage absentes ou expirées, ne déclenchent pas ce blocage. Le blocage expire à la réinitialisation applicable ou à l’expiration de la validité des données et fait l’objet d’une nouvelle vérification lorsque la page devient active ou visible ; Actualiser recharge à la fois les données des combos et les quotas. diff --git a/docs-site/src/content/docs/guides/combos.md b/docs-site/src/content/docs/guides/combos.md index 942feb374b4..b122b8279bb 100644 --- a/docs-site/src/content/docs/guides/combos.md +++ b/docs-site/src/content/docs/guides/combos.md @@ -769,8 +769,9 @@ task workflow. ### Dashboard Open the local dashboard and choose **Models → Combos**. The workspace creates, edits, renames, and removes -combos, and its target picker excludes disabled models, nested combos, and the credential-only JEV -provider. **Create JEV Auto** opens the same Combo editor with an editable decision target template; +combos. Its model selectors include hidden models: catalog visibility only controls external model +lists, not internal Combo selection, and selecting a hidden model does not make it visible externally. +The target picker still excludes disabled providers, nested combos, and the credential-only JEV provider. **Create JEV Auto** opens the same Combo editor with an editable decision target template; an existing `jev-auto` id or alias is reported instead of creating a duplicate. A JEV Combo also shows **Decision service** (TypeSafe JEV or a configured `jev-decision` provider) and **Decision timeout (ms)**, and the Combos overview lists each JEV Combo's decision service, endpoint, and timeout. diff --git a/docs-site/src/content/docs/ja/guides/combos.md b/docs-site/src/content/docs/ja/guides/combos.md index 33f2b628fd0..7ed25d4cb27 100644 --- a/docs-site/src/content/docs/ja/guides/combos.md +++ b/docs-site/src/content/docs/ja/guides/combos.md @@ -184,7 +184,7 @@ v1/base/v2 モードと完全な暗号化タスクのワークフローについ ### ダッシュボード -ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースはコンボを作成、編集、名前変更、削除し、そのターゲット ピッカーは無効なモデルとネストされたコンボを除外します。 +ローカル ダッシュボードを開き、**Models → コンボ**を選択します。ワークスペースではコンボの作成、編集、名前変更、削除ができます。モデル選択には非表示モデルも含まれます。表示設定は外部のモデル一覧にのみ適用され、コンボ内部の選択には影響しません。非表示モデルを選択しても外部には表示されません。ターゲット選択では、無効なプロバイダー、ネストされたコンボ、決定サービス専用の JEV プロバイダーは引き続き除外されます。 各ターゲットには **利用可能**、**クォータを使い切りました**、**クォータ不明** のライブバッジも表示されます。 エディターがクォータを理由に保存と作成をブロックするのは、使用可能なすべてのターゲットについて、設定された認証情報の推論上限に達したことを示す、サーバーによる確認が現在も有効な場合だけです。表示専用のアカウント・モデル・検索・MCP クォータや、ルーティングの根拠情報の欠落・期限切れによって、このブロックが発生することはありません。ブロックは該当するリセット時刻またはデータの有効期限に解除され、ページがアクティブになるか表示状態になると再確認されます。「更新」はコンボデータとクォータの両方を再読み込みします。ダッシュボードのエディターではまだ `cooldownMs` と `waitForCooldownMs` を設定できません。後続の UI 作業が完了するまでは、構成ファイルまたは管理 API を使用してください。 diff --git a/docs-site/src/content/docs/ko/guides/combos.md b/docs-site/src/content/docs/ko/guides/combos.md index 92be44fa008..a9a69b76b5a 100644 --- a/docs-site/src/content/docs/ko/guides/combos.md +++ b/docs-site/src/content/docs/ko/guides/combos.md @@ -194,7 +194,7 @@ v1/base/v2 모드와 암호화된 작업의 전체 흐름은 [Sub-agent Surface] ### 대시보드 -로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스는 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있으며, 대상 선택기에서는 비활성 모델과 중첩 콤보를 제외합니다. +로컬 대시보드를 열고 **Models → Combos**를 선택합니다. 워크스페이스에서 콤보를 만들고, 편집하고, 이름을 바꾸고, 제거할 수 있습니다. 모델 선택기에는 숨겨진 모델도 포함됩니다. 표시 설정은 외부 모델 목록에만 적용되며 콤보 내부 선택에는 영향을 주지 않습니다. 숨겨진 모델을 선택해도 외부에 표시되지 않습니다. 대상 선택기에서는 비활성 공급자, 중첩 콤보, 결정 서비스 전용 JEV 공급자를 계속 제외합니다. 각 대상에는 **사용 가능**, **할당량 소진**, **할당량 알 수 없음** 실시간 배지도 표시됩니다. 편집기는 사용 가능한 모든 대상에 대해 설정된 인증 정보의 추론 한도가 소진되었다는 서버 확인이 현재 유효할 때만 할당량을 이유로 저장과 만들기를 차단합니다. 표시 전용 계정·모델·검색·MCP 할당량이나 누락되거나 만료된 라우팅 근거 정보는 이 차단을 일으키지 않습니다. 차단은 해당 한도의 초기화 시점이나 데이터 유효기간이 끝나면 해제되며 페이지가 활성화되거나 표시될 때 다시 확인됩니다. 새로 고침은 콤보 데이터와 할당량을 모두 다시 불러옵니다. 대시보드 편집기에서는 아직 `cooldownMs`나 `waitForCooldownMs`를 설정할 수 없습니다. 후속 UI 작업이 완료될 때까지 구성 파일이나 관리 API를 사용하세요. diff --git a/docs-site/src/content/docs/ru/guides/combos.md b/docs-site/src/content/docs/ru/guides/combos.md index b6f139674c7..29c59a402b4 100644 --- a/docs-site/src/content/docs/ru/guides/combos.md +++ b/docs-site/src/content/docs/ru/guides/combos.md @@ -234,7 +234,7 @@ Failover намеренно ограничен. Он помогает при п ### Дашборд Откройте локальный дашборд и выберите **Models → Combos**. Рабочая область умеет создавать, редактировать, -переименовывать и удалять combo, а селектор целей исключает отключённые модели и вложенные combo. +переименовывать и удалять combo. Селекторы моделей включают скрытые модели: видимость влияет только на внешние списки, а не на внутренний выбор в combo. Выбор скрытой модели не делает её видимой снаружи. Селектор целей по-прежнему исключает отключённых провайдеров, вложенные combo и провайдеров JEV, предназначенных только для сервиса принятия решений. У каждой цели также отображается актуальный значок квоты: **Доступно**, **Квота исчерпана** или **Квота неизвестна**. Редактор блокирует сохранение и создание из-за квоты только тогда, когда для каждой пригодной к использованию цели есть действующее подтверждение сервера об исчерпании лимита инференса для настроенных учётных данных. Квоты аккаунта, модели, поиска и MCP, предназначенные только для отображения, а также отсутствующие или просроченные данные для принятия решения о маршрутизации не вызывают эту блокировку. Блокировка истекает при соответствующем сбросе квоты или окончании срока актуальности данных и проверяется повторно, когда страница становится активной или видимой; «Обновить» повторно загружает и данные combo, и квоты. Редактор дашборда пока не предоставляет `cooldownMs` и `waitForCooldownMs`; до появления соответствующего UI используйте файл конфигурации или Management API. diff --git a/docs-site/src/content/docs/tr/guides/combos.md b/docs-site/src/content/docs/tr/guides/combos.md index fe7381e0341..6e8b5444780 100644 --- a/docs-site/src/content/docs/tr/guides/combos.md +++ b/docs-site/src/content/docs/tr/guides/combos.md @@ -305,8 +305,9 @@ Arayüzü](/tr/guides/sub-agent-surface/) sayfasına bakın. Yerel kontrol panelini açın ve **Modeller → Kombolar** seçeneğini belirleyin. Çalışma alanı komboları oluşturur, düzenler, yeniden adlandırır ve kaldırır; -hedef seçicisi ise devre dışı bırakılmış modelleri ve iç içe geçmiş komboları -hariç tutar. +model seçicileri gizli modelleri de içerir. Görünürlük yalnızca dış model listelerini etkiler, +kombo içindeki seçimi etkilemez; gizli bir modeli seçmek onu dışarıda görünür yapmaz. +Hedef seçicisi devre dışı sağlayıcıları, iç içe komboları ve yalnızca karar hizmeti sunan JEV sağlayıcılarını hariç tutar. Her hedef ayrıca canlı bir kota rozeti gösterir: **Kullanılabilir**, **Kota tükendi** veya **Kota bilinmiyor**. Düzenleyici, kota nedeniyle Kaydet ve Oluştur işlemlerini yalnızca kullanılabilir hedeflerin tümü için yapılandırılmış kimlik bilgisine ait çıkarım sınırının tükendiğini doğrulayan geçerli sunucu bilgisi varsa engeller. Yalnızca görüntüleme amaçlı hesap, model, arama ve MCP kotaları ya da eksik veya süresi dolmuş yönlendirme kanıtları bu engellemeye neden olmaz. Engelleme, ilgili sıfırlama zamanında veya verinin güncellik süresi dolduğunda sona erer ve sayfa etkin ya da görünür olduğunda yeniden kontrol edilir; Yenile, hem kombo verilerini hem de kotaları yeniden yükler. diff --git a/docs-site/src/content/docs/zh-cn/guides/combos.md b/docs-site/src/content/docs/zh-cn/guides/combos.md index 35dc0d4dc82..53e918bd56e 100644 --- a/docs-site/src/content/docs/zh-cn/guides/combos.md +++ b/docs-site/src/content/docs/zh-cn/guides/combos.md @@ -214,7 +214,7 @@ combo 失败分为 **跳转** 失败和 **终止** 失败。 ### Dashboard -打开本地 dashboard 并选择 **Models → Combos**。该工作区可以创建、编辑、重命名和删除 combo,其目标选择器会排除已禁用的模型和嵌套 combo。 +打开本地 dashboard 并选择 **Models → Combos**。该工作区可以创建、编辑、重命名和删除 combo。模型选择器使用包含隐藏模型的完整目录:显示/隐藏设置只影响对外模型列表,不影响 Combo 内部选择,选中隐藏模型也不会将其对外显示。目标选择器仍排除已禁用的提供方、嵌套 combo 和仅用于决策服务的 JEV 提供方。 每个目标还会显示实时额度徽章:**可用**、**额度已用尽**或**额度未知**。只有当每个可用目标均有当前有效的服务器确认,表明其所配置凭据的推理限额已耗尽时,编辑器才会因额度而禁止保存和创建。仅供显示的账户、模型、搜索和 MCP 额度,以及缺失或已过期的路由依据,都不会触发此限制。此限制会在适用的重置时间或数据有效期结束时解除,并在页面变为活动或可见状态时重新检查;刷新会同时重新加载 Combo 数据和额度。dashboard 编辑器目前还不能设置 `cooldownMs` 或 `waitForCooldownMs`;在后续 UI 完成前,请使用配置文件或管理 API。 diff --git a/docs-site/src/content/docs/zh-tw/guides/combos.md b/docs-site/src/content/docs/zh-tw/guides/combos.md index 7073a5baf6d..d95c8409370 100644 --- a/docs-site/src/content/docs/zh-tw/guides/combos.md +++ b/docs-site/src/content/docs/zh-tw/guides/combos.md @@ -221,7 +221,7 @@ Codex v2 子代理有一個重要限制([issue #92](https://github.com/lidge-j ### 儀表板 -開啟本機儀表板並選擇 **Combos**。該工作區可建立、編輯、重新命名與移除 combo,且其目標 picker 會排除已停用的模型與巢狀 combo。 +開啟本機儀表板並選擇 **Combos**。該工作區可建立、編輯、重新命名與移除 combo。模型選擇器包含隱藏模型;顯示/隱藏設定僅影響對外模型清單,不影響 Combo 內部選擇,選取隱藏模型也不會將其對外顯示。目標選擇器仍排除已停用的供應商、巢狀 combo 與僅提供決策服務的 JEV 供應商。 每個目標也會顯示即時額度徽章:**可用**、**額度已用盡**或**額度未知**。只有當每個可用目標均有目前有效的伺服器確認,顯示其所設定憑證的推論限額已耗盡時,編輯器才會因配額而停用儲存與建立。僅供顯示的帳戶、模型、搜尋與 MCP 配額,以及缺失或已過期的路由依據,都不會觸發此限制。此限制會在適用的重設時間或資料有效期限結束時解除,並在頁面變為作用中或可見狀態時重新檢查;重新整理會同時重新載入 Combo 資料與配額。 diff --git a/gui/src/pages/Combos.tsx b/gui/src/pages/Combos.tsx index 5b37b8b4c02..616de085480 100644 --- a/gui/src/pages/Combos.tsx +++ b/gui/src/pages/Combos.tsx @@ -92,7 +92,7 @@ export default function Combos({ onCountChange?: (count: number) => void; }) { const t = useT(); - const cacheKey = `ocx.combos.workspace.v1:${apiBase}`; + const cacheKey = `ocx.combos.workspace.v2:${apiBase}`; const cached = useMemo(() => seedCombos(cacheKey), [cacheKey]); /* @@ -223,7 +223,7 @@ export default function Combos({ visionSidecarConsumer: model.visionSidecarConsumer === true, }; capabilityModels.push(option); - if (model.disabled !== true) models.push(option); + models.push(option); // Catalog visibility does not restrict internal Combo selection. } // Add a default placeholder only when the catalog has no row, including disabled rows. diff --git a/gui/tests/combo-capability-hidden-targets.test.tsx b/gui/tests/combo-capability-hidden-targets.test.tsx index 56bf68002c4..22a749f384b 100644 --- a/gui/tests/combo-capability-hidden-targets.test.tsx +++ b/gui/tests/combo-capability-hidden-targets.test.tsx @@ -9,7 +9,7 @@ import { readSessionListCacheEntry, writeSessionListCacheEntry } from "../src/se import { parseComboList } from "../src/combo-workspace-data"; const API_BASE = "http://localhost"; -const CACHE_KEY = `ocx.combos.workspace.v1:${API_BASE}`; +const CACHE_KEY = `ocx.combos.workspace.v2:${API_BASE}`; const globals = ["document", "window", "navigator", "localStorage", "sessionStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; let previousGlobals: Record<(typeof globals)[number], unknown>; let testWindow: Window; @@ -209,7 +209,7 @@ test("JEV selected-target effort controls use the disabled row's advertised ladd expect(container.textContent).not.toContain("Reasoning efforts unknown"); }); -test("a disabled provider default never returns in the new-member picker", async () => { +test("hidden models including the provider default appear once in new and existing member pickers", async () => { mockCatalog([hiddenImage, { ...hiddenImage, id: "visible", disabled: false }], [target(hiddenImage)], {}, { vision: "hidden" }); await mount(); await openDetail(); @@ -218,8 +218,67 @@ test("a disabled provider default never returns in the new-member picker", async const dialog = container.querySelector("dialog")!; await chooseProvider("vision", dialog); const options = [...dialog.querySelector('select[aria-label="Model"]')!.options].map(option => option.value); - expect(options).toEqual(["", "visible"]); - expect(options).not.toContain("hidden"); + expect(options).toEqual(["", "hidden", "visible"]); + const model = dialog.querySelector('select[aria-label="Model"]')!; + await act(async () => { + model.value = "hidden"; + model.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + expect(model.value).toBe("hidden"); + expectImages(true, dialog); +}); + +test("all-hidden catalogs remain selectable and old filtered caches are ignored", async () => { + writeSessionListCacheEntry(`ocx.combos.workspace.v1:${API_BASE}`, { + combos: parseComboList({ combos: [combo([target(hiddenImage)])] }), + providers: [{ name: "vision" }], models: [], cataloguedComboIds: ["hidden-test"], + }); + const api = mockCatalog([hiddenImage, { ...hiddenImage, id: "other" }], [target(hiddenImage)]); + await mount(); + expect(api.requests()).toBe(1); + await act(async () => { container.querySelector('button[aria-label="Add combo"]')!.click(); }); + const dialog = container.querySelector("dialog")!; + await chooseProvider("vision", dialog); + expect([...dialog.querySelector('select[aria-label="Model"]')!.options].map(option => option.value)) + .toEqual(["", "hidden", "other"]); +}); + +test("editing can select and save a hidden model without changing its visibility", async () => { + const visible = { ...hiddenImage, id: "visible", disabled: false }; + mockCatalog([hiddenImage, visible], [target(visible)]); + const fetchCatalog = globalThis.fetch; + const writes: { path: string; body: Record }[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + writes.push({ path: new URL(String(input)).pathname, body: JSON.parse(String(init.body)) }); + return Response.json({ success: true }); + } + return fetchCatalog(input, init); + }) as typeof fetch; + await mount(); + await openDetail(); + const model = container.querySelector('select[aria-label="Model"]')!; + expect([...model.options].map(option => option.value)).toEqual(["", "hidden", "visible"]); + await act(async () => { + model.value = "hidden"; + model.dispatchEvent(new testWindow.Event("change", { bubbles: true })); + }); + const save = container.querySelector("#cwi-edit-save")!; + expect(save.disabled).toBe(false); + await act(async () => { save.click(); }); + expect(writes).toHaveLength(1); + expect(writes[0]).toMatchObject({ path: "/api/combos", body: { combo: { targets: [target(hiddenImage)] } } }); + expect(hiddenImage.disabled).toBe(true); +}); + +test("JEV decision model suggestions include hidden models but exclude the JEV combo itself", async () => { + mockCatalog([hiddenImage], [target(hiddenImage)], { strategy: "jev", decisionModel: "vision/hidden" }); + await mount(); + await openDetail(); + const routes = [...container.querySelectorAll('datalist[id$="-decision-model-options"] option')] + .map(option => option.value); + expect(routes).toContain("vision/hidden"); + expect(routes).not.toContain("combo/hidden-test"); }); test("a default missing from the catalog still gets a picker placeholder", async () => { @@ -242,7 +301,7 @@ test("cache remount and live revalidation preserve hidden-target capabilities", provider: "vision", id: "hidden", inputModalities: ["text", "image"], reasoningEfforts: ["low", "high"], visionSidecarConsumer: false, }]); - expect(cached.data.models).toEqual([]); + expect(cached.data.models).toEqual(cached.data.capabilityModels); await act(async () => { root!.unmount(); }); root = undefined; clearClientResourceStoresForTests(); diff --git a/gui/tests/page-loading-contract.test.tsx b/gui/tests/page-loading-contract.test.tsx index d12d9b589c3..45f117e3618 100644 --- a/gui/tests/page-loading-contract.test.tsx +++ b/gui/tests/page-loading-contract.test.tsx @@ -123,7 +123,7 @@ let testWindow: Window; const originalFetch = globalThis.fetch; const API_BASE = "http://localhost"; -const CACHE_KEY = `ocx.combos.workspace.v1:${API_BASE}`; +const CACHE_KEY = `ocx.combos.workspace.v2:${API_BASE}`; const CACHED_PAGE = { combos: [], providers: [{ name: "openai", disabled: false, hiddenFromPicker: false, authMode: "forward", adapter: "openai", baseUrl: "https://api.openai.com/v1", defaultModel: "gpt-5" }], diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 8254b5a1de5..a4f4935f1c0 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -461,12 +461,12 @@ Chat helper admission in `src/server/responses/core.ts` follows the [deferred st ## Combo editor routing quota -`src/server/management/provider-routes.ts` projects `routingQuota` after each quota read using the -current provider configuration and [scoped inference evidence](runtime.md#scoped-provider-quota-for-combo-selection). -The DTO carries only state, observation time and an exclusive `validUntil`; cached display reports -and private credential bindings are unchanged. Known states expire within 30 minutes; exhaustion -may expire earlier when the runtime predicate clears at a reset boundary, accounting for other -windows and persistent USD blockers. +`gui/src/pages/Combos.tsx` supplies all non-Combo management catalog rows to internal model selectors, including hidden models. Catalog visibility does not restrict Combo member or JEV decision-model selection and selecting a hidden model does not change its external visibility. Disabled-provider, decision-service and recursion exclusions remain unchanged; the v2 workspace cache avoids reusing v1 visibility-filtered candidates. + +`src/server/management/provider-routes.ts` projects `routingQuota` after each quota read using the current provider +configuration and [scoped inference evidence](runtime.md#scoped-provider-quota-for-combo-selection). The DTO carries only state, +observation time and an exclusive `validUntil`; cached display reports and private credential bindings are unchanged. Known states +expire within 30 minutes; exhaustion may expire earlier when the runtime predicate clears at a reset boundary, accounting for other windows and persistent USD blockers. `gui/src/combo-workspace-data.ts` accepts only this projection for quota-based Save/Create blocking. Missing, invalid or expired evidence is unknown. `gui/src/pages/Combos.tsx` wakes at the rendered From cee8aa50b72bbf3f2468225c075bf80a39e9710c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=AF=BC=EC=9A=B0?= Date: Fri, 16 Oct 2026 10:54:47 +0900 Subject: [PATCH 02/12] feat(gui): add draggable split panes to Remote Workspace (carry #6767) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carried from #6767. Co-authored-by: 김민우 --- desktop/src-tauri/src/lib.rs | 3 + .../content/docs/guides/remote-workspace.md | 23 +++ .../docs/ko/guides/remote-workspace.md | 22 +++ .../remote-workspace/RemoteWorkspaceChats.tsx | 157 +++++++++++++++ .../remote-workspace/split-layout.ts | 92 +++++++++ gui/src/components/remote-workspace/types.ts | 61 ++++++ .../remote-workspace/use-session-actions.ts | 70 +++++++ gui/src/i18n/de.ts | 10 + gui/src/i18n/en.ts | 10 + gui/src/i18n/fr.ts | 10 + gui/src/i18n/ja.ts | 10 + gui/src/i18n/ko.ts | 10 + gui/src/i18n/pt.ts | 10 + gui/src/i18n/ru.ts | 10 + gui/src/i18n/tr.ts | 10 + gui/src/i18n/vi.ts | 10 + gui/src/i18n/zh-TW.ts | 10 + gui/src/i18n/zh.ts | 10 + gui/src/pages/RemoteWorkspace.tsx | 184 ++---------------- gui/src/styles-remote-workspace.css | 67 +++++++ .../remote-workspace-split-layout.test.ts | 68 +++++++ gui/tests/remote-workspace-split.test.tsx | 148 ++++++++++++++ gui/tests/remote-workspace.test.tsx | 5 +- structure/desktop-shell.md | 3 +- structure/remote-workspace.md | 17 ++ 25 files changed, 853 insertions(+), 177 deletions(-) create mode 100644 gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx create mode 100644 gui/src/components/remote-workspace/split-layout.ts create mode 100644 gui/src/components/remote-workspace/types.ts create mode 100644 gui/src/components/remote-workspace/use-session-actions.ts create mode 100644 gui/tests/remote-workspace-split-layout.test.ts create mode 100644 gui/tests/remote-workspace-split.test.tsx diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1aa62294d56..255954ed767 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -374,6 +374,9 @@ pub fn run() { .inner_size(1100.0, 720.0) .visible(false) .user_agent(&window::webview_user_agent()) + // Keep dashboard HTML drag/drop (including chat panes) in the webview; + // Tauri's native drop handler intercepts it on Windows. + .disable_drag_drop_handler() // Cmd on macOS, Ctrl elsewhere, with + / - / 0. WebView2 zooms natively; on // macOS and Linux Tauri injects a keydown polyfill whose one IPC call is granted // to the loopback dashboard by `capabilities/dashboard-zoom.json`. diff --git a/docs-site/src/content/docs/guides/remote-workspace.md b/docs-site/src/content/docs/guides/remote-workspace.md index 939ea624de9..1ebc0f725a8 100644 --- a/docs-site/src/content/docs/guides/remote-workspace.md +++ b/docs-site/src/content/docs/guides/remote-workspace.md @@ -171,6 +171,29 @@ Prompt submission acknowledges acceptance immediately; the dashboard polls the s cancels an active Executor command, and prevents a late response from reopening the stopped session. +## Work with sessions side by side + +The session list on the left opens a session in the focused pane. Drag a session to the left, +right, top, or bottom edge of another pane to split the conversation area. You can show up to +four different sessions at once. An already visible session moves between panes instead of +opening a duplicate composer. + +Use **Split right** or **Split below** next to a session for the keyboard-accessible alternative. +Drag a divider to resize the panes, or focus the divider and use its arrow keys. **Home** and +**End** set the divider to its minimum and maximum size. Each session +keeps its own draft, submission state, and Stop action, so a pending turn in one pane does not +block a different session. **Close pane** changes the layout only; it does not stop the session. + +The pane layout is restored for this Hub in the same browser. Only session identifiers and pane +positions and divider sizes are saved; draft text and model output are not written to browser storage. Reloading +the page clears unsent drafts. On screens up to 760px wide, panes stack vertically without +resize dividers; the saved split positions and sizes return when the screen is wider. + +This view is shared by the browser dashboard and the OpenCodex desktop dashboard. CLI users +can open the dashboard with `ocx gui` and select **Remote Workspace** after its normal Hub setup. +It does not change the terminal interface of `codex` or the conversation layout inside the +separate official ChatGPT/Codex desktop app. + ## Restart and reconnect behavior The Hub persists bounded session metadata and a small recent event snapshot. After a Hub restart, diff --git a/docs-site/src/content/docs/ko/guides/remote-workspace.md b/docs-site/src/content/docs/ko/guides/remote-workspace.md index ef5b6490aec..338234f4da3 100644 --- a/docs-site/src/content/docs/ko/guides/remote-workspace.md +++ b/docs-site/src/content/docs/ko/guides/remote-workspace.md @@ -95,6 +95,28 @@ ocx remote-workspace status 프롬프트 실행 중에도 **Stop**을 사용할 수 있습니다. Hub 코딩 에이전트의 현재 턴을 중단하고 활성 Executor 명령을 취소하며, 늦게 온 응답이 중지된 세션을 다시 열지 못하게 합니다. +## 여러 세션을 분할해서 보기 + +왼쪽 세션 목록에서 세션을 클릭하면 현재 선택한 창에서 열립니다. 세션을 다른 창의 +왼쪽·오른쪽·위·아래 가장자리로 끌어다 놓으면 화면이 분할됩니다. 최대 네 개의 서로 다른 +세션을 동시에 표시할 수 있고, 이미 표시 중인 세션은 중복 창을 만드는 대신 이동합니다. + +세션 옆 **오른쪽으로 분할** 또는 **아래로 분할** 버튼도 사용할 수 있습니다. 경계선을 +드래그하거나 경계선에 키보드 초점을 놓고 방향키를 눌러 크기를 조절하세요. **Home**과 +**End** 키는 최소·최대 크기로 조절합니다. 각 세션의 +입력 중인 글, 전송 상태, 중지 동작은 독립적입니다. **창 닫기**는 배치만 변경하며 세션을 +중지하지 않습니다. + +같은 브라우저에서 이 Hub를 다시 열면 창 배치가 복원됩니다. 브라우저 저장소에는 세션 +식별자, 분할 배치와 경계선 크기만 저장하며, 작성 중인 글이나 모델 응답은 저장하지 않습니다. +새로고침하면 아직 보내지 않은 글은 지워집니다. 화면 너비가 760px 이하이면 경계선 없이 +창을 세로로 쌓아 표시하고, 화면이 넓어지면 저장된 분할 배치와 크기로 돌아갑니다. + +이 화면은 OpenCodex 브라우저 대시보드와 데스크톱 앱에서 공통으로 사용합니다. CLI에서는 +`ocx gui`로 대시보드를 열고 정상적인 Hub 설정 후 **원격 워크스페이스**를 선택하세요. +이 기능은 `codex` 터미널 화면이나 별도 공식 ChatGPT/Codex 앱 내부의 대화 배치를 변경하지 +않습니다. + ## 재시작과 재연결 동작 Hub는 제한된 세션 메타데이터와 최근 이벤트의 작은 스냅샷을 저장합니다. Hub 재시작 뒤 완료되지 않은 세션은 원래 Executor를 기다립니다. 해당 기기가 다시 연결되면 다음 프롬프트가 원래 Codex 작업, Claude Code 세션 또는 Pi 세션 ID를 이어갑니다. diff --git a/gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx b/gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx new file mode 100644 index 00000000000..61f28ad2cf8 --- /dev/null +++ b/gui/src/components/remote-workspace/RemoteWorkspaceChats.tsx @@ -0,0 +1,157 @@ +import { useEffect, useRef, useState, type CSSProperties, type DragEvent, type ReactNode } from "react"; +import { IconArrowDown, IconChevron, IconGrip, IconX } from "../../icons"; +import { useT } from "../../i18n/shared"; +import { Notice } from "../../ui"; +import { EVENT_TKEY, PROFILE_LABEL, STATUS_TKEY, type RemoteDevice, type RemoteSession } from "./types"; +import { MAX_PANES, minLayoutWidth, openPane, paneIds, parseLayout, reconcileLayout, removePane, resizeSplit, splitPane, type PaneLayout, type SplitEdge } from "./split-layout"; +import { useSessionActions } from "./use-session-actions"; + +const DRAG_TYPE = "application/x-opencodex-session"; +const EDGES: SplitEdge[] = ["left", "right", "up", "down"]; + +// Only a nested split reserves its width; a leaf pane stays free to follow the ratio. +const trackMin = (child: PaneLayout) => child.type === "split" ? minLayoutWidth(child) : 0; + +function SplitBranch({ node, renderPane, resize }: { + node: PaneLayout; renderPane: (id: string) => ReactNode; resize: (id: string, ratio: number) => void; +}) { + const t = useT(); + const container = useRef(null); + const dragging = useRef(false); + if (node.type === "pane") return renderPane(node.sessionId); + const style = { "--split-ratio": node.ratio, "--split-min-width": minLayoutWidth(node), + "--split-first-min": trackMin(node.first), "--split-second-min": trackMin(node.second) } as CSSProperties; + return
+
+
{ if (event.button !== 0) return; event.preventDefault(); event.currentTarget.focus(); dragging.current = true; event.currentTarget.setPointerCapture(event.pointerId); }} + onPointerMove={event => { + if (!dragging.current || !container.current) return; + const rect = container.current.getBoundingClientRect(); + const size = node.axis === "row" ? rect.width : rect.height; + if (size > 0) resize(node.id, (node.axis === "row" ? event.clientX - rect.left : event.clientY - rect.top) / size); + }} + onPointerUp={event => { dragging.current = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }} + onPointerCancel={() => { dragging.current = false; }} onLostPointerCapture={() => { dragging.current = false; }} + onKeyDown={event => { + const backward = node.axis === "row" ? "ArrowLeft" : "ArrowUp", forward = node.axis === "row" ? "ArrowRight" : "ArrowDown"; + if (![backward, forward, "Home", "End"].includes(event.key)) return; + event.preventDefault(); + resize(node.id, event.key === "Home" ? 0.2 : event.key === "End" ? 0.8 : node.ratio + (event.key === backward ? -0.05 : 0.05)); + }} /> +
+
; +} + +export function RemoteWorkspaceChats({ apiBase, sessions, devices, selectedSessionId, stale, refresh }: { + apiBase: string; sessions: RemoteSession[]; devices: RemoteDevice[]; selectedSessionId: string; stale: boolean; refresh: () => unknown; +}) { + const t = useT(); + const storageKey = `ocx-remote-workspace-layout:${apiBase}`; + const [layout, setLayout] = useState(() => { + try { return parseLayout(localStorage.getItem(storageKey)); } catch { return null; } + }); + const [active, setActive] = useState(""); + const [dragged, setDragged] = useState(null); + const [dropTarget, setDropTarget] = useState<{ id: string; edge: SplitEdge } | null>(null); + const [focusRequest, setFocusRequest] = useState<{ id: string } | null>(null); + const [lastSelection, setLastSelection] = useState(selectedSessionId); + const host = useRef(null); + const actions = useSessionActions(apiBase, sessions, devices, stale, refresh); + const ids = actions.sessions.map(session => session.id); + const fallback = [...actions.sessions].reverse().find(session => session.status !== "stopped")?.id ?? ids.at(-1); + const effectiveLayout = reconcileLayout(layout, ids, fallback); + const visible = paneIds(effectiveLayout); + const activeId = visible.includes(active) ? active : visible[0] ?? ""; + + if (lastSelection !== selectedSessionId) { + setLastSelection(selectedSessionId); + if (selectedSessionId && ids.includes(selectedSessionId)) { + setLayout(openPane(effectiveLayout, selectedSessionId, activeId)); + setActive(selectedSessionId); + } + } else if (layout !== effectiveLayout) setLayout(effectiveLayout); + + useEffect(() => { + try { localStorage.setItem(storageKey, JSON.stringify(effectiveLayout)); } catch { /* The workspace remains usable without storage. */ } + }, [effectiveLayout, storageKey]); + useEffect(() => { + if (!focusRequest) return; + const heading = [...(host.current?.querySelectorAll("[data-pane-heading]") ?? [])].find(element => element.dataset.paneHeading === focusRequest.id); + heading?.focus(); + }, [focusRequest]); + + const open = (id: string) => { setLayout(openPane(effectiveLayout, id, activeId)); setActive(id); }; + const split = (id: string, target: string, edge: SplitEdge) => { + if (!ids.includes(id)) return; + const next = splitPane(effectiveLayout, id, target, edge); + setLayout(next); + if (paneIds(next).includes(id)) setActive(id); + setDragged(null); setDropTarget(null); + }; + const close = (id: string) => { + const next = removePane(effectiveLayout, id); + const nextId = paneIds(next)[0] ?? ""; + setLayout(next); setActive(nextId); setFocusRequest({ id: nextId }); + }; + const startDrag = (event: DragEvent, id: string) => { event.dataTransfer.setData(DRAG_TYPE, id); event.dataTransfer.effectAllowed = "move"; setDragged(id); }; + const endDrag = () => { setDragged(null); setDropTarget(null); }; + const canSplit = (id: string, target: string) => id !== target && (visible.includes(id) || visible.length < MAX_PANES); + + const renderPane = (id: string) => { + const session = actions.sessions.find(value => value.id === id); + if (!session) return null; + const disabled = session.status === "stopped" || (session.status === "failed" && session.resumable === false); + return
setActive(id)} onPointerDown={() => setActive(id)}> +
+
startDrag(event, id)} onDragEnd={endDrag}> +
{PROFILE_LABEL[session.profile]}{session.deviceName}/{session.rootLabel}
+
+ {t(STATUS_TKEY[session.status])} + +
+ {session.accessMode === "read-only" ? t("remote.access.readOnly") : session.capabilities.includes("workspace.exec") ? t("remote.access.workspace") : t("remote.access.workspaceFilesOnly")} +
+ {session.events.length === 0 ?

{t("remote.noEvents")}

: session.events.map(event =>
+ {event.type === "assistant" ? PROFILE_LABEL[session.profile] : t(EVENT_TKEY[event.type])}

{event.text}

+
)} +
+ {session.status === "failed" && session.resumable === false ? {t("remote.notResumable")} : null} + {actions.errors[id] ? {actions.errors[id]} : null} +