feat: refresh dashboard settings UX and Codex feature toggles (#153)
* feat: refresh dashboard settings UX and Codex feature toggles Improve settings/tasks mobile layout, model effort validation by agent type, and preset models for GPT 5.5 and Opus 4.7. Store Codex fast mode and goals in config.toml with Claude fastMode session sync and updated docs for Codex 0.133. Co-authored-by: Cursor <cursoragent@cursor.com> * style: apply pre-commit formatting after dashboard settings commit Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,258 +1,85 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
fetchRoomSkillSettings,
|
||||
fetchRuntimeInventory,
|
||||
updateRoomSkillSetting,
|
||||
type RuntimeAgentInventory,
|
||||
type RuntimeInventorySnapshot,
|
||||
type RuntimePathSnapshot,
|
||||
type RuntimeSkillDirSnapshot,
|
||||
type RoomSkillCatalogItem,
|
||||
type RoomSkillSettingUpdateInput,
|
||||
type RoomSkillSettingsSnapshot,
|
||||
} from './api';
|
||||
import { SettingsSectionHeading } from './SettingsPanelChrome';
|
||||
import './RuntimeInventorySettings.css';
|
||||
import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome';
|
||||
import { type Messages } from './i18n';
|
||||
|
||||
function ExistsBadge({ exists }: { exists: boolean }) {
|
||||
return (
|
||||
<span className={`settings-account-badge ${exists ? 'is-active' : ''}`}>
|
||||
{exists ? '감지됨' : '없음'}
|
||||
</span>
|
||||
);
|
||||
function agentLabel(agentType: 'claude-code' | 'codex', t: Messages): string {
|
||||
return agentType === 'codex'
|
||||
? t.settings.runtime.agentCodex
|
||||
: t.settings.runtime.agentClaude;
|
||||
}
|
||||
|
||||
function PathRow({ item }: { item: RuntimePathSnapshot }) {
|
||||
return (
|
||||
<li className="runtime-path-row">
|
||||
<span>
|
||||
<strong>{item.label}</strong>
|
||||
<code>{item.path}</code>
|
||||
</span>
|
||||
<ExistsBadge exists={item.exists} />
|
||||
</li>
|
||||
);
|
||||
function scopeLabel(scope: RoomSkillCatalogItem['scope'], t: Messages): string {
|
||||
if (scope === 'codex-user') return t.settings.runtime.scopeCodexUser;
|
||||
if (scope === 'claude-user') return t.settings.runtime.scopeClaudeUser;
|
||||
return t.settings.runtime.scopeRunner;
|
||||
}
|
||||
|
||||
function SkillDirCard({ dir }: { dir: RuntimeSkillDirSnapshot }) {
|
||||
const preview = dir.skills.slice(0, 6);
|
||||
return (
|
||||
<article className="runtime-skill-card">
|
||||
<header>
|
||||
<div>
|
||||
<strong>{dir.label}</strong>
|
||||
<code>{dir.path}</code>
|
||||
</div>
|
||||
<span className="settings-account-badge is-active">
|
||||
{dir.count} skills
|
||||
</span>
|
||||
</header>
|
||||
{preview.length === 0 ? (
|
||||
<p className="settings-hint">표시할 SKILL.md 없음</p>
|
||||
) : (
|
||||
<ul className="runtime-skill-list">
|
||||
{preview.map((skill) => (
|
||||
<li key={skill.path}>
|
||||
<strong>{skill.name}</strong>
|
||||
{skill.description ? <span>{skill.description}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{dir.count > preview.length ? (
|
||||
<small className="settings-hint">
|
||||
외 {dir.count - preview.length}개 더 있음
|
||||
</small>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
function applyOptimisticToggle(
|
||||
snapshot: RoomSkillSettingsSnapshot,
|
||||
input: RoomSkillSettingUpdateInput,
|
||||
): RoomSkillSettingsSnapshot {
|
||||
const { roomJid, agentType, skillId, enabled } = input;
|
||||
|
||||
return {
|
||||
...snapshot,
|
||||
rooms: snapshot.rooms.map((room) => {
|
||||
if (room.jid !== roomJid) return room;
|
||||
return {
|
||||
...room,
|
||||
agents: room.agents.map((agent) => {
|
||||
if (agent.agentType !== agentType) return agent;
|
||||
|
||||
const disabledSkillIds = enabled
|
||||
? agent.disabledSkillIds.filter((id) => id !== skillId)
|
||||
: agent.disabledSkillIds.includes(skillId)
|
||||
? agent.disabledSkillIds
|
||||
: [...agent.disabledSkillIds, skillId];
|
||||
|
||||
const effectiveEnabledSkillIds = enabled
|
||||
? agent.availableSkillIds.filter(
|
||||
(id) => !disabledSkillIds.includes(id),
|
||||
)
|
||||
: agent.availableSkillIds.filter(
|
||||
(id) => id !== skillId && !disabledSkillIds.includes(id),
|
||||
);
|
||||
|
||||
return {
|
||||
...agent,
|
||||
mode: disabledSkillIds.length === 0 ? 'all-enabled' : 'custom',
|
||||
disabledSkillIds,
|
||||
effectiveEnabledSkillIds,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function AgentInventoryCard({
|
||||
title,
|
||||
inventory,
|
||||
}: {
|
||||
title: string;
|
||||
inventory: RuntimeAgentInventory;
|
||||
}) {
|
||||
return (
|
||||
<article className="runtime-agent-card">
|
||||
<header className="runtime-card-head">
|
||||
<h4>{title}</h4>
|
||||
<span className="settings-account-badge is-active">
|
||||
MCP {inventory.mcp.ejclawConfigured ? '연결' : '미감지'}
|
||||
</span>
|
||||
</header>
|
||||
<ul className="runtime-path-list">
|
||||
{inventory.configFiles.map((item) => (
|
||||
<PathRow item={item} key={item.path} />
|
||||
))}
|
||||
<PathRow item={inventory.mcp.configPath} />
|
||||
</ul>
|
||||
<p className="settings-hint">
|
||||
MCP servers {inventory.mcp.serverCount}개 · EJClaw section{' '}
|
||||
{inventory.mcp.ejclawConfigured ? '있음' : '없음'}
|
||||
</p>
|
||||
<div className="runtime-skill-grid">
|
||||
{inventory.skillDirs.map((dir) => (
|
||||
<SkillDirCard dir={dir} key={dir.path} />
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function agentLabel(agentType: 'claude-code' | 'codex') {
|
||||
return agentType === 'codex' ? 'Codex' : 'Claude Code';
|
||||
}
|
||||
|
||||
function RoomSkillPolicyCard({
|
||||
onToggle,
|
||||
savingKey,
|
||||
snapshot,
|
||||
}: {
|
||||
onToggle: (input: RoomSkillSettingUpdateInput) => void;
|
||||
savingKey: string | null;
|
||||
snapshot: RoomSkillSettingsSnapshot | null;
|
||||
}) {
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<article className="runtime-agent-card">
|
||||
<header className="runtime-card-head">
|
||||
<h4>방별 스킬 정책</h4>
|
||||
</header>
|
||||
<p className="settings-hint">방별 스킬 정책을 불러오는 중…</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const catalogById = new Map<string, RoomSkillCatalogItem>(
|
||||
snapshot.catalog.map((skill) => [skill.id, skill]),
|
||||
);
|
||||
const roomPreview = snapshot.rooms.slice(0, 8);
|
||||
|
||||
return (
|
||||
<article className="runtime-agent-card">
|
||||
<header className="runtime-card-head">
|
||||
<div>
|
||||
<h4>방별 스킬 정책</h4>
|
||||
<p className="settings-hint">
|
||||
전역 설정은 읽기 전용입니다. 이 목록은 다음 토글 PR에서 방 단위
|
||||
enable/disable 대상으로 쓰입니다.
|
||||
</p>
|
||||
</div>
|
||||
<span className="settings-account-badge is-active">
|
||||
{snapshot.catalog.length} skills · {snapshot.rooms.length} rooms
|
||||
</span>
|
||||
</header>
|
||||
|
||||
{roomPreview.length === 0 ? (
|
||||
<p className="settings-hint">등록된 방이 없습니다.</p>
|
||||
) : (
|
||||
<div className="runtime-room-skill-list">
|
||||
{roomPreview.map((room) => (
|
||||
<section className="runtime-room-skill-card" key={room.jid}>
|
||||
<header>
|
||||
<div>
|
||||
<strong>{room.name}</strong>
|
||||
<span>{room.folder}</span>
|
||||
</div>
|
||||
<code>{room.jid}</code>
|
||||
</header>
|
||||
<div className="runtime-room-agent-grid">
|
||||
{room.agents.map((agent) => {
|
||||
const disabledNames = agent.disabledSkillIds
|
||||
.map((id) => catalogById.get(id)?.displayName ?? id)
|
||||
.slice(0, 3);
|
||||
const enabledIds = new Set(agent.effectiveEnabledSkillIds);
|
||||
return (
|
||||
<div
|
||||
className="runtime-room-agent-policy"
|
||||
key={`${room.jid}:${agent.agentType}`}
|
||||
>
|
||||
<strong>{agentLabel(agent.agentType)}</strong>
|
||||
<span>
|
||||
{agent.mode === 'all-enabled'
|
||||
? '기본 전체 ON'
|
||||
: `${agent.disabledSkillIds.length}개 OFF`}
|
||||
</span>
|
||||
<small>
|
||||
사용 {agent.effectiveEnabledSkillIds.length} / 가능{' '}
|
||||
{agent.availableSkillIds.length}
|
||||
</small>
|
||||
{disabledNames.length > 0 ? (
|
||||
<small>OFF: {disabledNames.join(', ')}</small>
|
||||
) : null}
|
||||
<div className="runtime-room-skill-toggles">
|
||||
{agent.availableSkillIds.map((skillId) => {
|
||||
const skill = catalogById.get(skillId);
|
||||
const key = `${room.jid}:${agent.agentType}:${skillId}`;
|
||||
return (
|
||||
<label
|
||||
className="runtime-room-skill-toggle"
|
||||
key={skillId}
|
||||
>
|
||||
<input
|
||||
checked={enabledIds.has(skillId)}
|
||||
disabled={savingKey !== null}
|
||||
onChange={(event) =>
|
||||
onToggle({
|
||||
roomJid: room.jid,
|
||||
agentType: agent.agentType,
|
||||
skillId,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<strong>{skill?.displayName ?? skillId}</strong>
|
||||
<small>
|
||||
{skill?.scope ?? 'unknown'}
|
||||
{savingKey === key ? ' · 저장 중' : ''}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{snapshot.rooms.length > roomPreview.length ? (
|
||||
<small className="settings-hint">
|
||||
외 {snapshot.rooms.length - roomPreview.length}개 방 더 있음
|
||||
</small>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeInventorySettings() {
|
||||
const [snapshot, setSnapshot] = useState<RuntimeInventorySnapshot | null>(
|
||||
export function RuntimeInventorySettings({ t }: { t: Messages }) {
|
||||
const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [roomSkillSnapshot, setRoomSkillSnapshot] =
|
||||
useState<RoomSkillSettingsSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [roomSkillError, setRoomSkillError] = useState<string | null>(null);
|
||||
const [savingRoomSkillKey, setSavingRoomSkillKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const [selectedRoomJid, setSelectedRoomJid] = useState<string>('');
|
||||
const [selectedAgentType, setSelectedAgentType] = useState<
|
||||
'claude-code' | 'codex'
|
||||
>('codex');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Promise.all([fetchRuntimeInventory(), fetchRoomSkillSettings()])
|
||||
.then(([value, roomSkills]) => {
|
||||
fetchRoomSkillSettings()
|
||||
.then((value) => {
|
||||
if (cancelled) return;
|
||||
setSnapshot(value);
|
||||
setRoomSkillSnapshot(roomSkills);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -264,17 +91,65 @@ export function RuntimeInventorySettings() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleRoomSkillToggle(input: RoomSkillSettingUpdateInput) {
|
||||
const selectedRoom = useMemo(
|
||||
() => snapshot?.rooms.find((room) => room.jid === selectedRoomJid) ?? null,
|
||||
[snapshot, selectedRoomJid],
|
||||
);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() =>
|
||||
selectedRoom?.agents.find(
|
||||
(agent) => agent.agentType === selectedAgentType,
|
||||
) ?? null,
|
||||
[selectedRoom, selectedAgentType],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!snapshot || snapshot.rooms.length === 0) return;
|
||||
if (!snapshot.rooms.some((room) => room.jid === selectedRoomJid)) {
|
||||
setSelectedRoomJid(snapshot.rooms[0]?.jid ?? '');
|
||||
}
|
||||
}, [snapshot, selectedRoomJid]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedRoom || selectedRoom.agents.length === 0) return;
|
||||
if (
|
||||
!selectedRoom.agents.some(
|
||||
(agent) => agent.agentType === selectedAgentType,
|
||||
)
|
||||
) {
|
||||
setSelectedAgentType(selectedRoom.agents[0]?.agentType ?? 'codex');
|
||||
}
|
||||
}, [selectedRoom, selectedAgentType]);
|
||||
|
||||
const catalogById = useMemo(
|
||||
() =>
|
||||
new Map<string, RoomSkillCatalogItem>(
|
||||
snapshot?.catalog.map((skill) => [skill.id, skill]) ?? [],
|
||||
),
|
||||
[snapshot],
|
||||
);
|
||||
|
||||
async function handleToggle(input: RoomSkillSettingUpdateInput) {
|
||||
const key = `${input.roomJid}:${input.agentType}:${input.skillId}`;
|
||||
setSavingRoomSkillKey(key);
|
||||
setRoomSkillError(null);
|
||||
setError(null);
|
||||
setSnapshot((current) =>
|
||||
current ? applyOptimisticToggle(current, input) : current,
|
||||
);
|
||||
setSavingKey(key);
|
||||
|
||||
try {
|
||||
const next = await updateRoomSkillSetting(input);
|
||||
setRoomSkillSnapshot(next);
|
||||
setSnapshot(next);
|
||||
} catch (err) {
|
||||
setRoomSkillError(err instanceof Error ? err.message : String(err));
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
try {
|
||||
setSnapshot(await fetchRoomSkillSettings());
|
||||
} catch {
|
||||
/* keep optimistic state if refetch also fails */
|
||||
}
|
||||
} finally {
|
||||
setSavingRoomSkillKey(null);
|
||||
setSavingKey(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,55 +161,112 @@ export function RuntimeInventorySettings() {
|
||||
role="tabpanel"
|
||||
>
|
||||
<SettingsSectionHeading
|
||||
detail="Runtime inventory"
|
||||
title="런타임"
|
||||
description="Codex/Claude Code 설정, 스킬, MCP 연결 상태를 읽기 전용으로 확인합니다."
|
||||
description={t.settings.sections.runtime.description}
|
||||
detail={t.settings.sections.runtime.kicker}
|
||||
title={t.settings.sections.runtime.title}
|
||||
/>
|
||||
|
||||
<p className="settings-hint settings-skill-default-hint">
|
||||
{t.settings.runtime.defaultHint}
|
||||
</p>
|
||||
|
||||
{error ? <p className="settings-error">{error}</p> : null}
|
||||
|
||||
{!snapshot ? (
|
||||
<p className="settings-hint">런타임 정보를 불러오는 중…</p>
|
||||
<p className="settings-hint">{t.settings.common.loading}</p>
|
||||
) : snapshot.rooms.length === 0 ? (
|
||||
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
|
||||
<p className="settings-hint">{t.settings.runtime.emptyRooms}</p>
|
||||
</SettingsCard>
|
||||
) : (
|
||||
<div className="runtime-inventory">
|
||||
<section className="runtime-summary-card">
|
||||
<div>
|
||||
<span className="settings-kicker">Current service</span>
|
||||
<strong>{snapshot.service.id}</strong>
|
||||
<small>
|
||||
{snapshot.service.agentType} · session{' '}
|
||||
{snapshot.service.sessionScope}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span className="settings-kicker">Project</span>
|
||||
<code>{snapshot.projectRoot}</code>
|
||||
<small>data {snapshot.dataDir}</small>
|
||||
</div>
|
||||
</section>
|
||||
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
|
||||
<div className="settings-form-grid settings-skill-controls">
|
||||
<label className="settings-row">
|
||||
<span className="settings-label">
|
||||
{t.settings.runtime.selectRoomLabel}
|
||||
</span>
|
||||
<select
|
||||
onChange={(event) => setSelectedRoomJid(event.target.value)}
|
||||
value={selectedRoomJid}
|
||||
>
|
||||
{snapshot.rooms.map((room) => (
|
||||
<option key={room.jid} value={room.jid}>
|
||||
{room.name} ({room.folder})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<AgentInventoryCard title="Codex" inventory={snapshot.codex} />
|
||||
<AgentInventoryCard title="Claude Code" inventory={snapshot.claude} />
|
||||
{roomSkillError ? (
|
||||
<p className="settings-error">{roomSkillError}</p>
|
||||
) : null}
|
||||
<RoomSkillPolicyCard
|
||||
onToggle={(input) => {
|
||||
void handleRoomSkillToggle(input);
|
||||
}}
|
||||
savingKey={savingRoomSkillKey}
|
||||
snapshot={roomSkillSnapshot}
|
||||
/>
|
||||
{selectedRoom && selectedRoom.agents.length > 1 ? (
|
||||
<div className="settings-row">
|
||||
<span className="settings-label">
|
||||
{t.settings.runtime.selectAgentLabel}
|
||||
</span>
|
||||
<div className="settings-skill-agent-tabs" role="tablist">
|
||||
{selectedRoom.agents.map((agent) => (
|
||||
<button
|
||||
aria-selected={selectedAgentType === agent.agentType}
|
||||
className={
|
||||
selectedAgentType === agent.agentType
|
||||
? 'is-active'
|
||||
: undefined
|
||||
}
|
||||
key={agent.agentType}
|
||||
onClick={() => setSelectedAgentType(agent.agentType)}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
{agentLabel(agent.agentType, t)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<article className="runtime-agent-card">
|
||||
<header className="runtime-card-head">
|
||||
<h4>EJClaw bridge</h4>
|
||||
<ExistsBadge exists={snapshot.ejclaw.mcpServer.exists} />
|
||||
</header>
|
||||
<ul className="runtime-path-list">
|
||||
<PathRow item={snapshot.ejclaw.mcpServer} />
|
||||
<PathRow item={snapshot.ejclaw.runnerSkillDir} />
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
|
||||
<p className="settings-hint">{t.settings.runtime.emptySkills}</p>
|
||||
) : (
|
||||
<div className="settings-toggle-stack">
|
||||
{selectedAgent.availableSkillIds.map((skillId) => {
|
||||
const skill = catalogById.get(skillId);
|
||||
const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`;
|
||||
const enabled =
|
||||
selectedAgent.effectiveEnabledSkillIds.includes(skillId);
|
||||
return (
|
||||
<label
|
||||
aria-busy={savingKey === key}
|
||||
className={`settings-toggle-row${savingKey === key ? ' is-busy' : ''}`}
|
||||
key={skillId}
|
||||
>
|
||||
<span className="settings-toggle-label">
|
||||
<span className="settings-toggle-title">
|
||||
{skill?.displayName ?? skillId}
|
||||
</span>
|
||||
<small>
|
||||
{skill ? scopeLabel(skill.scope, t) : null}
|
||||
{skill?.description ? ` · ${skill.description}` : null}
|
||||
</small>
|
||||
</span>
|
||||
<input
|
||||
checked={enabled}
|
||||
disabled={savingKey === key}
|
||||
onChange={(event) =>
|
||||
void handleToggle({
|
||||
roomJid: selectedRoomJid,
|
||||
agentType: selectedAgent.agentType,
|
||||
skillId,
|
||||
enabled: event.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
type="checkbox"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user