Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions gui/src/components/subagents-workspace/SubagentsWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/**
* SubagentsWorkspace — rail + main workspace for the Subagents tab, mirroring the
* Providers/Combos workspace DNA. Left rail lists Featured and Available models with
* add/remove toggles; the main pane shows either the featured roster (reorder + save)
* or a per-model detail view.
*/
import { useMemo, useState } from "react";
import {
IconArrowDown,
IconArrowUp,
IconBot,
IconCheck,
IconChevron,
IconInfo,
IconPlus,
IconX,
} from "../../icons";
import { useT } from "../../i18n/shared";
import { Trans } from "../../i18n/provider";
import { modelLabel } from "../../model-display";

export interface SubagentsWorkspaceProps {
available: string[];
chosen: string[];
busy?: boolean;
onToggle: (m: string) => void;
onMove: (i: number, dir: -1 | 1) => void;
onSave: () => void;
}

const FEATURED_MAX = 5;

export default function SubagentsWorkspace({
available,
chosen,
busy = false,
onToggle,
onMove,
onSave,
}: SubagentsWorkspaceProps) {
const t = useT();
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<string | null>(null);

const chosenSet = useMemo(() => new Set(chosen), [chosen]);
const full = chosen.length >= FEATURED_MAX;

const featuredFiltered = useMemo(() => {
const q = query.trim().toLowerCase();
return chosen.filter(m => !q || m.toLowerCase().includes(q));
}, [chosen, query]);

const availableFiltered = useMemo(() => {
const q = query.trim().toLowerCase();
return available.filter(m => !chosenSet.has(m) && (!q || m.toLowerCase().includes(q)));
}, [available, chosenSet, query]);

const selectedIndex = selected ? chosen.indexOf(selected) : -1;
const selectedIsFeatured = selectedIndex !== -1;

return (
<div className="subagents-workspace-shell">
<div className="subagents-workspace-root">
<aside className="subagents-workspace-rail" aria-label={t("nav.subagents")}>
<div className="subagents-workspace-rail-header">
<span className="subagents-workspace-rail-title">{t("nav.subagents")}</span>
<span className="subagents-workspace-rail-count">{chosen.length}/{FEATURED_MAX}</span>
</div>
<div className="subagents-workspace-rail-search">
<input
className="input"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t("sub.search")}
aria-label={t("sub.search")}
/>
</div>
<div className="subagents-workspace-rail-list">
{featuredFiltered.length === 0 && availableFiltered.length === 0 && (
<span className="subagents-workspace-rail-empty">{t("sub.noModels")}</span>
)}
{featuredFiltered.length > 0 && (
<div className="subagents-workspace-rail-group">
<div className="subagents-workspace-rail-group-head">
<span>{t("sub.featured")}</span>
<span className="subagents-workspace-rail-group-count">{featuredFiltered.length}</span>
</div>
{featuredFiltered.map(m => {
const priority = chosen.indexOf(m) + 1;
return (
<div key={m} className={`subagents-workspace-rail-row${selected === m ? " subagents-workspace-rail-row--selected" : ""}`}>
<button
type="button"
className="subagents-workspace-rail-row-main"
onClick={() => setSelected(m)}
aria-current={selected === m ? "true" : undefined}
>
<span className="swi-rail-priority">{priority}</span>
<span className="subagents-workspace-rail-name">{modelLabel(m)}</span>
</button>
<button
type="button"
className="subagents-workspace-rail-toggle subagents-workspace-rail-toggle--on"
onClick={() => onToggle(m)}
disabled={busy}
aria-label={t("sub.workspace.removeFromFeatured", { m })}
title={t("sub.workspace.removeFromFeatured", { m })}
>
<IconCheck style={{ width: 14, height: 14 }} />
</button>
</div>
);
})}
</div>
)}
{availableFiltered.length > 0 && (
<div className="subagents-workspace-rail-group">
<div className="subagents-workspace-rail-group-head">
<span>{t("sub.workspace.allModels")}</span>
<span className="subagents-workspace-rail-group-count">{availableFiltered.length}</span>
</div>
{availableFiltered.map(m => (
<div key={m} className={`subagents-workspace-rail-row${selected === m ? " subagents-workspace-rail-row--selected" : ""}`}>
<button
type="button"
className="subagents-workspace-rail-row-main"
onClick={() => setSelected(m)}
aria-current={selected === m ? "true" : undefined}
>
<span className="swi-rail-priority" aria-hidden="true" />
<span className="subagents-workspace-rail-name">{modelLabel(m)}</span>
</button>
<button
type="button"
className={`subagents-workspace-rail-toggle${full || busy ? " subagents-workspace-rail-toggle--disabled" : ""}`}
onClick={() => { if (!full && !busy) onToggle(m); }}
disabled={full || busy}
aria-label={t("sub.workspace.addToFeatured", { m })}
title={full ? t("sub.workspace.featuredFull") : t("sub.workspace.addToFeatured", { m })}
>
<IconPlus style={{ width: 14, height: 14 }} />
</button>
</div>
))}
</div>
)}
</div>
</aside>

<section className="subagents-workspace-main" aria-label={t("sub.workspace.mainAria")}>
{selected ? (
<div className="swi-detail">
<button type="button" className="swi-detail-back" onClick={() => setSelected(null)}>
<IconChevron className="swi-detail-back-chevron" />
{t("modal.back")}
</button>
<div className="swi-detail-head">
<span className="swi-detail-icon"><IconBot style={{ width: 24, height: 24 }} /></span>
<h2 className="swi-detail-title">{selected}</h2>
</div>

<div className="swi-detail-section">
<dl className="swi-detail-kv">
<div className="swi-detail-kv-row">
<dt>{t("sub.workspace.selector")}</dt>
<dd><code>{selected}</code></dd>
</div>
<div className="swi-detail-kv-row">
<dt>{t("sub.workspace.priority")}</dt>
<dd>{selectedIsFeatured ? selectedIndex + 1 : t("sub.workspace.notFeatured")}</dd>
</div>
</dl>
</div>

<div className="swi-detail-section">
<h3 className="swi-detail-section-title">{t("sub.featured")}</h3>
<div className="swi-detail-actions">
{selectedIsFeatured ? (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => onToggle(selected)} disabled={busy}>
<IconX /> {t("sub.workspace.removeFromFeatured", { m: selected })}
</button>
) : (
<button type="button" className="btn btn-primary btn-sm" onClick={() => onToggle(selected)} disabled={full || busy}>
<IconPlus /> {full ? t("sub.workspace.featuredFull") : t("sub.workspace.addToFeatured", { m: selected })}
</button>
)}
</div>
</div>
</div>
) : (
<>
<div className="swi-featured-head">
<h2 className="swi-featured-title">{t("sub.featured")}</h2>
<span className="swi-featured-count">{chosen.length}/{FEATURED_MAX}</span>
</div>
<p className="swi-featured-hint">
<IconInfo width={15} height={15} aria-hidden="true" />
<span><Trans k="sub.orderHint" cmd="spawn_agent" /></span>
</p>

{chosen.length === 0 ? (
<div className="swi-featured-empty">{t("sub.noneSelected")}</div>
) : (
<div className="swi-featured-list">
{chosen.map((m, i) => (
<div key={m} className="swi-featured-row">
<span className="swi-featured-pos">{i + 1}</span>
<span className="swi-featured-name">{modelLabel(m)}</span>
<span className="swi-featured-actions">
<button type="button" className="btn btn-ghost btn-icon btn-sm" onClick={() => onMove(i, -1)} disabled={busy || i === 0} aria-label={t("sub.moveUp", { m })}>
<IconArrowUp />
</button>
<button type="button" className="btn btn-ghost btn-icon btn-sm" onClick={() => onMove(i, 1)} disabled={busy || i === chosen.length - 1} aria-label={t("sub.moveDown", { m })}>
<IconArrowDown />
</button>
<button type="button" className="btn btn-ghost btn-icon btn-sm" onClick={() => onToggle(m)} disabled={busy} aria-label={t("sub.removeAria", { m })} style={{ color: "var(--red)" }}>
<IconX />
</button>
</span>
</div>
))}
</div>
)}

<div className="swi-save-row">
<button type="button" className="btn btn-primary" onClick={onSave} disabled={busy}>{t("common.save")}</button>
</div>
</>
)}
</section>
</div>
</div>
);
}
13 changes: 13 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,16 @@ export const de: Record<TKey, string> = {
"sub.moveUp": "{m} nach oben",
"sub.moveDown": "{m} nach unten",
"sub.removeAria": "{m} entfernen",
"sub.workspace.allModels": "Alle Modelle",
"sub.workspace.selector": "Öffentlicher Selektor",
"sub.workspace.priority": "Priorität",
"sub.workspace.notFeatured": "Nicht hervorgehoben",
"sub.workspace.addToFeatured": "{m} zu Hervorgehobenen hinzufügen",
"sub.workspace.removeFromFeatured": "{m} aus Hervorgehobenen entfernen",
"sub.workspace.featuredFull": "Hervorgehobene Liste ist voll (max. 5)",
"sub.workspace.selectModel": "Modell auswählen",
"sub.workspace.selectModelDesc": "Wählen Sie ein Modell aus der Liste, um Details anzuzeigen und es für spawn_agent hervorzuheben.",
"sub.workspace.mainAria": "Subagent-Modelldetails",
"logs.title": "Anfrage-Protokolle",
"logs.tabLogs": "Protokolle",
"logs.tabDebug": "Diagnose",
Expand Down Expand Up @@ -585,6 +595,8 @@ export const de: Record<TKey, string> = {
"usage.section.models": "Modelle",
"usage.section.providers": "Anbieter",
"usage.section.coverage": "Abdeckungs-Aufschlüsselung",
"usage.workspace.sections": "Nutzungsabschnitte",
"usage.workspace.report": "Nutzungsbericht",
"usage.coverage.measured": "Gemessen",
"usage.coverage.reported": "Anbieter gemeldet",
"usage.coverage.estimated": "Geschätzt",
Expand Down Expand Up @@ -1286,6 +1298,7 @@ export const de: Record<TKey, string> = {
"codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…",
"codexAuth.resetCreditsAria": "{count} Reset-Guthaben",
"claude.pageTitle": "Claude Code",
"claude.workspace.settings": "Einstellungen",

// Combos workspace
"cws.loading": "Combos werden geladen…",
Expand Down
13 changes: 13 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,16 @@ export const en = {
"sub.moveUp": "Move {m} up",
"sub.moveDown": "Move {m} down",
"sub.removeAria": "Remove {m}",
"sub.workspace.allModels": "All models",
"sub.workspace.selector": "Public selector",
"sub.workspace.priority": "Priority",
"sub.workspace.notFeatured": "Not featured",
"sub.workspace.addToFeatured": "Add {m} to featured",
"sub.workspace.removeFromFeatured": "Remove {m} from featured",
"sub.workspace.featuredFull": "Featured list is full (max 5)",
"sub.workspace.selectModel": "Select a model",
"sub.workspace.selectModelDesc": "Pick a model from the list to see details and feature it for spawn_agent.",
"sub.workspace.mainAria": "Subagent model details",

// logs
"logs.title": "Request Logs",
Expand Down Expand Up @@ -607,6 +617,8 @@ export const en = {
"usage.section.models": "Models",
"usage.section.providers": "Providers",
"usage.section.coverage": "Coverage breakdown",
"usage.workspace.sections": "Usage sections",
"usage.workspace.report": "Usage report",
"usage.coverage.measured": "Measured",
"usage.coverage.reported": "Provider reported",
"usage.coverage.estimated": "Estimated",
Expand Down Expand Up @@ -1243,6 +1255,7 @@ export const en = {
"nav.claude": "Claude",
"claude.subtitle": "Use GPT, Gemini, and other models inside Claude Code.",
"claude.pageTitle": "Claude Code",
"claude.workspace.settings": "Settings",
"claude.enabledLabel": "Claude connection",
"claude.enabledHint": "When off, Claude Code cannot use this proxy.",
"claude.authMode": "Auth Mode",
Expand Down
13 changes: 13 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,16 @@ export const ja: Record<TKey, string> = {
"sub.moveUp": "{m} を上へ移動",
"sub.moveDown": "{m} を下へ移動",
"sub.removeAria": "{m} を削除",
"sub.workspace.allModels": "すべてのモデル",
"sub.workspace.selector": "公開セレクター",
"sub.workspace.priority": "優先度",
"sub.workspace.notFeatured": "おすすめ未設定",
"sub.workspace.addToFeatured": "{m} をおすすめに追加",
"sub.workspace.removeFromFeatured": "{m} をおすすめから削除",
"sub.workspace.featuredFull": "おすすめリストがいっぱいです(最大 5)",
"sub.workspace.selectModel": "モデルを選択",
"sub.workspace.selectModelDesc": "一覧からモデルを選んで詳細を確認し、spawn_agent のおすすめに設定します。",
"sub.workspace.mainAria": "サブエージェントのモデル詳細",

// logs
"logs.title": "リクエストログ",
Expand Down Expand Up @@ -574,6 +584,8 @@ export const ja: Record<TKey, string> = {
"usage.section.models": "モデル",
"usage.section.providers": "プロバイダー",
"usage.section.coverage": "カバレッジ内訳",
"usage.workspace.sections": "使用量セクション",
"usage.workspace.report": "使用量レポート",
"usage.coverage.measured": "計測",
"usage.coverage.reported": "プロバイダー報告",
"usage.coverage.estimated": "推定",
Expand Down Expand Up @@ -1198,6 +1210,7 @@ export const ja: Record<TKey, string> = {
"nav.claude": "Claude",
"claude.subtitle": "Claude Code 内で GPT、Gemini などのモデルを使用します。",
"claude.pageTitle": "Claude Code",
"claude.workspace.settings": "設定",
"claude.enabledLabel": "Claude 接続",
"claude.enabledHint": "オフにすると Claude Code はこのプロキシを使用できません。",
"claude.authMode": "認証モード",
Expand Down
13 changes: 13 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,16 @@ export const ko: Record<TKey, string> = {
"sub.moveUp": "{m} 위로 이동",
"sub.moveDown": "{m} 아래로 이동",
"sub.removeAria": "{m} 삭제",
"sub.workspace.allModels": "모든 모델",
"sub.workspace.selector": "공개 셀렉터",
"sub.workspace.priority": "우선순위",
"sub.workspace.notFeatured": "추천되지 않음",
"sub.workspace.addToFeatured": "{m}을(를) 추천에 추가",
"sub.workspace.removeFromFeatured": "{m}을(를) 추천에서 제거",
"sub.workspace.featuredFull": "추천 목록이 가득 찼습니다 (최대 5개)",
"sub.workspace.selectModel": "모델 선택",
"sub.workspace.selectModelDesc": "목록에서 모델을 선택하여 세부 정보를 확인하고 spawn_agent에 추천하세요.",
"sub.workspace.mainAria": "서브에이전트 모델 세부 정보",

// logs
"logs.title": "요청 로그",
Expand Down Expand Up @@ -600,6 +610,8 @@ export const ko: Record<TKey, string> = {
"usage.section.models": "모델",
"usage.section.providers": "프로바이더",
"usage.section.coverage": "커버리지 상세",
"usage.workspace.sections": "사용량 섹션",
"usage.workspace.report": "사용량 보고서",
"usage.coverage.measured": "측정됨",
"usage.coverage.reported": "제공자 보고",
"usage.coverage.estimated": "추정",
Expand Down Expand Up @@ -1306,6 +1318,7 @@ export const ko: Record<TKey, string> = {
"codexAuth.addIdPlaceholder": "codex-work, codex-alt, team…",
"codexAuth.resetCreditsAria": "리셋 크레딧 {count}개",
"claude.pageTitle": "Claude Code",
"claude.workspace.settings": "설정",

// Combos workspace
"cws.loading": "콤보 불러오는 중…",
Expand Down
Loading
Loading