)}
+ {showSplitNotice && (
+
+
+
+ {rosterStatus.v1.length > 0 && rosterStatus.v2.length > 0
+ ? t("sub.roster.splitNoticeBoth", { v1Models: v1Names, v2Models: v2Names })
+ : rosterStatus.v1.length > 0
+ ? t("sub.roster.splitNoticeV2", { models: v1Names })
+ : t("sub.roster.splitNoticeV1", { models: v2Names })}
+
+ {/* Focus-only navigation: the button never mutates drafts, so it
+ stays enabled while the roster is busy. The hint travels via
+ aria-describedby on a dedicated sr-only span — a reliable
+ accessible description without re-announcing the full note
+ text that sits directly adjacent. */}
+ onUseConcurrentV2?.()}
+ aria-describedby="swi-roster-note-action-hint"
+ >{t("sub.roster.useConcurrentV2")}
+ {t("sub.roster.useConcurrentV2Hint")}
+
+ )}
+
+ {rosterReachability && rosterReachability.size > 0 && (
+
+ {t("sub.roster.diagnostics")}
+ {t("sub.roster.diagnosticsHint")}
+
+ {chosen.map(selector => {
+ const surface = rosterReachability.get(selector);
+ if (!surface) return null;
+ return (
+
+ {formatNamespacedModelId(selector, t)}
+ {surfaceLabel(surface, t)}
+
+ );
+ })}
+
+
+ )}
+
{t("sub.dragHint")}
diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts
index 4ce49f0105..bfe1d16891 100644
--- a/gui/src/i18n/de.ts
+++ b/gui/src/i18n/de.ts
@@ -570,7 +570,7 @@ export const de: Record = {
"sub.pageTitle": "Agenten-Kommandozentrale",
"sub.pageSubtitle": "Wähle beworbene Worker-Modelle, Anleitung und Fallbacks für Kindläufe.",
"sub.desktopPickerLimit": "Diese Liste steuert spawn_agent, nicht den Modell-Picker von Codex Desktop. Desktop kann geroutete Provider-IDs per Remote-Allowlist ausblenden; verwende Combos → Natives OpenAI-Alias für eine explizite Kompatibilitätszuordnung.",
- "sub.featured": "Aktiver Kader",
+ "sub.featured": "Konfigurierter Kader",
"sub.noneSelected": "Noch keine Schnellauswahl.",
"sub.models": "Agenten-Bibliothek",
"sub.search": "Modelle suchen",
@@ -696,10 +696,26 @@ export const de: Record = {
"sub.catalog.newTaskNote": "Eine neue Aufgabe allein lädt den Codex-Hintergrundworker nicht neu und ändert sein Routing nicht. Anwenden gleicht beides ab und ersetzt nur verifizierte veraltete Worker.",
"sub.catalog.legacyStaleNotice": "Der gespeicherte Katalog weicht von {n} laufenden Codex-Sitzung(en) ab. Dieser Proxy bietet noch kein gesichertes Anwenden; nutze {cmd} als erweiterten Fallback, wenn du sie neu starten möchtest.",
"sub.catalog.notRunningNotice": "Kein Codex-Hintergrundworker läuft. Der nächste Workerstart lädt den gespeicherten Katalog.",
+ "sub.roster.diagnostics": "Protokolldiagnose",
+ "sub.roster.diagnosticsHint": "Wo jedes konfigurierte Modell auf den Collab-Oberflächen angeboten wird.",
"sub.roster.excludedNotice": "Gespeicherte Roster-Modelle werden auf der gewählten spawn_agent-Oberfläche derzeit nicht angeboten ({n}): {models}. Die Katalogdatei ist geladen, aber dieses Roster ist nicht vollständig wirksam.",
- "sub.workspace.addToFeatured": "{m} zum aktiven Kader hinzufügen",
- "sub.workspace.featuredFull": "Aktiver Kader ist voll (max. 5)",
- "sub.workspace.removeFromFeatured": "{m} aus dem aktiven Kader entfernen",
+ "sub.roster.splitNoticeBoth": "Der Codex-Standard teilt dieses Roster auf V1 und V2 auf. Ein V2-Elternagent kann {v1Models} nicht auswählen. Ein V1-Elternagent kann {v2Models} nicht auswählen.",
+ "sub.roster.splitNoticeV1": "Der Codex-Standard teilt dieses Roster auf V1 und V2 auf. Ein V1-Elternagent kann {models} nicht auswählen.",
+ "sub.roster.splitNoticeV2": "Der Codex-Standard teilt dieses Roster auf V1 und V2 auf. Ein V2-Elternagent kann {models} nicht auswählen.",
+ "sub.roster.status.bothSr": "Auf den V1- und V2-Collab-Oberflächen verfügbar.",
+ "sub.roster.status.notAdvertised": "Nicht angeboten",
+ "sub.roster.status.routedV2": "Geroutet · V2-kompatibel",
+ "sub.roster.status.v1Only": "V1 unter Codex-Standard",
+ "sub.roster.status.v2Only": "Nur V2",
+ "sub.roster.surface.both": "V1 + V2",
+ "sub.roster.surface.none": "Nicht angeboten",
+ "sub.roster.surface.v1": "V1",
+ "sub.roster.surface.v2": "V2",
+ "sub.roster.useConcurrentV2": "Parallel v2 für das gesamte Roster verwenden",
+ "sub.roster.useConcurrentV2Hint": "Scrollt zum Agentenprotokoll-Auswahlfeld und fokussiert es. Es ändert oder speichert nichts.",
+ "sub.workspace.addToFeatured": "{m} zum konfigurierten Kader hinzufügen",
+ "sub.workspace.featuredFull": "Konfigurierter Kader ist voll (max. 5)",
+ "sub.workspace.removeFromFeatured": "{m} aus dem konfigurierten Kader entfernen",
"logs.title": "Anfrage-Protokolle",
"logs.tabLogs": "Protokolle",
"logs.tabDebug": "Diagnose",
diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts
index ca9b74fc1a..d1c63544cd 100644
--- a/gui/src/i18n/en.ts
+++ b/gui/src/i18n/en.ts
@@ -593,7 +593,7 @@ export const en = {
"sub.pageTitle": "Agent Command Center",
"sub.pageSubtitle": "Choose advertised worker models, guidance, and child fallback behavior.",
"sub.desktopPickerLimit": "This roster controls spawn_agent, not the top-level Codex Desktop picker. Desktop may hide routed provider ids with its remote allowlist; use Combos → Native OpenAI alias for an explicit compatibility mapping.",
- "sub.featured": "Active Roster",
+ "sub.featured": "Configured Roster",
"sub.noneSelected": "No quick picks yet.",
"sub.models": "Agent Library",
"sub.search": "Search models",
@@ -719,10 +719,26 @@ export const en = {
"sub.catalog.newTaskNote": "A new task alone does not reload the Codex background worker or change its routing. Apply reconciles both and replaces only verified stale workers.",
"sub.catalog.legacyStaleNotice": "Saved catalog differs from {n} running Codex session(s). This proxy does not yet expose guarded Apply; use {cmd} as an advanced fallback when ready to restart them.",
"sub.catalog.notRunningNotice": "No Codex background worker is running. The next worker start loads the saved catalog.",
+ "sub.roster.diagnostics": "Protocol diagnostics",
+ "sub.roster.diagnosticsHint": "Where each configured model is advertised across collaboration surfaces.",
"sub.roster.excludedNotice": "Saved roster models are not currently advertised on the selected spawn_agent surface ({n}): {models}. The catalog file is loaded, but this roster is not fully effective.",
- "sub.workspace.addToFeatured": "Add {m} to active roster",
- "sub.workspace.featuredFull": "Active roster is full (max 5)",
- "sub.workspace.removeFromFeatured": "Remove {m} from active roster",
+ "sub.roster.splitNoticeBoth": "Codex defaults split this roster across V1 and V2. A V2 parent cannot select {v1Models}. A V1 parent cannot select {v2Models}.",
+ "sub.roster.splitNoticeV1": "Codex defaults split this roster across V1 and V2. A V1 parent cannot select {models}.",
+ "sub.roster.splitNoticeV2": "Codex defaults split this roster across V1 and V2. A V2 parent cannot select {models}.",
+ "sub.roster.status.bothSr": "Available on V1 and V2 collaboration surfaces.",
+ "sub.roster.status.notAdvertised": "Not advertised",
+ "sub.roster.status.routedV2": "Routed · V2 compatible",
+ "sub.roster.status.v1Only": "V1 under Codex defaults",
+ "sub.roster.status.v2Only": "V2 only",
+ "sub.roster.surface.both": "V1 + V2",
+ "sub.roster.surface.none": "Not advertised",
+ "sub.roster.surface.v1": "V1",
+ "sub.roster.surface.v2": "V2",
+ "sub.roster.useConcurrentV2": "Use Concurrent V2 for the entire roster",
+ "sub.roster.useConcurrentV2Hint": "Scrolls to the agent protocol selector and focuses it. It does not change or save anything.",
+ "sub.workspace.addToFeatured": "Add {m} to configured roster",
+ "sub.workspace.featuredFull": "Configured roster is full (max 5)",
+ "sub.workspace.removeFromFeatured": "Remove {m} from configured roster",
// logs
"logs.title": "Request Logs",
diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts
index ee40f200f5..3e00ce47a9 100644
--- a/gui/src/i18n/ja.ts
+++ b/gui/src/i18n/ja.ts
@@ -555,7 +555,7 @@ export const ja: Record = {
"sub.pageTitle": "エージェント・コマンドセンター",
"sub.pageSubtitle": "ワーカーとして案内するモデル、ガイダンス、子タスクのフォールバックを選びます。",
"sub.desktopPickerLimit": "このロスターは spawn_agent を制御し、Codex Desktop のトップレベルモデルピッカーは制御しません。Desktop が remote allowlist で routed provider id を隠す場合は、Combos → ネイティブ OpenAI エイリアスで明示的な互換マッピングを作成してください。",
- "sub.featured": "アクティブ・ロースター",
+ "sub.featured": "設定済みロースター",
"sub.noneSelected": "クイックピックはまだありません。",
"sub.models": "エージェント・ライブラリ",
"sub.search": "モデルを検索",
@@ -681,10 +681,26 @@ export const ja: Record = {
"sub.catalog.newTaskNote": "新しいタスクを開始するだけでは Codex バックグラウンドワーカーの再読み込みもルーティング変更も行われません。適用は両方を整合させ、検証済みの古いワーカーのみを置き換えます。",
"sub.catalog.legacyStaleNotice": "保存済みカタログは実行中の {n} 件の Codex セッションと異なります。このプロキシはまだ保護された適用を提供していません。再起動する準備ができたら上級者向けの代替として {cmd} を使ってください。",
"sub.catalog.notRunningNotice": "Codex バックグラウンドワーカーは動作していません。次のワーカー開始時に保存済みカタログが読み込まれます。",
+ "sub.roster.diagnostics": "プロトコル診断",
+ "sub.roster.diagnosticsHint": "各設定済みモデルがどのコラボサーフェスで表示されるかを示します。",
"sub.roster.excludedNotice": "保存済みロースターのモデルが選択した spawn_agent 画面に現在表示されていません({n} 件):{models}。カタログファイルは読み込まれていますが、このロースターは完全には有効ではありません。",
- "sub.workspace.addToFeatured": "{m} をアクティブ・ロースターに追加",
- "sub.workspace.featuredFull": "アクティブ・ロースターがいっぱいです(最大 5)",
- "sub.workspace.removeFromFeatured": "{m} をアクティブ・ロースターから削除",
+ "sub.roster.splitNoticeBoth": "Codex のデフォルトでは、このロースターは V1 と V2 に分割されます。V2 の親は {v1Models} を選択できません。V1 の親は {v2Models} を選択できません。",
+ "sub.roster.splitNoticeV1": "Codex のデフォルトでは、このロースターは V1 と V2 に分割されます。V1 の親は {models} を選択できません。",
+ "sub.roster.splitNoticeV2": "Codex のデフォルトでは、このロースターは V1 と V2 に分割されます。V2 の親は {models} を選択できません。",
+ "sub.roster.status.bothSr": "V1 と V2 のコラボサーフェスで利用できます。",
+ "sub.roster.status.notAdvertised": "表示されていません",
+ "sub.roster.status.routedV2": "ルーティング済み · V2 互換",
+ "sub.roster.status.v1Only": "V1(Codex デフォルト)",
+ "sub.roster.status.v2Only": "V2 のみ",
+ "sub.roster.surface.both": "V1 + V2",
+ "sub.roster.surface.none": "表示されていません",
+ "sub.roster.surface.v1": "V1",
+ "sub.roster.surface.v2": "V2",
+ "sub.roster.useConcurrentV2": "ロースター全体に並行 v2 を使用",
+ "sub.roster.useConcurrentV2Hint": "エージェントプロトコルセレクターまでスクロールしてフォーカスします。変更や保存は行いません。",
+ "sub.workspace.addToFeatured": "{m} を設定済みロースターに追加",
+ "sub.workspace.featuredFull": "設定済みロースターがいっぱいです(最大 5)",
+ "sub.workspace.removeFromFeatured": "{m} を設定済みロースターから削除",
// logs
"logs.title": "リクエストログ",
diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts
index 0c5324c3cc..c67cb6ea48 100644
--- a/gui/src/i18n/ko.ts
+++ b/gui/src/i18n/ko.ts
@@ -582,7 +582,7 @@ export const ko: Record = {
"sub.pageTitle": "에이전트 커맨드 센터",
"sub.pageSubtitle": "워커로 안내할 모델, 안내 방식, 하위 작업 폴백을 선택하세요.",
"sub.desktopPickerLimit": "이 roster는 spawn_agent를 제어하며 Codex Desktop의 최상위 모델 선택기를 제어하지 않습니다. Desktop이 remote allowlist로 routed provider id를 숨기면 Combos → 네이티브 OpenAI 별칭에서 명시적 호환 매핑을 만드세요.",
- "sub.featured": "활성 로스터",
+ "sub.featured": "구성된 로스터",
"sub.noneSelected": "아직 빠른 선택 항목이 없습니다.",
"sub.models": "에이전트 라이브러리",
"sub.search": "모델 검색",
@@ -708,10 +708,26 @@ export const ko: Record = {
"sub.catalog.newTaskNote": "새 작업만 시작해도 Codex 백그라운드 워커가 다시 로드되거나 라우팅이 바뀌지 않습니다. 적용은 둘을 일치시키고 확인된 오래된 워커만 교체합니다.",
"sub.catalog.legacyStaleNotice": "저장된 카탈로그가 실행 중인 Codex 세션 {n}개와 다릅니다. 이 프록시는 아직 보호된 적용을 제공하지 않습니다. 재시작할 준비가 되면 고급 대안으로 {cmd} 를 사용하세요.",
"sub.catalog.notRunningNotice": "실행 중인 Codex 백그라운드 워커가 없습니다. 다음 워커 시작 시 저장된 카탈로그를 불러옵니다.",
+ "sub.roster.diagnostics": "프로토콜 진단",
+ "sub.roster.diagnosticsHint": "각 구성된 모델이 협업 서피스의 어디에 표시되는지 보여줍니다.",
"sub.roster.excludedNotice": "저장된 로스터 모델이 선택한 spawn_agent 화면에 현재 표시되지 않습니다({n}개): {models}. 카탈로그 파일은 로드됐지만 이 로스터는 완전히 적용되지 않았습니다.",
- "sub.workspace.addToFeatured": "{m}을(를) 활성 로스터에 추가",
- "sub.workspace.featuredFull": "활성 로스터가 가득 찼습니다 (최대 5개)",
- "sub.workspace.removeFromFeatured": "{m}을(를) 활성 로스터에서 제거",
+ "sub.roster.splitNoticeBoth": "Codex 기본값이 이 로스터를 V1과 V2로 나눕니다. V2 부모는 {v1Models}을(를) 선택할 수 없습니다. V1 부모는 {v2Models}을(를) 선택할 수 없습니다.",
+ "sub.roster.splitNoticeV1": "Codex 기본값이 이 로스터를 V1과 V2로 나눕니다. V1 부모는 {models}을(를) 선택할 수 없습니다.",
+ "sub.roster.splitNoticeV2": "Codex 기본값이 이 로스터를 V1과 V2로 나눕니다. V2 부모는 {models}을(를) 선택할 수 없습니다.",
+ "sub.roster.status.bothSr": "V1 및 V2 협업 서피스에서 사용할 수 있습니다.",
+ "sub.roster.status.notAdvertised": "표시되지 않음",
+ "sub.roster.status.routedV2": "라우팅됨 · V2 호환",
+ "sub.roster.status.v1Only": "V1(Codex 기본값)",
+ "sub.roster.status.v2Only": "V2만",
+ "sub.roster.surface.both": "V1 + V2",
+ "sub.roster.surface.none": "표시되지 않음",
+ "sub.roster.surface.v1": "V1",
+ "sub.roster.surface.v2": "V2",
+ "sub.roster.useConcurrentV2": "전체 로스터에 동시 v2 사용",
+ "sub.roster.useConcurrentV2Hint": "에이전트 프로토콜 선택기로 스크롤하여 포커스를 맞춥니다. 변경하거나 저장하지 않습니다.",
+ "sub.workspace.addToFeatured": "{m}을(를) 구성된 로스터에 추가",
+ "sub.workspace.featuredFull": "구성된 로스터가 가득 찼습니다 (최대 5개)",
+ "sub.workspace.removeFromFeatured": "{m}을(를) 구성된 로스터에서 제거",
// logs
"logs.title": "요청 로그",
diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts
index 8156e47966..70d675c0cd 100644
--- a/gui/src/i18n/ru.ts
+++ b/gui/src/i18n/ru.ts
@@ -587,7 +587,7 @@ export const ru: Record = {
"sub.pageTitle": "Командный центр агентов",
"sub.pageSubtitle": "Выберите модели для подсказок воркерам, сами подсказки и цепочку запасных вариантов дочерних задач.",
"sub.desktopPickerLimit": "Этот roster управляет spawn_agent, а не верхнеуровневым picker Codex Desktop. Desktop может скрывать routed id провайдеров через remote allowlist; используйте Combos → Нативный псевдоним OpenAI для явного совместимого сопоставления.",
- "sub.featured": "Активный состав",
+ "sub.featured": "Настроенный состав",
"sub.noneSelected": "Быстрый выбор пока пуст.",
"sub.models": "Библиотека агентов",
"sub.search": "Поиск моделей",
@@ -713,10 +713,26 @@ export const ru: Record = {
"sub.catalog.newTaskNote": "Новая задача сама по себе не перезагружает фоновый воркер Codex и не меняет его маршрутизацию. Применение согласует оба состояния и заменяет только подтверждённо устаревшие воркеры.",
"sub.catalog.legacyStaleNotice": "Сохранённый каталог отличается от {n} запущенных сессий Codex. Этот прокси ещё не предоставляет защищённое применение; используйте {cmd} как расширенный резервный вариант, когда будете готовы перезапустить их.",
"sub.catalog.notRunningNotice": "Фоновый воркер Codex не запущен. Следующий запуск воркера загрузит сохранённый каталог.",
+ "sub.roster.diagnostics": "Диагностика протокола",
+ "sub.roster.diagnosticsHint": "Где каждая настроенная модель показывается на поверхностях взаимодействия.",
"sub.roster.excludedNotice": "Сохранённые модели состава сейчас не показываются на выбранной поверхности spawn_agent ({n}): {models}. Файл каталога загружен, но состав действует не полностью.",
- "sub.workspace.addToFeatured": "Добавить {m} в активный состав",
- "sub.workspace.featuredFull": "Активный состав заполнен (макс. 5)",
- "sub.workspace.removeFromFeatured": "Убрать {m} из активного состава",
+ "sub.roster.splitNoticeBoth": "По умолчанию Codex делит этот состав между V1 и V2. Родитель V2 не может выбрать {v1Models}. Родитель V1 не может выбрать {v2Models}.",
+ "sub.roster.splitNoticeV1": "По умолчанию Codex делит этот состав между V1 и V2. Родитель V1 не может выбрать {models}.",
+ "sub.roster.splitNoticeV2": "По умолчанию Codex делит этот состав между V1 и V2. Родитель V2 не может выбрать {models}.",
+ "sub.roster.status.bothSr": "Доступна на поверхностях взаимодействия V1 и V2.",
+ "sub.roster.status.notAdvertised": "Не показывается",
+ "sub.roster.status.routedV2": "Маршрутизированная · совместима с V2",
+ "sub.roster.status.v1Only": "V1 по умолчанию в Codex",
+ "sub.roster.status.v2Only": "Только V2",
+ "sub.roster.surface.both": "V1 + V2",
+ "sub.roster.surface.none": "Не показывается",
+ "sub.roster.surface.v1": "V1",
+ "sub.roster.surface.v2": "V2",
+ "sub.roster.useConcurrentV2": "Использовать Параллельный v2 для всего состава",
+ "sub.roster.useConcurrentV2Hint": "Прокручивает к селектору протокола агента и фокусирует его. Ничего не изменяет и не сохраняет.",
+ "sub.workspace.addToFeatured": "Добавить {m} в настроенный состав",
+ "sub.workspace.featuredFull": "Настроенный состав заполнен (макс. 5)",
+ "sub.workspace.removeFromFeatured": "Убрать {m} из настроенного состава",
// logs
"logs.title": "Журнал запросов",
diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts
index 11fcdadcfb..d7e12fbb11 100644
--- a/gui/src/i18n/zh.ts
+++ b/gui/src/i18n/zh.ts
@@ -579,7 +579,7 @@ export const zh: Record = {
"sub.pageTitle": "代理指挥中心",
"sub.pageSubtitle": "选择工作者指导模型、指导方式和子任务回退行为。",
"sub.desktopPickerLimit": "此 roster 控制 spawn_agent,而不是 Codex Desktop 顶层模型选择器。Desktop 可能通过远程 allowlist 隐藏路由提供商 id;请使用“组合 → 原生 OpenAI 别名”创建显式兼容映射。",
- "sub.featured": "活跃名单",
+ "sub.featured": "已配置名单",
"sub.noneSelected": "尚无快捷选择。",
"sub.models": "代理库",
"sub.search": "搜索模型",
@@ -705,10 +705,26 @@ export const zh: Record = {
"sub.catalog.newTaskNote": "仅启动新任务不会重新加载 Codex 后台工作器或更改其路由。应用会协调两者,并且只替换已验证的过期工作器。",
"sub.catalog.legacyStaleNotice": "已保存的目录与 {n} 个正在运行的 Codex 会话不同。此代理尚未提供受保护的应用;准备重启它们时,请将 {cmd} 用作高级备用方案。",
"sub.catalog.notRunningNotice": "没有运行中的 Codex 后台工作器。下一次工作器启动会加载已保存的目录。",
+ "sub.roster.diagnostics": "协议诊断",
+ "sub.roster.diagnosticsHint": "显示每个已配置模型在协作界面中的展示位置。",
"sub.roster.excludedNotice": "已保存的阵容模型当前未在所选 spawn_agent 界面中展示({n} 个):{models}。目录文件已加载,但该阵容尚未完全生效。",
- "sub.workspace.addToFeatured": "将 {m} 添加到活跃名单",
- "sub.workspace.featuredFull": "活跃名单已满(最多 5 个)",
- "sub.workspace.removeFromFeatured": "将 {m} 从活跃名单中移除",
+ "sub.roster.splitNoticeBoth": "Codex 默认值会将此名单拆分到 V1 和 V2。V2 父级无法选择 {v1Models}。V1 父级无法选择 {v2Models}。",
+ "sub.roster.splitNoticeV1": "Codex 默认值会将此名单拆分到 V1 和 V2。V1 父级无法选择 {models}。",
+ "sub.roster.splitNoticeV2": "Codex 默认值会将此名单拆分到 V1 和 V2。V2 父级无法选择 {models}。",
+ "sub.roster.status.bothSr": "在 V1 和 V2 协作界面中可用。",
+ "sub.roster.status.notAdvertised": "未展示",
+ "sub.roster.status.routedV2": "已路由 · 兼容 V2",
+ "sub.roster.status.v1Only": "V1(Codex 默认值)",
+ "sub.roster.status.v2Only": "仅 V2",
+ "sub.roster.surface.both": "V1 + V2",
+ "sub.roster.surface.none": "未展示",
+ "sub.roster.surface.v1": "V1",
+ "sub.roster.surface.v2": "V2",
+ "sub.roster.useConcurrentV2": "对整个名单使用并发 v2",
+ "sub.roster.useConcurrentV2Hint": "滚动到代理协议选择器并聚焦它。不会更改或保存任何内容。",
+ "sub.workspace.addToFeatured": "将 {m} 添加到已配置名单",
+ "sub.workspace.featuredFull": "已配置名单已满(最多 5 个)",
+ "sub.workspace.removeFromFeatured": "将 {m} 从已配置名单中移除",
// logs
"logs.title": "请求日志",
diff --git a/gui/src/pages/Subagents.tsx b/gui/src/pages/Subagents.tsx
index b9c1201497..eb88dd4e9b 100644
--- a/gui/src/pages/Subagents.tsx
+++ b/gui/src/pages/Subagents.tsx
@@ -12,6 +12,7 @@ import { useDataSurface } from "../data-surface";
import { DataSurfaceSkeleton } from "../components/data-surface";
import { useSubagentDelegation } from "./use-subagent-delegation";
import { useSubagentRunPolicy } from "./use-subagent-run-policy";
+import { deriveRosterReachability, type RosterProjections } from "./subagent-roster-reachability";
import SubagentRunPolicySection from "../components/subagents-workspace/SubagentRunPolicySection";
import { setClientResourceData } from "../client-resource";
import {
@@ -61,6 +62,8 @@ type CatalogActivation = {
routingStatus: "current" | "not_injected" | "external" | "unknown" | "not_required";
routingKind: "native" | "codexcommander-local" | "custom-local" | "custom-remote" | "unknown";
protocol: string | null;
+ projections?: RosterProjections;
+ desiredChosen?: string[];
advertised: string[];
excluded: RosterExclusion[];
};
@@ -176,6 +179,15 @@ function parseActivation(value: unknown): CatalogActivation | undefined {
: [];
})
: [];
+ const desiredChosen = Array.isArray(desired?.chosen)
+ ? desired.chosen.filter((model): model is string => typeof model === "string" && model.length > 0)
+ : undefined;
+ const projectionsValue = catalog?.projections;
+ const projections = projectionsValue !== null
+ && typeof projectionsValue === "object"
+ && !Array.isArray(projectionsValue)
+ ? projectionsValue as RosterProjections
+ : undefined;
return {
desiredRevision,
reloadRequired,
@@ -186,6 +198,8 @@ function parseActivation(value: unknown): CatalogActivation | undefined {
routingStatus,
routingKind,
protocol: typeof desired?.protocol === "string" ? desired.protocol : null,
+ projections,
+ desiredChosen,
advertised,
excluded,
};
@@ -321,6 +335,16 @@ export default function Subagents({ apiBase }: { apiBase: string }) {
const launcherRequired = reloadRequired && !routingBlocked && (
activation?.applyReason === "confirmed-launch-required" || (applyAllowed && !confirmedGuiLaunch)
);
+ // Reachability is derived from the server-observed roster, never the draft:
+ // a draft row absent from the server's projection computation must get no
+ // reachability claim (deriveRosterReachability returns an empty map for
+ // malformed/absent projections).
+ const serverRoster = activation?.desiredChosen ?? committedChosen;
+ const rosterReachability = useMemo(
+ () => deriveRosterReachability(serverRoster, activation?.projections),
+ [serverRoster, activation?.projections],
+ );
+ const protocol = enumValue(activation?.protocol, ["v1", "default", "v2"] as const);
useEffect(() => {
const update = () => setConfirmedGuiLaunch(isConfirmedGuiLaunch());
@@ -387,6 +411,17 @@ export default function Subagents({ apiBase }: { apiBase: string }) {
[chosen, committedChosen],
);
+ /** Focus-only affordance: scroll to and focus the protocol mode Select. Never changes or saves the draft. */
+ const useConcurrentV2 = useCallback(() => {
+ const target = document.getElementById("subagent-policy-mode");
+ if (!(target instanceof HTMLButtonElement)) return;
+ const reduced = typeof window.matchMedia === "function"
+ ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ : false;
+ target.scrollIntoView({ block: "center", behavior: reduced ? "auto" : "smooth" });
+ target.focus({ preventScroll: true });
+ }, []);
+
const toggle = (model: string) => {
if (busy) return;
setStatus("");
@@ -726,6 +761,9 @@ export default function Subagents({ apiBase }: { apiBase: string }) {
chosen={chosen}
busy={busy}
rosterDirty={rosterDirty}
+ protocol={protocol}
+ rosterReachability={rosterReachability}
+ onUseConcurrentV2={useConcurrentV2}
onToggle={toggle}
onMove={move}
onReorder={reorder}
diff --git a/gui/src/pages/subagent-roster-reachability.ts b/gui/src/pages/subagent-roster-reachability.ts
new file mode 100644
index 0000000000..c586cb0f8c
--- /dev/null
+++ b/gui/src/pages/subagent-roster-reachability.ts
@@ -0,0 +1,86 @@
+/**
+ * Defensive, read-only derivation of per-model collaboration-surface
+ * reachability from the catalog activation projections exposed by the
+ * management API (`activation.catalog.projections.v1` / `.v2`).
+ *
+ * The server computes each projection with `effectiveSubagentRoster(...)`
+ * (src/codex/catalog/sync.ts). Every configured selector that did not enter a
+ * projection's advertised window appears in that projection's `excluded` list,
+ * keyed by the exact configured selector. Reachability is therefore exact per
+ * selector: a row is reachable on a surface unless that surface's projection
+ * excludes it.
+ *
+ * This module never mutates state and never falls back to guessing: when the
+ * projections are absent or malformed (e.g. an older server), the derivation
+ * returns an empty map and the UI simply shows no per-row exceptions.
+ *
+ * IMPORTANT: `chosen` must be the server-observed roster
+ * (`activation.desired.chosen`), never an unsaved draft. Absence from a
+ * projection's `excluded` list proves reachability only for selectors the
+ * server actually computed; a draft row that is simply absent from the
+ * computation would otherwise be mislabeled "both".
+ */
+
+export type RosterReachability = "both" | "v1" | "v2" | "neither";
+
+export interface RosterProjectionExclusion {
+ configured: string;
+ reason?: string;
+}
+
+/** Parsed subset of `CodexCatalogRosterProjection` (src/codex/catalog-activation.ts). */
+export interface RosterProjection {
+ excluded: RosterProjectionExclusion[];
+}
+
+export interface RosterProjections {
+ v1?: unknown;
+ v2?: unknown;
+}
+
+function parseProjection(value: unknown): RosterProjection | null {
+ if (value === null || typeof value !== "object") return null;
+ const record = value as Record;
+ if (!Array.isArray(record.excluded)) return null;
+ const excluded: RosterProjectionExclusion[] = [];
+ for (const entry of record.excluded) {
+ if (entry === null || typeof entry !== "object") return null;
+ const item = entry as Record;
+ if (typeof item.configured !== "string" || item.configured.length === 0) return null;
+ excluded.push({
+ configured: item.configured,
+ ...(typeof item.reason === "string" ? { reason: item.reason } : {}),
+ });
+ }
+ return { excluded };
+}
+
+/**
+ * Map every configured selector to its collaboration-surface state.
+ *
+ * Both surfaces must be observable before claiming an exception: a response
+ * that omits projections cannot prove a row is limited to one surface, so no
+ * exceptions are shown in that case.
+ */
+export function deriveRosterReachability(
+ chosen: readonly string[],
+ projections?: RosterProjections,
+): ReadonlyMap {
+ const result = new Map();
+ const v1 = parseProjection(projections?.v1);
+ const v2 = parseProjection(projections?.v2);
+ if (!v1 || !v2) return result;
+ for (const selector of chosen) {
+ const onV1 = !v1.excluded.some(entry => entry.configured === selector);
+ const onV2 = !v2.excluded.some(entry => entry.configured === selector);
+ const state: RosterReachability = onV1 && onV2
+ ? "both"
+ : onV1
+ ? "v1"
+ : onV2
+ ? "v2"
+ : "neither";
+ result.set(selector, state);
+ }
+ return result;
+}
diff --git a/gui/src/styles-subagents-workspace.css b/gui/src/styles-subagents-workspace.css
index ea5290bb8b..97a53f5b39 100644
--- a/gui/src/styles-subagents-workspace.css
+++ b/gui/src/styles-subagents-workspace.css
@@ -301,6 +301,18 @@
color: var(--red);
}
+/* Exception-only roster status line: quiet text, deliberately NOT a pill/badge
+ so the roster rows stay calm next to the library's capability chips. */
+.swi-roster-status {
+ color: var(--muted);
+ font-size: var(--text-micro);
+ line-height: var(--leading-ui);
+}
+
+.swi-roster-status--neither {
+ color: var(--amber);
+}
+
.swi-card-footer {
display: flex;
align-items: center;
@@ -324,6 +336,100 @@
margin-top: 1px;
}
+/* Split notice: one quiet info row between the roster and the footer,
+ mirroring the .swi-card-footer rhythm (icon + text + inline action). */
+.swi-roster-note {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ margin: var(--space-3) var(--space-4) 0;
+ padding: var(--space-3);
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius-sm);
+ color: var(--muted);
+ font-size: var(--text-label);
+ line-height: var(--leading-ui);
+}
+
+.swi-roster-note > svg {
+ flex-shrink: 0;
+ margin-top: 1px;
+}
+
+.swi-roster-note-text {
+ min-width: 0;
+ flex: 1;
+}
+
+.swi-roster-note-action {
+ flex: 0 0 auto;
+ white-space: nowrap;
+}
+
+/* Protocol diagnostics disclosure, styled after .swi-policy-details. */
+.swi-roster-diagnostics {
+ margin: var(--space-3) var(--space-4) 0;
+ border-top: 1px solid var(--border-soft);
+}
+
+.swi-roster-diagnostics > summary {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ min-height: var(--control-lg);
+ color: var(--muted);
+ font-size: var(--text-label);
+ font-weight: var(--weight-medium);
+ cursor: pointer;
+ list-style-position: inside;
+}
+
+.swi-roster-diagnostics > summary:hover {
+ color: var(--text);
+}
+
+.swi-roster-diagnostics > p {
+ margin: 0 0 var(--space-2);
+ color: var(--muted);
+ font-size: var(--text-label);
+ line-height: var(--leading-body);
+}
+
+.swi-roster-matrix {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-0-5);
+ margin: 0 0 var(--space-3);
+ padding: 0;
+ list-style: none;
+}
+
+.swi-roster-matrix-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ min-height: var(--control-md);
+ padding: 0 var(--space-2);
+ border: 1px solid var(--border-soft);
+ border-radius: var(--radius-xs);
+ color: var(--muted);
+ font-size: var(--text-label);
+}
+
+.swi-roster-matrix-name {
+ min-width: 0;
+ overflow: hidden;
+ font-family: var(--mono);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.swi-roster-matrix-surface {
+ flex-shrink: 0;
+}
+
.swi-featured-empty {
display: flex;
flex: 1;
@@ -979,6 +1085,11 @@
width: 100%;
}
+ .swi-roster-note {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
.swi-policy-body {
padding-right: var(--space-3);
padding-left: var(--space-3);
diff --git a/tests/subagent-roster-reachability.test.ts b/tests/subagent-roster-reachability.test.ts
new file mode 100644
index 0000000000..de32eec0da
--- /dev/null
+++ b/tests/subagent-roster-reachability.test.ts
@@ -0,0 +1,218 @@
+import { describe, expect, test } from "bun:test";
+import {
+ deriveRosterReachability,
+ type RosterProjectionExclusion,
+ type RosterProjections,
+} from "../gui/src/pages/subagent-roster-reachability";
+
+function projections(v1: unknown, v2: unknown): RosterProjections {
+ return { v1, v2 };
+}
+
+function projection(excluded: unknown): unknown {
+ return { excluded };
+}
+
+function exclusion(configured: string, reason?: string): RosterProjectionExclusion {
+ return reason === undefined ? { configured } : { configured, reason };
+}
+
+function entries(map: ReadonlyMap): [string, string][] {
+ return [...map.entries()];
+}
+
+describe("deriveRosterReachability", () => {
+ test("valid empty exclusions on both surfaces -> every chosen selector is both", () => {
+ const map = deriveRosterReachability(
+ ["a", "b", "c"],
+ projections(projection([]), projection([])),
+ );
+ expect(entries(map)).toEqual([
+ ["a", "both"],
+ ["b", "both"],
+ ["c", "both"],
+ ]);
+ });
+
+ test("v2 exclusion only -> v1", () => {
+ const map = deriveRosterReachability(
+ ["foo", "bar"],
+ projections(projection([]), projection([exclusion("foo")])),
+ );
+ expect(map.get("foo")).toBe("v1");
+ expect(map.get("bar")).toBe("both");
+ });
+
+ test("v1 exclusion only -> v2", () => {
+ const map = deriveRosterReachability(
+ ["foo", "bar"],
+ projections(projection([exclusion("foo")]), projection([])),
+ );
+ expect(map.get("foo")).toBe("v2");
+ expect(map.get("bar")).toBe("both");
+ });
+
+ test("same selector excluded by both -> neither", () => {
+ const map = deriveRosterReachability(
+ ["foo"],
+ projections(projection([exclusion("foo")]), projection([exclusion("foo")])),
+ );
+ expect(map.get("foo")).toBe("neither");
+ });
+
+ test("reason is ignored: every reason string excludes identically", () => {
+ const reasons = [
+ "missing_catalog_entry",
+ "outside_display_limit",
+ "surface_incompatible",
+ "picker_hidden",
+ ];
+ for (const reason of reasons) {
+ const map = deriveRosterReachability(
+ ["foo", "bar"],
+ projections(
+ projection([exclusion("foo", reason)]),
+ projection([exclusion("bar", reason)]),
+ ),
+ );
+ expect(entries(map), `reason=${reason}`).toEqual([
+ ["foo", "v2"],
+ ["bar", "v1"],
+ ]);
+ }
+ });
+
+ test("multiple mixed selectors -> exact both/v1/v2/neither map", () => {
+ const map = deriveRosterReachability(
+ ["both-model", "v1-only", "v2-only", "neither-model", "untouched"],
+ projections(
+ projection([exclusion("v2-only"), exclusion("neither-model")]),
+ projection([exclusion("v1-only"), exclusion("neither-model")]),
+ ),
+ );
+ expect(entries(map)).toEqual([
+ ["both-model", "both"],
+ ["v1-only", "v1"],
+ ["v2-only", "v2"],
+ ["neither-model", "neither"],
+ ["untouched", "both"],
+ ]);
+ });
+
+ test("exclusion matches the exact configured selector, not prefixes or suffixes", () => {
+ const map = deriveRosterReachability(
+ ["foo", "provider/foo", "foo/bar"],
+ projections(projection([exclusion("foo")]), projection([exclusion("provider/foo")])),
+ );
+ // "foo" is excluded only on v1; "provider/foo" only on v2; "foo/bar" never.
+ expect(entries(map)).toEqual([
+ ["foo", "v2"],
+ ["provider/foo", "v1"],
+ ["foo/bar", "both"],
+ ]);
+ });
+
+ test("catalogModel on the entry does not affect matching; configured wins", () => {
+ const map = deriveRosterReachability(
+ ["alias-target"],
+ projections(
+ projection([{ configured: "alias-target", catalogModel: "completely/different" }]),
+ projection([]),
+ ),
+ );
+ expect(entries(map)).toEqual([["alias-target", "v2"]]);
+ });
+
+ test("duplicate selector in chosen -> one stable map entry", () => {
+ const map = deriveRosterReachability(
+ ["dup", "dup", "dup"],
+ projections(projection([]), projection([exclusion("dup")])),
+ );
+ expect(entries(map)).toEqual([["dup", "v1"]]);
+ expect(map.size).toBe(1);
+ });
+
+ test("no projections argument -> empty map", () => {
+ expect(entries(deriveRosterReachability(["foo"]))).toEqual([]);
+ });
+
+ test("only v1 present -> empty map", () => {
+ const map = deriveRosterReachability(["foo"], projections(projection([]), undefined));
+ expect(entries(map)).toEqual([]);
+ });
+
+ test("only v2 present -> empty map", () => {
+ const map = deriveRosterReachability(["foo"], projections(undefined, projection([])));
+ expect(entries(map)).toEqual([]);
+ });
+
+ test("v1 as null, primitive, or string -> empty map", () => {
+ const validV2 = projection([]);
+ for (const v1 of [null, 42, true, "excluded"]) {
+ expect(
+ entries(deriveRosterReachability(["foo"], projections(v1, validV2))),
+ `v1=${String(v1)}`,
+ ).toEqual([]);
+ }
+ });
+
+ test("projection missing excluded -> empty map", () => {
+ for (const v1 of [{}, { excluded: undefined }, { reasons: [] }]) {
+ expect(
+ entries(deriveRosterReachability(["foo"], projections(v1, projection([])))),
+ JSON.stringify(v1),
+ ).toEqual([]);
+ }
+ });
+
+ test("excluded not an array -> empty map", () => {
+ for (const excluded of ["string", 42, null, { configured: "foo" }]) {
+ expect(
+ entries(deriveRosterReachability(["foo"], projections(projection(excluded), projection([])))),
+ `excluded=${String(excluded)}`,
+ ).toEqual([]);
+ }
+ });
+
+ test("malformed exclusion entries fail closed with no partial results", () => {
+ const malformed: unknown[] = [
+ null,
+ 42,
+ "string",
+ {},
+ { reason: "missing_catalog_entry" },
+ { configured: "" },
+ { configured: 7 },
+ ];
+ for (const entry of malformed) {
+ const bad = projection([{ configured: "foo" }, entry]);
+ expect(
+ entries(deriveRosterReachability(["foo"], projections(bad, projection([])))),
+ `entry=${JSON.stringify(entry)}`,
+ ).toEqual([]);
+ }
+ });
+
+ test("unknown reason and extra fields with a valid configured are accepted", () => {
+ const map = deriveRosterReachability(
+ ["foo", "bar"],
+ projections(
+ projection([{ configured: "foo", reason: "brand_new_reason", extra: { anything: true } }]),
+ projection([{ configured: "bar", reason: 123, extra: ["x"] }]),
+ ),
+ );
+ expect(entries(map)).toEqual([
+ ["foo", "v2"],
+ ["bar", "v1"],
+ ]);
+ });
+
+ test("empty chosen with valid projections -> empty map", () => {
+ const map = deriveRosterReachability(
+ [],
+ projections(projection([exclusion("foo")]), projection([exclusion("foo")])),
+ );
+ expect(entries(map)).toEqual([]);
+ expect(map.size).toBe(0);
+ });
+});