Add dedicated settings workspace

This commit is contained in:
2026-04-04 21:04:05 -04:00
parent 457ad5fe3a
commit 1d93535551
6 changed files with 686 additions and 590 deletions

View File

@@ -2,7 +2,7 @@ import React, { useEffect, useCallback, useRef } from 'react';
import { Terminal } from './components/Terminal/Terminal';
import { Sidebar } from './components/Sidebar/Sidebar';
import { TabBar } from './components/TabBar/TabBar';
import { AgentSettingsModal } from './components/Settings/AgentSettingsModal';
import { SettingsPage } from './components/Settings/SettingsPage';
import { useTabs } from './hooks/useTabs';
import { createEntry } from './hooks/useTerminal';
import { agentSettingsBridge } from './lib/agentSettingsBridge';
@@ -10,12 +10,14 @@ import { terminalBridge } from './lib/terminalBridge';
import { AgentConfigStatus } from './types/agentSettings';
import './App.css';
type AppView = 'terminal' | 'settings';
function App() {
const tabs = useTabs();
const [sidebarOpen, setSidebarOpen] = React.useState(true);
const [isProcessing, setIsProcessing] = React.useState(false);
const [agentStatus, setAgentStatus] = React.useState<AgentConfigStatus | null>(null);
const [isSettingsOpen, setIsSettingsOpen] = React.useState(false);
const [activeView, setActiveView] = React.useState<AppView>('terminal');
const commandHistoryRefs = useRef<Record<string, string[]>>({});
const commandIndexRefs = useRef<Record<string, number>>({});
@@ -55,10 +57,14 @@ function App() {
try {
await refreshAgentStatus();
} finally {
setIsSettingsOpen(true);
setActiveView('settings');
}
}, [refreshAgentStatus]);
const handleReturnToTerminal = useCallback(() => {
setActiveView('terminal');
}, []);
const handleCommand = useCallback(async (command: string) => {
const trimmedCommand = command.trim();
const workspaceId = tabs.activeWorkspaceId;
@@ -71,6 +77,8 @@ function App() {
return;
}
setActiveView('terminal');
if (trimmedCommand === '/clear' || trimmedCommand.toLowerCase() === 'clear') {
clearWorkspaceSession(workspaceId);
return;
@@ -239,10 +247,12 @@ function App() {
}, [tabs.workspaces]);
const clearTerminal = useCallback(() => {
setActiveView('terminal');
clearWorkspaceSession(tabs.activeWorkspaceId);
}, [clearWorkspaceSession, tabs.activeWorkspaceId]);
const handleCreateWorkspace = useCallback(() => {
setActiveView('terminal');
tabs.createWorkspace();
}, [tabs]);
@@ -267,11 +277,16 @@ function App() {
e.preventDefault();
clearTerminal();
}
if ((e.metaKey || e.ctrlKey) && e.key === ',') {
e.preventDefault();
void handleOpenSettings();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [tabs, clearTerminal, handleCreateWorkspace]);
}, [tabs, clearTerminal, handleCreateWorkspace, handleOpenSettings]);
useEffect(() => {
return () => {
@@ -290,7 +305,11 @@ function App() {
<div className="flex h-screen bg-[#0a0a0a]">
{/* Sidebar */}
<Sidebar
isSettingsActive={activeView === 'settings'}
isOpen={sidebarOpen}
onOpenSettings={() => {
void handleOpenSettings();
}}
onToggle={() => setSidebarOpen(!sidebarOpen)}
onCommand={handleCommand}
/>
@@ -300,17 +319,23 @@ function App() {
{/* Tab Bar */}
<TabBar
tabs={tabBarTabs}
onTabClick={(id) => tabs.setActiveWorkspace(id)}
onTabClick={(id) => {
setActiveView('terminal');
tabs.setActiveWorkspace(id);
}}
onTabClose={(id) => tabs.closeWorkspace(id)}
onNewTab={handleCreateWorkspace}
onOpenSettings={() => {
void handleOpenSettings();
}}
isAgentReady={Boolean(agentStatus?.configured)}
onTabRename={(id, name) => tabs.renameWorkspace(id, name)}
/>
{/* Terminal */}
{activeView === 'settings' ? (
<SettingsPage
status={agentStatus}
onRefreshStatus={refreshAgentStatus}
onStatusChange={handleAgentStatusChange}
onBackToTerminal={handleReturnToTerminal}
/>
) : (
<Terminal
history={getActiveHistory()}
isProcessing={isProcessing}
@@ -320,14 +345,8 @@ function App() {
getNextCommand={getNextCommand}
resetCommandIndex={resetCommandIndex}
/>
)}
</div>
<AgentSettingsModal
isOpen={isSettingsOpen}
status={agentStatus}
onClose={() => setIsSettingsOpen(false)}
onStatusChange={handleAgentStatusChange}
/>
</div>
);
}

View File

@@ -0,0 +1,308 @@
import React, { useEffect, useState } from 'react';
import { agentSettingsBridge } from '../../lib/agentSettingsBridge';
import {
AgentConfigStatus,
AgentTaskRoute,
TASK_LABELS,
TASK_PROFILES,
TaskProfile,
} from '../../types/agentSettings';
interface AgentSettingsFormProps {
status: AgentConfigStatus | null;
onStatusChange: (status: AgentConfigStatus) => void;
}
const inputClassName =
'w-full rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-sm font-mono text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]';
const buttonClassName =
'rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50';
const mergeTaskDefaults = (
taskDefaults: Partial<Record<TaskProfile, AgentTaskRoute>>,
defaultRemoteModel: string,
): Record<TaskProfile, AgentTaskRoute> =>
TASK_PROFILES.reduce((acc, profile) => {
acc[profile] = taskDefaults[profile] ?? { model: defaultRemoteModel };
return acc;
}, {} as Record<TaskProfile, AgentTaskRoute>);
export const AgentSettingsForm: React.FC<AgentSettingsFormProps> = ({
status,
onStatusChange,
}) => {
const [remoteEnabled, setRemoteEnabled] = useState(true);
const [remoteBaseUrl, setRemoteBaseUrl] = useState('');
const [defaultRemoteModel, setDefaultRemoteModel] = useState('');
const [taskDefaults, setTaskDefaults] = useState<Record<TaskProfile, AgentTaskRoute>>(
mergeTaskDefaults({}, ''),
);
const [remoteApiKey, setRemoteApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
useEffect(() => {
if (!status) {
return;
}
setRemoteEnabled(status.remoteEnabled);
setRemoteBaseUrl(status.remoteBaseUrl);
setDefaultRemoteModel(status.defaultRemoteModel);
setTaskDefaults(mergeTaskDefaults(status.taskDefaults, status.defaultRemoteModel));
setRemoteApiKey('');
setError(null);
setSuccess(null);
}, [status]);
if (!status) {
return (
<div className="rounded border border-[#2a2a2a] bg-[#111111] px-4 py-3 text-xs font-mono text-[#888888]">
Loading AI settings...
</div>
);
}
const runtimeRequest = {
remoteEnabled,
remoteBaseUrl,
defaultRemoteModel,
taskDefaults,
};
const setTaskRoute = (
task: TaskProfile,
updater: (route: AgentTaskRoute) => AgentTaskRoute,
) => {
setTaskDefaults((current) => ({
...current,
[task]: updater(current[task]),
}));
};
const saveRuntimeConfig = async () => {
const nextStatus = await agentSettingsBridge.saveRuntimeConfig(runtimeRequest);
onStatusChange(nextStatus);
return nextStatus;
};
const handleSaveRuntime = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
try {
await saveRuntimeConfig();
setSuccess('Remote settings saved.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save runtime settings.');
} finally {
setIsBusy(false);
}
};
const handleSaveRemoteApiKey = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
try {
const savedStatus = await saveRuntimeConfig();
const nextStatus = await agentSettingsBridge.updateRemoteApiKey({ apiKey: remoteApiKey });
onStatusChange({ ...savedStatus, ...nextStatus });
setRemoteApiKey('');
setSuccess(status.hasRemoteApiKey ? 'Remote API key updated.' : 'Remote API key saved.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save remote API key.');
} finally {
setIsBusy(false);
}
};
const handleClearRemoteApiKey = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
try {
const savedStatus = await saveRuntimeConfig();
const nextStatus = await agentSettingsBridge.clearRemoteApiKey();
onStatusChange({ ...savedStatus, ...nextStatus });
setRemoteApiKey('');
setSuccess('Remote API key cleared.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clear remote API key.');
} finally {
setIsBusy(false);
}
};
const handleDefaultRemoteModelChange = (nextValue: string) => {
const previousValue = defaultRemoteModel;
setDefaultRemoteModel(nextValue);
setTaskDefaults((current) => {
const next = { ...current };
for (const profile of TASK_PROFILES) {
if (next[profile].model.trim() === previousValue.trim()) {
next[profile] = { model: nextValue };
}
}
return next;
});
};
return (
<div className="space-y-5">
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-mono font-semibold text-[#e0e0e0]">Runtime Status</h3>
<p className="mt-1 text-xs font-mono text-[#888888]">
{status.configured ? 'Configured' : 'Configuration incomplete'}
</p>
</div>
<div className="text-right text-xs font-mono text-[#888888]">
<div>Remote ready: {status.remoteConfigured ? 'yes' : 'no'}</div>
<div>API key stored: {status.hasRemoteApiKey ? 'yes' : 'no'}</div>
</div>
</div>
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<h3 className="text-sm font-mono font-semibold text-[#e0e0e0]">Remote Provider</h3>
<p className="mt-1 text-xs font-mono text-[#888888]">
OpenAI-compatible HTTP endpoint.
</p>
</div>
<label className="flex items-center gap-2 text-xs font-mono text-[#e0e0e0]">
<input
type="checkbox"
checked={remoteEnabled}
onChange={(event) => setRemoteEnabled(event.target.checked)}
/>
Enabled
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">Remote Base URL</span>
<input
className={inputClassName}
value={remoteBaseUrl}
onChange={(event) => setRemoteBaseUrl(event.target.value)}
placeholder="https://api.z.ai/api/coding/paas/v4"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">
Default Remote Model
</span>
<input
className={inputClassName}
value={defaultRemoteModel}
onChange={(event) => handleDefaultRemoteModelChange(event.target.value)}
placeholder="glm-5.1"
/>
</label>
</div>
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<div className="mb-4">
<h3 className="text-sm font-mono font-semibold text-[#e0e0e0]">Task Models</h3>
<p className="mt-1 text-xs font-mono text-[#888888]">
Choose the default remote model for each harness task.
</p>
</div>
<div className="space-y-3">
{TASK_PROFILES.map((task) => (
<div
key={task}
className="grid gap-3 rounded border border-[#1d1d1d] bg-[#0d0d0d] p-3 md:grid-cols-[180px_minmax(0,1fr)]"
>
<div className="text-sm font-mono text-[#e0e0e0]">{TASK_LABELS[task]}</div>
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">Model</span>
<input
className={inputClassName}
value={taskDefaults[task].model}
onChange={(event) => setTaskRoute(task, () => ({ model: event.target.value }))}
placeholder={defaultRemoteModel || 'Remote model'}
/>
</label>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSaveRuntime}
disabled={isBusy}
className={buttonClassName}
>
Save Settings
</button>
</div>
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<h3 className="text-sm font-mono font-semibold text-[#e0e0e0]">Remote API Key</h3>
<p className="mt-2 text-xs font-mono text-[#888888]">
Stored in plain text for the remote OpenAI-compatible provider.
</p>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-mono text-[#888888]">
{status.hasRemoteApiKey ? 'Replace Remote API Key' : 'Remote API Key'}
</span>
<input
type="password"
className={inputClassName}
value={remoteApiKey}
onChange={(event) => setRemoteApiKey(event.target.value)}
placeholder="Enter remote API key"
/>
</label>
<div className="mt-4 flex justify-between gap-3">
<div>
{status.hasRemoteApiKey ? (
<button
type="button"
onClick={handleClearRemoteApiKey}
disabled={isBusy}
className="rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#ff7b72] transition-colors hover:border-[#ff7b72] disabled:cursor-not-allowed disabled:opacity-50"
>
Clear Key
</button>
) : null}
</div>
<button
type="button"
onClick={handleSaveRemoteApiKey}
disabled={isBusy || !remoteApiKey.trim()}
className={buttonClassName}
>
{status.hasRemoteApiKey ? 'Save Settings & Update Key' : 'Save Settings & Save Key'}
</button>
</div>
</section>
{success ? (
<div className="rounded border border-[#214f31] bg-[#102417] px-3 py-2 text-xs font-mono text-[#9ee6b3]">
{success}
</div>
) : null}
{error ? (
<div className="rounded border border-[#5c2b2b] bg-[#211313] px-3 py-2 text-xs font-mono text-[#ffb4b4]">
{error}
</div>
) : null}
</div>
);
};

View File

@@ -1,544 +0,0 @@
import React, { useEffect, useState } from 'react';
import { agentSettingsBridge } from '../../lib/agentSettingsBridge';
import {
AgentConfigStatus,
AgentTaskRoute,
ProviderMode,
TASK_LABELS,
TASK_PROFILES,
TaskProfile,
} from '../../types/agentSettings';
interface AgentSettingsModalProps {
isOpen: boolean;
status: AgentConfigStatus | null;
onClose: () => void;
onStatusChange: (status: AgentConfigStatus) => void;
}
const inputClassName =
'w-full rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-sm font-mono text-[#e0e0e0] outline-none transition-colors focus:border-[#58a6ff]';
const textareaClassName = `${inputClassName} min-h-28 resize-y`;
const buttonClassName =
'rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50';
const normalizeModelList = (value: string): string[] => {
const items = value
.split(/[\n,]/)
.map((item) => item.trim())
.filter(Boolean);
return Array.from(new Set(items));
};
const mergeTaskDefaults = (
taskDefaults: Partial<Record<TaskProfile, AgentTaskRoute>>,
defaultRemoteModel: string,
localModels: string[],
): Record<TaskProfile, AgentTaskRoute> => {
const fallbackLocalModel = localModels[0] ?? '';
return TASK_PROFILES.reduce((acc, profile) => {
const existing = taskDefaults[profile];
acc[profile] = existing ?? {
providerMode: 'remote',
model: defaultRemoteModel || fallbackLocalModel,
};
return acc;
}, {} as Record<TaskProfile, AgentTaskRoute>);
};
export const AgentSettingsModal: React.FC<AgentSettingsModalProps> = ({
isOpen,
status,
onClose,
onStatusChange,
}) => {
const [remoteEnabled, setRemoteEnabled] = useState(true);
const [remoteBaseUrl, setRemoteBaseUrl] = useState('');
const [defaultRemoteModel, setDefaultRemoteModel] = useState('');
const [localEnabled, setLocalEnabled] = useState(false);
const [localBaseUrl, setLocalBaseUrl] = useState('');
const [localAvailableModelsText, setLocalAvailableModelsText] = useState('');
const [taskDefaults, setTaskDefaults] = useState<Record<TaskProfile, AgentTaskRoute>>(
mergeTaskDefaults({}, '', []),
);
const [remoteApiKey, setRemoteApiKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [localStatusMessage, setLocalStatusMessage] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
useEffect(() => {
if (!status || !isOpen) {
return;
}
setRemoteEnabled(status.remoteEnabled);
setRemoteBaseUrl(status.remoteBaseUrl);
setDefaultRemoteModel(status.defaultRemoteModel);
setLocalEnabled(status.localEnabled);
setLocalBaseUrl(status.localBaseUrl);
setLocalAvailableModelsText(status.localAvailableModels.join('\n'));
setTaskDefaults(
mergeTaskDefaults(
status.taskDefaults,
status.defaultRemoteModel,
status.localAvailableModels,
),
);
setRemoteApiKey('');
setError(null);
setSuccess(null);
setLocalStatusMessage(null);
}, [isOpen, status]);
if (!isOpen || !status) {
return null;
}
const localAvailableModels = normalizeModelList(localAvailableModelsText);
const runtimeRequest = {
remoteEnabled,
remoteBaseUrl,
defaultRemoteModel,
localEnabled,
localBaseUrl,
localAvailableModels,
taskDefaults,
};
const setTaskRoute = (
task: TaskProfile,
updater: (route: AgentTaskRoute) => AgentTaskRoute,
) => {
setTaskDefaults((current) => ({
...current,
[task]: updater(current[task]),
}));
};
const saveRuntimeConfig = async () => {
const nextStatus = await agentSettingsBridge.saveRuntimeConfig(runtimeRequest);
onStatusChange(nextStatus);
return nextStatus;
};
const handleSaveRuntime = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
try {
await saveRuntimeConfig();
setSuccess('Provider routing saved.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save runtime settings.');
} finally {
setIsBusy(false);
}
};
const handleSaveRemoteApiKey = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
try {
const savedStatus = await saveRuntimeConfig();
const nextStatus = await agentSettingsBridge.updateRemoteApiKey({ apiKey: remoteApiKey });
onStatusChange({ ...savedStatus, ...nextStatus });
setRemoteApiKey('');
setSuccess(
status.hasRemoteApiKey
? 'Remote API key updated.'
: 'Remote API key saved.',
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save remote API key.');
} finally {
setIsBusy(false);
}
};
const handleClearRemoteApiKey = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
try {
const savedStatus = await saveRuntimeConfig();
const nextStatus = await agentSettingsBridge.clearRemoteApiKey();
onStatusChange({ ...savedStatus, ...nextStatus });
setRemoteApiKey('');
setSuccess('Remote API key cleared.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to clear remote API key.');
} finally {
setIsBusy(false);
}
};
const handleDiscoverLocalModels = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
setLocalStatusMessage(null);
try {
const result = await agentSettingsBridge.listLocalModels();
setLocalAvailableModelsText(result.models.join('\n'));
setTaskDefaults((current) => {
const next = { ...current };
for (const profile of TASK_PROFILES) {
if (
next[profile].providerMode === 'local' &&
!next[profile].model.trim() &&
result.models[0]
) {
next[profile] = { ...next[profile], model: result.models[0] };
}
}
return next;
});
setLocalStatusMessage(
result.reachable
? `Discovered ${result.models.length} local model(s).`
: 'Loaded stored local models because the sidecar was unreachable.',
);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to list local models.');
} finally {
setIsBusy(false);
}
};
const handleCheckLocalHealth = async () => {
setIsBusy(true);
setError(null);
setSuccess(null);
try {
const result = await agentSettingsBridge.checkLocalProviderHealth();
setLocalStatusMessage(result.message);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reach local provider.');
} finally {
setIsBusy(false);
}
};
const handleTaskProviderChange = (task: TaskProfile, providerMode: ProviderMode) => {
const suggestedLocalModel = localAvailableModels[0] ?? '';
setTaskRoute(task, (route) => ({
providerMode,
model:
providerMode === 'remote'
? route.providerMode === 'remote' && route.model.trim()
? route.model
: defaultRemoteModel
: route.providerMode === 'local' && route.model.trim()
? route.model
: suggestedLocalModel,
}));
};
const handleDefaultRemoteModelChange = (nextValue: string) => {
const previousValue = defaultRemoteModel;
setDefaultRemoteModel(nextValue);
setTaskDefaults((current) => {
const next = { ...current };
for (const profile of TASK_PROFILES) {
if (
next[profile].providerMode === 'remote' &&
next[profile].model.trim() === previousValue.trim()
) {
next[profile] = { ...next[profile], model: nextValue };
}
}
return next;
});
};
return (
<div className="fixed inset-0 z-40 flex items-center justify-center bg-black/70 px-4">
<div className="max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-xl border border-[#2a2a2a] bg-[#0a0a0a] shadow-2xl">
<div className="flex items-center justify-between border-b border-[#2a2a2a] px-5 py-4">
<div>
<h2 className="text-sm font-mono font-semibold text-[#e0e0e0]">AI Settings</h2>
<p className="mt-1 text-xs font-mono text-[#888888]">
Configure remote and local providers, then assign a default provider/model per task.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded px-2 py-1 text-xs font-mono text-[#888888] transition-colors hover:bg-[#1a1a1a] hover:text-[#e0e0e0]"
>
Close
</button>
</div>
<div className="space-y-5 px-5 py-5">
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
Runtime Status
</h3>
<p className="mt-1 text-sm font-mono text-[#e0e0e0]">
{status.configured ? 'Configured' : 'Routing incomplete'}
</p>
</div>
<div className="text-right text-xs font-mono text-[#888888]">
<div>Remote ready: {status.remoteConfigured ? 'yes' : 'no'}</div>
<div>Local ready: {status.localConfigured ? 'yes' : 'no'}</div>
</div>
</div>
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
Remote Provider
</h3>
<p className="mt-1 text-xs font-mono text-[#888888]">
OpenAI-compatible HTTP endpoint.
</p>
</div>
<label className="flex items-center gap-2 text-xs font-mono text-[#e0e0e0]">
<input
type="checkbox"
checked={remoteEnabled}
onChange={(event) => setRemoteEnabled(event.target.checked)}
/>
Enabled
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">
Remote Base URL
</span>
<input
className={inputClassName}
value={remoteBaseUrl}
onChange={(event) => setRemoteBaseUrl(event.target.value)}
placeholder="https://api.z.ai/api/coding/paas/v4"
/>
</label>
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">
Default Remote Model
</span>
<input
className={inputClassName}
value={defaultRemoteModel}
onChange={(event) => handleDefaultRemoteModelChange(event.target.value)}
placeholder="glm-5.1"
/>
</label>
</div>
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
Local Provider
</h3>
<p className="mt-1 text-xs font-mono text-[#888888]">
Running Mistral HTTP sidecar on localhost.
</p>
</div>
<label className="flex items-center gap-2 text-xs font-mono text-[#e0e0e0]">
<input
type="checkbox"
checked={localEnabled}
onChange={(event) => setLocalEnabled(event.target.checked)}
/>
Enabled
</label>
</div>
<div className="grid gap-4 md:grid-cols-[minmax(0,1fr)_auto]">
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">
Local Base URL
</span>
<input
className={inputClassName}
value={localBaseUrl}
onChange={(event) => setLocalBaseUrl(event.target.value)}
placeholder="http://127.0.0.1:1234"
/>
</label>
<div className="flex items-end gap-2">
<button
type="button"
onClick={handleCheckLocalHealth}
disabled={isBusy}
className={buttonClassName}
>
Check Local
</button>
<button
type="button"
onClick={handleDiscoverLocalModels}
disabled={isBusy}
className={buttonClassName}
>
Discover Models
</button>
</div>
</div>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-mono text-[#888888]">
Available Local Models
</span>
<textarea
className={textareaClassName}
value={localAvailableModelsText}
onChange={(event) => setLocalAvailableModelsText(event.target.value)}
placeholder={'qwen2.5:3b-instruct\nmistral-small'}
/>
</label>
{localStatusMessage ? (
<div className="mt-4 rounded border border-[#2a2a2a] bg-[#0d0d0d] px-3 py-2 text-xs font-mono text-[#9fb3c8]">
{localStatusMessage}
</div>
) : null}
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<div className="mb-4">
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
Task Routing
</h3>
<p className="mt-1 text-xs font-mono text-[#888888]">
Choose the default provider and model for each harness task.
</p>
</div>
<div className="space-y-3">
{TASK_PROFILES.map((task) => (
<div
key={task}
className="grid gap-3 rounded border border-[#1d1d1d] bg-[#0d0d0d] p-3 md:grid-cols-[180px_140px_minmax(0,1fr)]"
>
<div className="text-sm font-mono text-[#e0e0e0]">{TASK_LABELS[task]}</div>
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">Provider</span>
<select
className={inputClassName}
value={taskDefaults[task].providerMode}
onChange={(event) =>
handleTaskProviderChange(task, event.target.value as ProviderMode)
}
>
<option value="remote">remote</option>
<option value="local">local</option>
</select>
</label>
<label className="block">
<span className="mb-2 block text-xs font-mono text-[#888888]">Model</span>
<input
list={taskDefaults[task].providerMode === 'local' ? `${task}-local-models` : undefined}
className={inputClassName}
value={taskDefaults[task].model}
onChange={(event) =>
setTaskRoute(task, (route) => ({ ...route, model: event.target.value }))
}
placeholder={
taskDefaults[task].providerMode === 'remote'
? defaultRemoteModel || 'Remote model'
: 'Local model'
}
/>
{taskDefaults[task].providerMode === 'local' ? (
<datalist id={`${task}-local-models`}>
{localAvailableModels.map((model) => (
<option key={`${task}-${model}`} value={model} />
))}
</datalist>
) : null}
</label>
</div>
))}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={handleSaveRuntime}
disabled={isBusy}
className={buttonClassName}
>
Save Routing
</button>
</div>
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4">
<h3 className="text-xs font-mono uppercase tracking-wide text-[#888888]">
Remote API Key
</h3>
<p className="mt-2 text-xs font-mono text-[#888888]">
Stored in plain text for the remote OpenAI-compatible provider only.
</p>
<label className="mt-4 block">
<span className="mb-2 block text-xs font-mono text-[#888888]">
{status.hasRemoteApiKey ? 'Replace Remote API Key' : 'Remote API Key'}
</span>
<input
type="password"
className={inputClassName}
value={remoteApiKey}
onChange={(event) => setRemoteApiKey(event.target.value)}
placeholder="Enter remote API key"
/>
</label>
<div className="mt-4 flex justify-between">
<div>
{status.hasRemoteApiKey ? (
<button
type="button"
onClick={handleClearRemoteApiKey}
disabled={isBusy}
className="rounded border border-[#2a2a2a] bg-[#151515] px-3 py-2 text-xs font-mono text-[#ff7b72] transition-colors hover:border-[#ff7b72] disabled:cursor-not-allowed disabled:opacity-50"
>
Clear Key
</button>
) : null}
</div>
<button
type="button"
onClick={handleSaveRemoteApiKey}
disabled={isBusy}
className={buttonClassName}
>
{status.hasRemoteApiKey ? 'Save Routing & Update Key' : 'Save Routing & Save Key'}
</button>
</div>
</section>
{success ? (
<div className="rounded border border-[#214f31] bg-[#102417] px-3 py-2 text-xs font-mono text-[#9ee6b3]">
{success}
</div>
) : null}
{error ? (
<div className="rounded border border-[#5c2b2b] bg-[#211313] px-3 py-2 text-xs font-mono text-[#ffb4b4]">
{error}
</div>
) : null}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,274 @@
import React, { useMemo, useState } from 'react';
import { AgentConfigStatus } from '../../types/agentSettings';
import { AgentSettingsForm } from './AgentSettingsForm';
type SettingsSectionId = 'general' | 'ai' | 'workspace' | 'about';
interface SettingsPageProps {
status: AgentConfigStatus | null;
onRefreshStatus: () => Promise<AgentConfigStatus>;
onStatusChange: (status: AgentConfigStatus) => void;
onBackToTerminal: () => void;
}
interface SettingsSection {
id: SettingsSectionId;
label: string;
description: string;
}
const sections: SettingsSection[] = [
{
id: 'general',
label: 'General',
description: 'Configuration hub and runtime overview.',
},
{
id: 'ai',
label: 'AI & Models',
description: 'Remote provider, model routing, and credentials.',
},
{
id: 'workspace',
label: 'Workspace',
description: 'Shell, tabs, and terminal behavior.',
},
{
id: 'about',
label: 'About',
description: 'Product details and settings conventions.',
},
];
const statusBadgeClassName = (active: boolean) =>
active
? 'border-[#1d4c7d] bg-[#0f1f31] text-[#8dc3ff]'
: 'border-[#3d3420] bg-[#1d170c] text-[#e7bb62]';
export const SettingsPage: React.FC<SettingsPageProps> = ({
status,
onRefreshStatus,
onStatusChange,
onBackToTerminal,
}) => {
const [activeSection, setActiveSection] = useState<SettingsSectionId>('general');
const [isRefreshing, setIsRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const statusSummary = useMemo(
() => [
{
label: 'Settings health',
value: status?.configured ? 'Ready' : 'Needs attention',
},
{
label: 'Remote provider',
value: status?.remoteEnabled ? 'Enabled' : 'Disabled',
},
{
label: 'API key',
value: status?.hasRemoteApiKey ? 'Stored' : 'Missing',
},
],
[status],
);
const handleRefresh = async () => {
setIsRefreshing(true);
setRefreshError(null);
try {
const nextStatus = await onRefreshStatus();
onStatusChange(nextStatus);
} catch (error) {
setRefreshError(error instanceof Error ? error.message : 'Failed to refresh settings status.');
} finally {
setIsRefreshing(false);
}
};
const renderContent = () => {
if (activeSection === 'general') {
return (
<div className="space-y-5">
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-5">
<h2 className="text-sm font-mono font-semibold text-[#e0e0e0]">
Settings are centralized here
</h2>
<p className="mt-2 max-w-2xl text-xs font-mono leading-6 text-[#888888]">
This page is now the dedicated control surface for MosaicIQ configuration. New
settings categories should be added as submenu entries here instead of modal dialogs
or one-off controls elsewhere in the shell.
</p>
</section>
<section className="grid gap-4 lg:grid-cols-3">
{statusSummary.map((item) => (
<div
key={item.label}
className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-4"
>
<div className="text-[11px] font-mono text-[#888888]">{item.label}</div>
<div className="mt-2 text-sm font-mono text-[#e0e0e0]">{item.value}</div>
</div>
))}
</section>
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-5">
<h2 className="text-sm font-mono font-semibold text-[#e0e0e0]">
Settings roadmap
</h2>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded border border-[#1d1d1d] bg-[#0d0d0d] p-4">
<div className="text-xs font-mono text-[#e0e0e0]">AI & Models</div>
<p className="mt-2 text-xs font-mono leading-6 text-[#888888]">
Active now. Provider routing, default models, and credential storage are managed
in this section.
</p>
</div>
<div className="rounded border border-[#1d1d1d] bg-[#0d0d0d] p-4">
<div className="text-xs font-mono text-[#e0e0e0]">Workspace</div>
<p className="mt-2 text-xs font-mono leading-6 text-[#888888]">
Reserved for terminal defaults, tab naming conventions, and shell preferences as
those controls are added.
</p>
</div>
</div>
</section>
</div>
);
}
if (activeSection === 'ai') {
return <AgentSettingsForm status={status} onStatusChange={onStatusChange} />;
}
if (activeSection === 'workspace') {
return (
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-5">
<h2 className="text-sm font-mono font-semibold text-[#e0e0e0]">Workspace settings</h2>
<p className="mt-2 max-w-2xl text-xs font-mono leading-6 text-[#888888]">
This submenu is in place for shell-wide controls such as sidebar defaults, startup
behavior, and terminal preferences. Future workspace configuration should be added here
rather than inside the terminal view.
</p>
<div className="mt-5 grid gap-3 md:grid-cols-2">
<div className="rounded border border-[#1d1d1d] bg-[#0d0d0d] p-4">
<div className="text-xs font-mono text-[#e0e0e0]">Planned controls</div>
<p className="mt-2 text-xs font-mono leading-6 text-[#888888]">
Default workspace names, sidebar visibility at launch, terminal input behavior, and
session retention rules.
</p>
</div>
<div className="rounded border border-[#1d1d1d] bg-[#0d0d0d] p-4">
<div className="text-xs font-mono text-[#e0e0e0]">Implementation note</div>
<p className="mt-2 text-xs font-mono leading-6 text-[#888888]">
Keep future settings grouped by submenu and avoid reintroducing feature-specific
controls in headers, toolbars, or modal overlays.
</p>
</div>
</div>
</section>
);
}
return (
<section className="rounded-lg border border-[#2a2a2a] bg-[#111111] p-5">
<h2 className="text-sm font-mono font-semibold text-[#e0e0e0]">About this settings page</h2>
<div className="mt-4 space-y-3 text-xs font-mono leading-6 text-[#888888]">
<p>
The settings page is designed as a durable home for configuration instead of scattering
controls across the product shell.
</p>
<p>
Each submenu is a stable category. Future settings work should add to these categories
or introduce a new submenu here when the information architecture truly expands.
</p>
<p>
Current shell shortcut: use the bottom-left cog or press <kbd className="rounded border border-[#2a2a2a] bg-[#0d0d0d] px-1.5 py-0.5 text-[11px] text-[#e0e0e0]">Cmd+,</kbd> to open settings.
</p>
</div>
</section>
);
};
return (
<div className="flex h-full flex-col bg-[#0a0a0a]">
<div className="flex items-center justify-between border-b border-[#2a2a2a] px-6 py-4">
<div>
<h1 className="text-base font-mono font-semibold text-[#e0e0e0]">Settings</h1>
<p className="mt-1 text-xs font-mono text-[#888888]">
Centralized configuration for MosaicIQ.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleRefresh}
disabled={isRefreshing}
className="rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff] disabled:cursor-not-allowed disabled:opacity-50"
>
{isRefreshing ? 'Refreshing...' : 'Refresh status'}
</button>
<button
type="button"
onClick={onBackToTerminal}
className="rounded border border-[#2a2a2a] bg-[#111111] px-3 py-2 text-xs font-mono text-[#e0e0e0] transition-colors hover:border-[#58a6ff] hover:text-[#58a6ff]"
>
Back to terminal
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
<aside className="border-b border-[#2a2a2a] bg-[#0d0d0d] lg:w-[260px] lg:border-b-0 lg:border-r">
<nav className="flex gap-2 overflow-x-auto px-4 py-4 lg:flex-col lg:gap-1 lg:overflow-visible">
{sections.map((section) => {
const isActive = activeSection === section.id;
return (
<button
key={section.id}
type="button"
onClick={() => setActiveSection(section.id)}
className={`min-w-[180px] rounded border px-3 py-3 text-left transition-colors lg:min-w-0 ${
isActive
? 'border-[#3a3a3a] bg-[#111111] text-[#e0e0e0]'
: 'border-transparent bg-transparent text-[#888888] hover:border-[#2a2a2a] hover:bg-[#111111] hover:text-[#e0e0e0]'
}`}
>
<div className="text-xs font-mono">{section.label}</div>
<div className="mt-1 text-[11px] font-mono leading-5 text-[#666666]">
{section.description}
</div>
</button>
);
})}
</nav>
</aside>
<main className="min-h-0 flex-1 overflow-y-auto px-4 py-4 lg:px-6 lg:py-5">
<div className="mb-5 flex flex-wrap items-center gap-2">
<div
className={`rounded border px-2.5 py-1 text-[11px] font-mono ${statusBadgeClassName(
Boolean(status?.configured),
)}`}
>
{status?.configured ? 'Configured' : 'Needs configuration'}
</div>
<div className="rounded border border-[#2a2a2a] bg-[#111111] px-2.5 py-1 text-[11px] font-mono text-[#888888]">
Active section: {sections.find((section) => section.id === activeSection)?.label}
</div>
</div>
{refreshError ? (
<div className="mb-5 rounded border border-[#5c2b2b] bg-[#211313] px-3 py-2 text-xs font-mono text-[#ffb4b4]">
{refreshError}
</div>
) : null}
{renderContent()}
</main>
</div>
</div>
);
};

View File

@@ -5,13 +5,21 @@ import { useMockData } from '../../hooks/useMockData';
interface SidebarProps {
onCommand: (command: string) => void;
onOpenSettings: () => void;
isSettingsActive: boolean;
isOpen: boolean;
onToggle: () => void;
}
type SidebarState = 'closed' | 'minimized' | 'open';
export const Sidebar: React.FC<SidebarProps> = ({ onCommand, isOpen, onToggle }) => {
export const Sidebar: React.FC<SidebarProps> = ({
onCommand,
onOpenSettings,
isSettingsActive,
isOpen,
onToggle,
}) => {
const { getAllCompanies, getPortfolio } = useMockData();
const companies = getAllCompanies();
const portfolio = getPortfolio();
@@ -96,6 +104,31 @@ export const Sidebar: React.FC<SidebarProps> = ({ onCommand, isOpen, onToggle })
);
})}
</div>
<div className="border-t border-[#2a2a2a] p-2">
<button
onClick={onOpenSettings}
className={`group relative flex w-full items-center justify-center rounded border px-2 py-3 transition-colors ${
isSettingsActive
? 'border-[#58a6ff] bg-[#111827] text-[#58a6ff]'
: 'border-[#2a2a2a] bg-[#111111] text-[#888888] hover:text-[#e0e0e0]'
}`}
title="Open settings"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317a1 1 0 011.35-.936l.35.14a1 1 0 00.95 0l.35-.14a1 1 0 011.35.936l.05.373a1 1 0 00.62.79l.347.143a1 1 0 01.527 1.282l-.145.346a1 1 0 000 .95l.145.347a1 1 0 01-.527 1.282l-.347.143a1 1 0 00-.62.79l-.05.373a1 1 0 01-1.35.936l-.35-.14a1 1 0 00-.95 0l-.35.14a1 1 0 01-1.35-.936l-.05-.373a1 1 0 00-.62-.79l-.347-.143a1 1 0 01-.527-1.282l.145-.347a1 1 0 000-.95l-.145-.346a1 1 0 01.527-1.282l.347-.143a1 1 0 00.62-.79l.05-.373z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="absolute left-full ml-2 px-2 py-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded text-xs text-[#e0e0e0] opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap z-20 pointer-events-none">
Settings
</span>
</button>
</div>
</div>
);
}
@@ -133,10 +166,33 @@ export const Sidebar: React.FC<SidebarProps> = ({ onCommand, isOpen, onToggle })
{/* Footer */}
<div className="p-4 border-t border-[#2a2a2a]">
<div className="text-[10px] text-[#888888] font-mono text-center">
<div className="flex items-center justify-between gap-3">
<button
onClick={onOpenSettings}
className={`flex items-center gap-2 rounded border px-3 py-2 text-xs font-mono transition-colors ${
isSettingsActive
? 'border-[#58a6ff] bg-[#111827] text-[#58a6ff]'
: 'border-[#2a2a2a] bg-[#111111] text-[#888888] hover:border-[#58a6ff] hover:text-[#e0e0e0]'
}`}
title="Open settings"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M10.325 4.317a1 1 0 011.35-.936l.35.14a1 1 0 00.95 0l.35-.14a1 1 0 011.35.936l.05.373a1 1 0 00.62.79l.347.143a1 1 0 01.527 1.282l-.145.346a1 1 0 000 .95l.145.347a1 1 0 01-.527 1.282l-.347.143a1 1 0 00-.62.79l-.05.373a1 1 0 01-1.35.936l-.35-.14a1 1 0 00-.95 0l-.35.14a1 1 0 01-1.35-.936l-.05-.373a1 1 0 00-.62-.79l-.347-.143a1 1 0 01-.527-1.282l.145-.347a1 1 0 000-.95l-.145-.346a1 1 0 01.527-1.282l.347-.143a1 1 0 00.62-.79l.05-.373z"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Settings</span>
</button>
<div className="text-[10px] text-[#888888] font-mono text-right">
Press <kbd className="px-1.5 py-0.5 bg-[#1a1a1a] rounded text-[#e0e0e0]">Cmd+B</kbd> to toggle
</div>
</div>
</div>
</div>
);
};

View File

@@ -11,8 +11,6 @@ interface TabBarProps {
onTabClick: (id: string) => void;
onTabClose: (id: string) => void;
onNewTab: () => void;
onOpenSettings: () => void;
isAgentReady?: boolean;
onTabRename?: (id: string, newName: string) => void;
}
@@ -21,8 +19,6 @@ export const TabBar: React.FC<TabBarProps> = ({
onTabClick,
onTabClose,
onNewTab,
onOpenSettings,
isAgentReady = false,
onTabRename
}) => {
const [editingId, setEditingId] = useState<string | null>(null);
@@ -122,19 +118,6 @@ export const TabBar: React.FC<TabBarProps> = ({
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
<button
onClick={onOpenSettings}
className={`ml-1 flex items-center gap-1 rounded px-2 py-1 text-[10px] font-mono transition-colors ${
isAgentReady
? 'text-[#00d26a] hover:bg-[#102417]'
: 'text-[#ffb000] hover:bg-[#241b08]'
}`}
title="AI settings"
>
<span className={`inline-block h-2 w-2 rounded-full ${isAgentReady ? 'bg-[#00d26a]' : 'bg-[#ffb000]'}`} />
AI
</button>
</div>
);
};