mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 12:18:44 +02:00
feat: add MCP and LSP tooling support
Add global MCP/LSP settings, per-session Activity toggles, sidecar runtime integration, tests, and documentation updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -5,6 +5,9 @@ import { dialog } from 'electron';
|
||||
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
RunTurnLspProfileConfig,
|
||||
RunTurnMcpServerConfig,
|
||||
RunTurnToolingConfig,
|
||||
SidecarCapabilities,
|
||||
TurnDeltaEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
@@ -31,11 +34,22 @@ import {
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import {
|
||||
applyScratchpadSessionConfig,
|
||||
resolveSessionToolingSelection,
|
||||
createScratchpadSessionConfig,
|
||||
resolveSessionTitle,
|
||||
type ChatMessageRecord,
|
||||
type SessionRecord,
|
||||
} from '@shared/domain/session';
|
||||
import {
|
||||
createSessionToolingSelection,
|
||||
type LspProfileDefinition,
|
||||
type McpServerDefinition,
|
||||
normalizeLspProfileDefinition,
|
||||
normalizeMcpServerDefinition,
|
||||
normalizeSessionToolingSelection,
|
||||
validateLspProfileDefinition,
|
||||
validateMcpServerDefinition,
|
||||
} from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { createId, nowIso } from '@shared/utils/ids';
|
||||
import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||
@@ -193,6 +207,96 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async saveMcpServer(server: McpServerDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const existingIndex = workspace.settings.tooling.mcpServers.findIndex(
|
||||
(current) => current.id === server.id,
|
||||
);
|
||||
const timestamp = nowIso();
|
||||
const candidate = normalizeMcpServerDefinition({
|
||||
...server,
|
||||
createdAt:
|
||||
existingIndex >= 0
|
||||
? workspace.settings.tooling.mcpServers[existingIndex].createdAt
|
||||
: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const issue = validateMcpServerDefinition(candidate);
|
||||
if (issue) {
|
||||
throw new Error(issue);
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
workspace.settings.tooling.mcpServers[existingIndex] = candidate;
|
||||
} else {
|
||||
workspace.settings.tooling.mcpServers.push(candidate);
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async deleteMcpServer(serverId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.settings.tooling.mcpServers = workspace.settings.tooling.mcpServers.filter(
|
||||
(server) => server.id !== serverId,
|
||||
);
|
||||
|
||||
for (const session of workspace.sessions) {
|
||||
const selection = resolveSessionToolingSelection(session);
|
||||
session.tooling = {
|
||||
...selection,
|
||||
enabledMcpServerIds: selection.enabledMcpServerIds.filter((id) => id !== serverId),
|
||||
};
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async saveLspProfile(profile: LspProfileDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const existingIndex = workspace.settings.tooling.lspProfiles.findIndex(
|
||||
(current) => current.id === profile.id,
|
||||
);
|
||||
const timestamp = nowIso();
|
||||
const candidate = normalizeLspProfileDefinition({
|
||||
...profile,
|
||||
createdAt:
|
||||
existingIndex >= 0
|
||||
? workspace.settings.tooling.lspProfiles[existingIndex].createdAt
|
||||
: timestamp,
|
||||
updatedAt: timestamp,
|
||||
});
|
||||
const issue = validateLspProfileDefinition(candidate);
|
||||
if (issue) {
|
||||
throw new Error(issue);
|
||||
}
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
workspace.settings.tooling.lspProfiles[existingIndex] = candidate;
|
||||
} else {
|
||||
workspace.settings.tooling.lspProfiles.push(candidate);
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async deleteLspProfile(profileId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.settings.tooling.lspProfiles = workspace.settings.tooling.lspProfiles.filter(
|
||||
(profile) => profile.id !== profileId,
|
||||
);
|
||||
|
||||
for (const session of workspace.sessions) {
|
||||
const selection = resolveSessionToolingSelection(session);
|
||||
session.tooling = {
|
||||
...selection,
|
||||
enabledLspProfileIds: selection.enabledLspProfileIds.filter((id) => id !== profileId),
|
||||
};
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
@@ -213,6 +317,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
scratchpadConfig: isScratchpadProject(project)
|
||||
? createScratchpadSessionConfig(normalizedPattern)
|
||||
: undefined,
|
||||
tooling: createSessionToolingSelection(),
|
||||
};
|
||||
|
||||
workspace.sessions.unshift(session);
|
||||
@@ -303,6 +408,7 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workspaceKind,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
tooling: this.buildRunTurnToolingConfig(workspace, project, session),
|
||||
},
|
||||
async (event) => {
|
||||
await this.applyTurnDelta(workspace, session.id, event);
|
||||
@@ -366,6 +472,57 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async updateSessionTooling(
|
||||
sessionId: string,
|
||||
enabledMcpServerIds: string[],
|
||||
enabledLspProfileIds: string[],
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
|
||||
if (session.status === 'running') {
|
||||
throw new Error('Wait for the current response to finish before changing session tools.');
|
||||
}
|
||||
|
||||
const selection = normalizeSessionToolingSelection({
|
||||
enabledMcpServerIds,
|
||||
enabledLspProfileIds,
|
||||
});
|
||||
|
||||
if (
|
||||
isScratchpadProject(project)
|
||||
&& (selection.enabledMcpServerIds.length > 0 || selection.enabledLspProfileIds.length > 0)
|
||||
) {
|
||||
throw new Error('Scratchpad sessions do not support MCP or LSP tools.');
|
||||
}
|
||||
|
||||
const knownMcpServerIds = new Set(
|
||||
workspace.settings.tooling.mcpServers.map((server) => server.id),
|
||||
);
|
||||
const knownLspProfileIds = new Set(
|
||||
workspace.settings.tooling.lspProfiles.map((profile) => profile.id),
|
||||
);
|
||||
|
||||
const unknownMcpServerIds = selection.enabledMcpServerIds.filter(
|
||||
(id) => !knownMcpServerIds.has(id),
|
||||
);
|
||||
if (unknownMcpServerIds.length > 0) {
|
||||
throw new Error(`Unknown MCP server "${unknownMcpServerIds[0]}".`);
|
||||
}
|
||||
|
||||
const unknownLspProfileIds = selection.enabledLspProfileIds.filter(
|
||||
(id) => !knownLspProfileIds.has(id),
|
||||
);
|
||||
if (unknownLspProfileIds.length > 0) {
|
||||
throw new Error(`Unknown LSP profile "${unknownLspProfileIds[0]}".`);
|
||||
}
|
||||
|
||||
session.tooling = selection;
|
||||
session.updatedAt = nowIso();
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
return queryWorkspaceSessions(workspace, input);
|
||||
@@ -590,6 +747,86 @@ export class EryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return normalizePatternModels(patternWithSessionConfig, modelCatalog);
|
||||
}
|
||||
|
||||
private buildRunTurnToolingConfig(
|
||||
workspace: WorkspaceState,
|
||||
project: ProjectRecord,
|
||||
session: SessionRecord,
|
||||
): RunTurnToolingConfig | undefined {
|
||||
if (isScratchpadProject(project)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const selection = resolveSessionToolingSelection(session);
|
||||
const mcpServersById = new Map<string, McpServerDefinition>(
|
||||
workspace.settings.tooling.mcpServers.map((server) => [server.id, server]),
|
||||
);
|
||||
const lspProfilesById = new Map<string, LspProfileDefinition>(
|
||||
workspace.settings.tooling.lspProfiles.map((profile) => [profile.id, profile]),
|
||||
);
|
||||
|
||||
const mcpServers = selection.enabledMcpServerIds.flatMap((id): RunTurnMcpServerConfig[] => {
|
||||
const server = mcpServersById.get(id);
|
||||
if (!server) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (server.transport === 'local') {
|
||||
return [
|
||||
{
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: 'local',
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
command: server.command,
|
||||
args: [...server.args],
|
||||
cwd: server.cwd,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: server.transport,
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
url: server.url,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const lspProfiles = selection.enabledLspProfileIds.flatMap(
|
||||
(id): RunTurnLspProfileConfig[] => {
|
||||
const profile = lspProfilesById.get(id);
|
||||
if (!profile) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: profile.id,
|
||||
name: profile.name,
|
||||
command: profile.command,
|
||||
args: [...profile.args],
|
||||
languageId: profile.languageId,
|
||||
fileExtensions: [...profile.fileExtensions],
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
|
||||
if (mcpServers.length === 0 && lspProfiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
mcpServers,
|
||||
lspProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
private emitSessionEvent(event: SessionEventRecord): void {
|
||||
this.emit('session-event', event);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ import type {
|
||||
CreateSessionInput,
|
||||
DuplicateSessionInput,
|
||||
RenameSessionInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
SendSessionMessageInput,
|
||||
SetPatternFavoriteInput,
|
||||
SetSessionArchivedInput,
|
||||
SetSessionPinnedInput,
|
||||
UpdateSessionToolingInput,
|
||||
UpdateScratchpadSessionConfigInput,
|
||||
} from '@shared/contracts/ipc';
|
||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
@@ -30,6 +33,25 @@ export function registerIpcHandlers(window: BrowserWindow, service: EryxAppServi
|
||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||
service.setPatternFavorite(input.patternId, input.isFavorite),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) =>
|
||||
service.saveMcpServer(input.server),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.deleteMcpServer, (_event, serverId: string) =>
|
||||
service.deleteMcpServer(serverId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.saveLspProfile, (_event, input: SaveLspProfileInput) =>
|
||||
service.saveLspProfile(input.profile),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.deleteLspProfile, (_event, profileId: string) =>
|
||||
service.deleteLspProfile(profileId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.updateSessionTooling, (_event, input: UpdateSessionToolingInput) =>
|
||||
service.updateSessionTooling(
|
||||
input.sessionId,
|
||||
input.enabledMcpServerIds,
|
||||
input.enabledLspProfileIds,
|
||||
),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||
service.createSession(input.projectId, input.patternId),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdir } from 'node:fs/promises';
|
||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { normalizeSessionToolingSelection, normalizeWorkspaceSettings } from '@shared/domain/tooling';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
@@ -59,7 +60,11 @@ export class WorkspaceRepository {
|
||||
...stored,
|
||||
patterns: mergePatterns(stored.patterns ?? []),
|
||||
projects,
|
||||
sessions: stored.sessions ?? [],
|
||||
sessions: (stored.sessions ?? []).map((session) => ({
|
||||
...session,
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
})),
|
||||
settings: normalizeWorkspaceSettings(stored.settings),
|
||||
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
||||
? stored.selectedProjectId
|
||||
: projects[0]?.id,
|
||||
|
||||
@@ -13,6 +13,11 @@ const api: ElectronApi = {
|
||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
|
||||
deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId),
|
||||
saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input),
|
||||
deleteLspProfile: (profileId) => ipcRenderer.invoke(ipcChannels.deleteLspProfile, profileId),
|
||||
updateSessionTooling: (input) => ipcRenderer.invoke(ipcChannels.updateSessionTooling, input),
|
||||
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
||||
duplicateSession: (input) => ipcRenderer.invoke(ipcChannels.duplicateSession, input),
|
||||
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
||||
|
||||
+66
-12
@@ -24,6 +24,7 @@ import {
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject } from '@shared/domain/project';
|
||||
import { applyScratchpadSessionConfig } from '@shared/domain/session';
|
||||
import type { LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { createId, nowIso } from '@shared/utils/ids';
|
||||
|
||||
@@ -51,6 +52,34 @@ function createDraftPattern(defaultModelId: string, defaultReasoningEffort: Patt
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftMcpServer(): McpServerDefinition {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id: createId('mcp'),
|
||||
name: 'New MCP Server',
|
||||
transport: 'local',
|
||||
command: '',
|
||||
args: [],
|
||||
tools: ['*'],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftLspProfile(): LspProfileDefinition {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id: createId('lsp'),
|
||||
name: 'New LSP Profile',
|
||||
command: '',
|
||||
args: [],
|
||||
languageId: 'typescript',
|
||||
fileExtensions: ['.ts', '.tsx'],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const api = getElectronApi();
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||
@@ -195,7 +224,17 @@ export default function App() {
|
||||
detailPanel = (
|
||||
<ActivityPanel
|
||||
activity={activityForSession}
|
||||
lspProfiles={workspace.settings.tooling.lspProfiles}
|
||||
mcpServers={workspace.settings.tooling.mcpServers}
|
||||
onUpdateSessionTooling={(selection) => {
|
||||
void api.updateSessionTooling({
|
||||
sessionId: selectedSession.id,
|
||||
enabledMcpServerIds: selection.enabledMcpServerIds,
|
||||
enabledLspProfileIds: selection.enabledLspProfileIds,
|
||||
});
|
||||
}}
|
||||
pattern={patternForSession}
|
||||
projectIsScratchpad={isScratchpadProject(projectForSession)}
|
||||
session={selectedSession}
|
||||
/>
|
||||
);
|
||||
@@ -216,24 +255,39 @@ export default function App() {
|
||||
availableModels={availableModels}
|
||||
isRefreshingCapabilities={isRefreshingCapabilities}
|
||||
onClose={() => setShowSettings(false)}
|
||||
onDeletePattern={async (id) => {
|
||||
await api.deletePattern(id);
|
||||
}}
|
||||
onNewPattern={() => {
|
||||
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
||||
onDeleteLspProfile={async (id) => {
|
||||
await api.deleteLspProfile(id);
|
||||
}}
|
||||
onDeleteMcpServer={async (id) => {
|
||||
await api.deleteMcpServer(id);
|
||||
}}
|
||||
onDeletePattern={async (id) => {
|
||||
await api.deletePattern(id);
|
||||
}}
|
||||
onNewLspProfile={createDraftLspProfile}
|
||||
onNewMcpServer={createDraftMcpServer}
|
||||
onNewPattern={() => {
|
||||
const defaultModel = availableModels[0] ?? findModel('gpt-5.4', availableModels) ?? findModel('gpt-5.4');
|
||||
|
||||
return createDraftPattern(
|
||||
defaultModel?.id ?? 'gpt-5.4',
|
||||
resolveReasoningEffort(defaultModel, 'high'),
|
||||
);
|
||||
}}
|
||||
onRefreshCapabilities={refreshCapabilities}
|
||||
onSavePattern={async (pattern) => {
|
||||
await api.savePattern({ pattern });
|
||||
}}
|
||||
patterns={workspace.patterns}
|
||||
sidecarCapabilities={sidecarCapabilities}
|
||||
/>
|
||||
onRefreshCapabilities={refreshCapabilities}
|
||||
onSaveLspProfile={async (profile) => {
|
||||
await api.saveLspProfile({ profile });
|
||||
}}
|
||||
onSaveMcpServer={async (server) => {
|
||||
await api.saveMcpServer({ server });
|
||||
}}
|
||||
onSavePattern={async (pattern) => {
|
||||
await api.savePattern({ pattern });
|
||||
}}
|
||||
patterns={workspace.patterns}
|
||||
sidecarCapabilities={sidecarCapabilities}
|
||||
toolingSettings={workspace.settings.tooling}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
resolveSessionToolingSelection,
|
||||
type SessionRecord,
|
||||
} from '@shared/domain/session';
|
||||
import type {
|
||||
LspProfileDefinition,
|
||||
McpServerDefinition,
|
||||
SessionToolingSelection,
|
||||
} from '@shared/domain/tooling';
|
||||
import { ProviderIcon } from './ProviderIcons';
|
||||
|
||||
function formatModel(model: string): string {
|
||||
@@ -30,17 +38,31 @@ function formatEffort(effort: string | undefined): string | undefined {
|
||||
|
||||
interface ActivityPanelProps {
|
||||
activity?: SessionActivityState;
|
||||
lspProfiles: LspProfileDefinition[];
|
||||
mcpServers: McpServerDefinition[];
|
||||
onUpdateSessionTooling: (selection: SessionToolingSelection) => void;
|
||||
pattern: PatternDefinition;
|
||||
projectIsScratchpad: boolean;
|
||||
session: SessionRecord;
|
||||
}
|
||||
|
||||
export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps) {
|
||||
export function ActivityPanel({
|
||||
activity,
|
||||
lspProfiles,
|
||||
mcpServers,
|
||||
onUpdateSessionTooling,
|
||||
pattern,
|
||||
projectIsScratchpad,
|
||||
session,
|
||||
}: ActivityPanelProps) {
|
||||
const activityRows = useMemo(
|
||||
() => buildAgentActivityRows(activity, pattern.agents),
|
||||
[activity, pattern.agents],
|
||||
);
|
||||
const selection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||
|
||||
const isBusy = session.status === 'running';
|
||||
const toolsDisabled = isBusy || projectIsScratchpad;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
@@ -57,7 +79,71 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps
|
||||
|
||||
{/* Agent cards */}
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-[12px] font-semibold text-zinc-200">Session tools</h3>
|
||||
<p className="mt-0.5 text-[11px] text-zinc-500">
|
||||
Enable globally configured MCPs and LSPs for this session.
|
||||
</p>
|
||||
</div>
|
||||
{toolsDisabled && (
|
||||
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium text-zinc-400">
|
||||
{projectIsScratchpad ? 'Scratchpad disabled' : 'Locked while running'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{projectIsScratchpad ? (
|
||||
<p className="mt-3 text-[11px] leading-relaxed text-zinc-500">
|
||||
Scratchpad stays tool-free. Start a project-backed session to use MCPs or LSPs.
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-3">
|
||||
<ToolToggleGroup
|
||||
description="Globally configured MCP servers"
|
||||
emptyMessage="No MCP servers configured in Settings."
|
||||
enabledIds={selection.enabledMcpServerIds}
|
||||
items={mcpServers.map((server) => ({
|
||||
id: server.id,
|
||||
label: server.name,
|
||||
detail:
|
||||
server.transport === 'local'
|
||||
? server.command
|
||||
: server.url,
|
||||
}))}
|
||||
onToggle={(id) =>
|
||||
onUpdateSessionTooling({
|
||||
...selection,
|
||||
enabledMcpServerIds: toggleId(selection.enabledMcpServerIds, id),
|
||||
})
|
||||
}
|
||||
title="MCP servers"
|
||||
disabled={toolsDisabled}
|
||||
/>
|
||||
<ToolToggleGroup
|
||||
description="Globally configured LSP profiles"
|
||||
emptyMessage="No LSP profiles configured in Settings."
|
||||
enabledIds={selection.enabledLspProfileIds}
|
||||
items={lspProfiles.map((profile) => ({
|
||||
id: profile.id,
|
||||
label: profile.name,
|
||||
detail: `${profile.languageId} · ${profile.command}`,
|
||||
}))}
|
||||
onToggle={(id) =>
|
||||
onUpdateSessionTooling({
|
||||
...selection,
|
||||
enabledLspProfileIds: toggleId(selection.enabledLspProfileIds, id),
|
||||
})
|
||||
}
|
||||
title="LSP profiles"
|
||||
disabled={toolsDisabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activityRows.map((row, index) => {
|
||||
const agent = pattern.agents[index];
|
||||
const isActive = isAgentActivityActive(row.activity);
|
||||
@@ -149,3 +235,76 @@ export function ActivityPanel({ activity, pattern, session }: ActivityPanelProps
|
||||
);
|
||||
}
|
||||
|
||||
function ToolToggleGroup({
|
||||
title,
|
||||
description,
|
||||
items,
|
||||
enabledIds,
|
||||
onToggle,
|
||||
emptyMessage,
|
||||
disabled,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
items: Array<{ id: string; label: string; detail?: string }>;
|
||||
enabledIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
emptyMessage: string;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<h4 className="text-[11px] font-semibold uppercase tracking-[0.12em] text-zinc-500">
|
||||
{title}
|
||||
</h4>
|
||||
<p className="mt-0.5 text-[11px] text-zinc-600">{description}</p>
|
||||
</div>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[11px] text-zinc-600">{emptyMessage}</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item) => {
|
||||
const enabled = enabledIds.includes(item.id);
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition ${
|
||||
enabled
|
||||
? 'border-blue-500/30 bg-blue-500/5'
|
||||
: 'border-zinc-800 bg-zinc-900/30'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-60' : 'hover:border-zinc-700 hover:bg-zinc-900/60'}`}
|
||||
disabled={disabled}
|
||||
key={item.id}
|
||||
onClick={() => onToggle(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[12px] font-medium text-zinc-200">{item.label}</div>
|
||||
{item.detail && (
|
||||
<div className="truncate text-[11px] text-zinc-500">{item.detail}</div>
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
enabled
|
||||
? 'bg-blue-500/10 text-blue-300'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
{enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toggleId(current: string[], id: string): string[] {
|
||||
return current.includes(id)
|
||||
? current.filter((currentId) => currentId !== id)
|
||||
: [...current, id];
|
||||
}
|
||||
|
||||
@@ -1,30 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type HTMLAttributes, type ReactNode } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Cpu, Layers, Plus, Workflow } from 'lucide-react';
|
||||
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
|
||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import {
|
||||
normalizeLspProfileDefinition,
|
||||
normalizeMcpServerDefinition,
|
||||
type LspProfileDefinition,
|
||||
type McpServerDefinition,
|
||||
type WorkspaceToolingSettings,
|
||||
validateLspProfileDefinition,
|
||||
validateMcpServerDefinition,
|
||||
} from '@shared/domain/tooling';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
patterns: PatternDefinition[];
|
||||
sidecarCapabilities?: SidecarCapabilities;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
isRefreshingCapabilities: boolean;
|
||||
onRefreshCapabilities: () => void;
|
||||
onClose: () => void;
|
||||
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||
onDeletePattern: (patternId: string) => Promise<void>;
|
||||
onNewPattern: () => PatternDefinition;
|
||||
onSaveMcpServer: (server: McpServerDefinition) => Promise<void>;
|
||||
onDeleteMcpServer: (serverId: string) => Promise<void>;
|
||||
onNewMcpServer: () => McpServerDefinition;
|
||||
onSaveLspProfile: (profile: LspProfileDefinition) => Promise<void>;
|
||||
onDeleteLspProfile: (profileId: string) => Promise<void>;
|
||||
onNewLspProfile: () => LspProfileDefinition;
|
||||
}
|
||||
|
||||
type SettingsSection = 'connection' | 'patterns';
|
||||
type SettingsSection = 'connection' | 'patterns' | 'mcp-servers' | 'lsp-profiles';
|
||||
|
||||
interface NavItem {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
icon: ReactNode;
|
||||
}
|
||||
|
||||
interface NavGroup {
|
||||
@@ -45,6 +62,13 @@ const navGroups: NavGroup[] = [
|
||||
{ id: 'patterns', label: 'Patterns', icon: <Workflow className="size-3.5" /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Tooling',
|
||||
items: [
|
||||
{ id: 'mcp-servers', label: 'MCP Servers', icon: <Layers className="size-3.5" /> },
|
||||
{ id: 'lsp-profiles', label: 'LSP Profiles', icon: <Cpu className="size-3.5" /> },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function modeBadgeClasses(pattern: PatternDefinition) {
|
||||
@@ -56,17 +80,25 @@ export function SettingsPanel({
|
||||
availableModels,
|
||||
patterns,
|
||||
sidecarCapabilities,
|
||||
toolingSettings,
|
||||
isRefreshingCapabilities,
|
||||
onRefreshCapabilities,
|
||||
onClose,
|
||||
onSavePattern,
|
||||
onDeletePattern,
|
||||
onNewPattern,
|
||||
onSaveMcpServer,
|
||||
onDeleteMcpServer,
|
||||
onNewMcpServer,
|
||||
onSaveLspProfile,
|
||||
onDeleteLspProfile,
|
||||
onNewLspProfile,
|
||||
}: SettingsPanelProps) {
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('connection');
|
||||
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||
const [editingMcpServer, setEditingMcpServer] = useState<McpServerDefinition | null>(null);
|
||||
const [editingLspProfile, setEditingLspProfile] = useState<LspProfileDefinition | null>(null);
|
||||
|
||||
// Pattern editor sub-view
|
||||
if (editingPattern) {
|
||||
const isBuiltin = editingPattern.id.startsWith('pattern-');
|
||||
return (
|
||||
@@ -94,9 +126,58 @@ export function SettingsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
if (editingMcpServer) {
|
||||
const exists = toolingSettings.mcpServers.some((server) => server.id === editingMcpServer.id);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||
<McpServerEditor
|
||||
onBack={() => setEditingMcpServer(null)}
|
||||
onChange={setEditingMcpServer}
|
||||
onDelete={
|
||||
exists
|
||||
? async () => {
|
||||
await onDeleteMcpServer(editingMcpServer.id);
|
||||
setEditingMcpServer(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSave={async () => {
|
||||
await onSaveMcpServer(normalizeMcpServerDefinition(editingMcpServer));
|
||||
setEditingMcpServer(null);
|
||||
}}
|
||||
server={editingMcpServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (editingLspProfile) {
|
||||
const exists = toolingSettings.lspProfiles.some((profile) => profile.id === editingLspProfile.id);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||
<LspProfileEditor
|
||||
onBack={() => setEditingLspProfile(null)}
|
||||
onChange={setEditingLspProfile}
|
||||
onDelete={
|
||||
exists
|
||||
? async () => {
|
||||
await onDeleteLspProfile(editingLspProfile.id);
|
||||
setEditingLspProfile(null);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSave={async () => {
|
||||
await onSaveLspProfile(normalizeLspProfileDefinition(editingLspProfile));
|
||||
setEditingLspProfile(null);
|
||||
}}
|
||||
profile={editingLspProfile}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-12">
|
||||
<button
|
||||
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
@@ -108,9 +189,7 @@ export function SettingsPanel({
|
||||
<h2 className="text-sm font-semibold text-zinc-100">Settings</h2>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* Left navigation */}
|
||||
<nav className="w-52 shrink-0 border-r border-[var(--color-border)] bg-[var(--color-surface-1)] p-3">
|
||||
<div className="space-y-4">
|
||||
{navGroups.map((group) => (
|
||||
@@ -143,7 +222,6 @@ export function SettingsPanel({
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl px-8 py-6">
|
||||
{activeSection === 'connection' && (
|
||||
@@ -156,11 +234,25 @@ export function SettingsPanel({
|
||||
)}
|
||||
{activeSection === 'patterns' && (
|
||||
<PatternsSection
|
||||
onEditPattern={(p) => setEditingPattern(structuredClone(p))}
|
||||
onEditPattern={(pattern) => setEditingPattern(structuredClone(pattern))}
|
||||
onNewPattern={() => setEditingPattern(onNewPattern())}
|
||||
patterns={patterns}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'mcp-servers' && (
|
||||
<McpServersSection
|
||||
onEditServer={(server) => setEditingMcpServer(structuredClone(server))}
|
||||
onNewServer={() => setEditingMcpServer(onNewMcpServer())}
|
||||
servers={toolingSettings.mcpServers}
|
||||
/>
|
||||
)}
|
||||
{activeSection === 'lsp-profiles' && (
|
||||
<LspProfilesSection
|
||||
onEditProfile={(profile) => setEditingLspProfile(structuredClone(profile))}
|
||||
onNewProfile={() => setEditingLspProfile(onNewLspProfile())}
|
||||
profiles={toolingSettings.lspProfiles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -168,8 +260,6 @@ export function SettingsPanel({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Section components ---------- */
|
||||
|
||||
function ConnectionSection({
|
||||
connection,
|
||||
modelCount,
|
||||
@@ -212,22 +302,12 @@ function PatternsSection({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-200">Orchestration Patterns</h3>
|
||||
<p className="mt-0.5 text-[12px] text-zinc-500">
|
||||
Define reusable agent configurations for your sessions
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
||||
onClick={onNewPattern}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
New Pattern
|
||||
</button>
|
||||
</div>
|
||||
<SectionHeader
|
||||
description="Define reusable agent configurations for your sessions"
|
||||
title="Orchestration Patterns"
|
||||
>
|
||||
<SectionAction label="New Pattern" onClick={onNewPattern} />
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-1">
|
||||
{patterns.map((pattern) => (
|
||||
@@ -258,3 +338,587 @@ function PatternsSection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpServersSection({
|
||||
servers,
|
||||
onEditServer,
|
||||
onNewServer,
|
||||
}: {
|
||||
servers: McpServerDefinition[];
|
||||
onEditServer: (server: McpServerDefinition) => void;
|
||||
onNewServer: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Define machine-wide MCP servers that sessions can enable from the Activity panel."
|
||||
title="MCP Servers"
|
||||
>
|
||||
<SectionAction label="New MCP Server" onClick={onNewServer} />
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-1">
|
||||
{servers.length === 0 && (
|
||||
<EmptyState>
|
||||
No MCP servers configured yet. Add one here, then enable it per session from the Activity panel.
|
||||
</EmptyState>
|
||||
)}
|
||||
{servers.map((server) => (
|
||||
<ToolingListButton
|
||||
detail={
|
||||
server.transport === 'local'
|
||||
? `${server.command || 'No command'} · ${server.tools.length} tool filter${server.tools.length === 1 ? '' : 's'}`
|
||||
: `${server.url} · ${server.transport.toUpperCase()}`
|
||||
}
|
||||
key={server.id}
|
||||
label={server.name}
|
||||
meta={server.transport.toUpperCase()}
|
||||
onClick={() => onEditServer(server)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LspProfilesSection({
|
||||
profiles,
|
||||
onEditProfile,
|
||||
onNewProfile,
|
||||
}: {
|
||||
profiles: LspProfileDefinition[];
|
||||
onEditProfile: (profile: LspProfileDefinition) => void;
|
||||
onNewProfile: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<SectionHeader
|
||||
description="Define machine-wide LSP commands that sessions can enable from the Activity panel."
|
||||
title="LSP Profiles"
|
||||
>
|
||||
<SectionAction label="New LSP Profile" onClick={onNewProfile} />
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-1">
|
||||
{profiles.length === 0 && (
|
||||
<EmptyState>
|
||||
No LSP profiles configured yet. Add one here, then enable it per session from the Activity panel.
|
||||
</EmptyState>
|
||||
)}
|
||||
{profiles.map((profile) => (
|
||||
<ToolingListButton
|
||||
detail={`${profile.languageId} · ${profile.command || 'No command'}`}
|
||||
key={profile.id}
|
||||
label={profile.name}
|
||||
meta={profile.fileExtensions.join(', ')}
|
||||
onClick={() => onEditProfile(profile)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpServerEditor({
|
||||
server,
|
||||
onChange,
|
||||
onBack,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
server: McpServerDefinition;
|
||||
onChange: (server: McpServerDefinition) => void;
|
||||
onBack: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
onDelete?: () => Promise<void>;
|
||||
}) {
|
||||
const validationError = validateMcpServerDefinition(server);
|
||||
|
||||
return (
|
||||
<ToolingEditorShell
|
||||
description="Configure a machine-wide MCP server. Sessions can opt into this server from the Activity panel."
|
||||
disableSave={Boolean(validationError)}
|
||||
error={validationError}
|
||||
onBack={onBack}
|
||||
onDelete={onDelete}
|
||||
onSave={onSave}
|
||||
title="MCP Server"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="Name" required>
|
||||
<TextInput
|
||||
onChange={(value) => onChange(updateMcpServer(server, { name: value }))}
|
||||
value={server.name}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Transport" required>
|
||||
<SelectInput
|
||||
onChange={(value) => onChange(changeMcpTransport(server, value as McpServerDefinition['transport']))}
|
||||
options={[
|
||||
{ value: 'local', label: 'Local process' },
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'sse', label: 'SSE' },
|
||||
]}
|
||||
value={server.transport}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{server.transport === 'local' ? (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField className="md:col-span-2" label="Command" required>
|
||||
<TextInput
|
||||
onChange={(value) => onChange(updateMcpServer(server, { command: value }))}
|
||||
placeholder="node"
|
||||
value={server.command}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="md:col-span-2" label="Arguments">
|
||||
<TextareaInput
|
||||
onChange={(value) => onChange(updateMcpServer(server, { args: splitMultiline(value) }))}
|
||||
placeholder="One argument per line"
|
||||
rows={4}
|
||||
value={joinMultiline(server.args)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField className="md:col-span-2" label="Working directory">
|
||||
<TextInput
|
||||
onChange={(value) => onChange(updateMcpServer(server, { cwd: value || undefined }))}
|
||||
placeholder="Optional"
|
||||
value={server.cwd ?? ''}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
) : (
|
||||
<FormField label="Server URL" required>
|
||||
<TextInput
|
||||
onChange={(value) => onChange(updateMcpServer(server, { url: value }))}
|
||||
placeholder="https://example.com/mcp"
|
||||
value={server.url}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="Allowed tools">
|
||||
<TextareaInput
|
||||
onChange={(value) => onChange(updateMcpServer(server, { tools: splitTokens(value) }))}
|
||||
placeholder="Use * for all tools, or list one tool per line"
|
||||
rows={4}
|
||||
value={joinMultiline(server.tools)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Timeout (ms)">
|
||||
<TextInput
|
||||
inputMode="numeric"
|
||||
onChange={(value) =>
|
||||
onChange(
|
||||
updateMcpServer(server, {
|
||||
timeoutMs: value.trim() ? Number(value) : undefined,
|
||||
}),
|
||||
)
|
||||
}
|
||||
placeholder="Optional"
|
||||
value={server.timeoutMs?.toString() ?? ''}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<InfoCallout>
|
||||
Keep secrets out of this form. Use commands or endpoints that authenticate through the OS or external tooling.
|
||||
</InfoCallout>
|
||||
</ToolingEditorShell>
|
||||
);
|
||||
}
|
||||
|
||||
function LspProfileEditor({
|
||||
profile,
|
||||
onChange,
|
||||
onBack,
|
||||
onSave,
|
||||
onDelete,
|
||||
}: {
|
||||
profile: LspProfileDefinition;
|
||||
onChange: (profile: LspProfileDefinition) => void;
|
||||
onBack: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
onDelete?: () => Promise<void>;
|
||||
}) {
|
||||
const validationError = validateLspProfileDefinition(profile);
|
||||
|
||||
return (
|
||||
<ToolingEditorShell
|
||||
description="Configure a machine-wide LSP command. Sessions can opt into this profile from the Activity panel."
|
||||
disableSave={Boolean(validationError)}
|
||||
error={validationError}
|
||||
onBack={onBack}
|
||||
onDelete={onDelete}
|
||||
onSave={onSave}
|
||||
title="LSP Profile"
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="Name" required>
|
||||
<TextInput
|
||||
onChange={(value) => onChange(updateLspProfile(profile, { name: value }))}
|
||||
value={profile.name}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Language ID" required>
|
||||
<TextInput
|
||||
onChange={(value) => onChange(updateLspProfile(profile, { languageId: value }))}
|
||||
placeholder="typescript"
|
||||
value={profile.languageId}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Command" required>
|
||||
<TextInput
|
||||
onChange={(value) => onChange(updateLspProfile(profile, { command: value }))}
|
||||
placeholder="typescript-language-server"
|
||||
value={profile.command}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<FormField label="Arguments">
|
||||
<TextareaInput
|
||||
onChange={(value) => onChange(updateLspProfile(profile, { args: splitMultiline(value) }))}
|
||||
placeholder="One argument per line"
|
||||
rows={4}
|
||||
value={joinMultiline(profile.args)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="File extensions" required>
|
||||
<TextareaInput
|
||||
onChange={(value) => onChange(updateLspProfile(profile, { fileExtensions: splitTokens(value) }))}
|
||||
placeholder={'.ts\n.tsx'}
|
||||
rows={4}
|
||||
value={joinMultiline(profile.fileExtensions)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<InfoCallout>
|
||||
Profiles are global definitions only. Project root resolution still comes from the active session's project.
|
||||
</InfoCallout>
|
||||
</ToolingEditorShell>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-zinc-200">{title}</h3>
|
||||
<p className="mt-0.5 text-[12px] text-zinc-500">{description}</p>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionAction({ label, onClick }: { label: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
className="flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolingListButton({
|
||||
label,
|
||||
detail,
|
||||
meta,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
detail: string;
|
||||
meta: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="group flex w-full items-center gap-3 rounded-xl border border-transparent px-4 py-3 text-left transition hover:border-zinc-800 hover:bg-zinc-900"
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-medium text-zinc-200">{label}</span>
|
||||
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-zinc-400">
|
||||
{meta}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-[12px] text-zinc-500">{detail}</p>
|
||||
</div>
|
||||
<ChevronRight className="size-4 text-zinc-700 transition group-hover:text-zinc-500" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-zinc-800 bg-zinc-900/30 px-4 py-6 text-[12px] leading-relaxed text-zinc-500">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolingEditorShell({
|
||||
title,
|
||||
description,
|
||||
error,
|
||||
disableSave,
|
||||
onBack,
|
||||
onSave,
|
||||
onDelete,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
error?: string;
|
||||
disableSave: boolean;
|
||||
onBack: () => void;
|
||||
onSave: () => Promise<void>;
|
||||
onDelete?: () => Promise<void>;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-[var(--color-border)] px-5 pb-3 pt-12">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||
onClick={onBack}
|
||||
type="button"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-100">{title}</h2>
|
||||
<p className="mt-0.5 text-[12px] text-zinc-500">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{onDelete && (
|
||||
<button
|
||||
className="rounded-lg border border-zinc-700 px-3 py-1.5 text-[13px] font-medium text-zinc-300 transition hover:border-red-500/40 hover:bg-red-500/10 hover:text-red-300"
|
||||
onClick={() => void onDelete()}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="rounded-lg bg-zinc-100 px-3 py-1.5 text-[13px] font-semibold text-zinc-900 transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={disableSave}
|
||||
onClick={() => void onSave()}
|
||||
type="button"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-2xl space-y-5 px-8 py-6">
|
||||
{error && (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-[12px] text-amber-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({
|
||||
label,
|
||||
required,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label className={`block ${className ?? ''}`}>
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-zinc-300">
|
||||
{label}
|
||||
{required && <span className="ml-1 text-amber-300">*</span>}
|
||||
</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
inputMode,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
inputMode?: HTMLAttributes<HTMLInputElement>['inputMode'];
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition placeholder:text-zinc-600 focus:border-zinc-600"
|
||||
inputMode={inputMode}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
rows,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
rows: number;
|
||||
}) {
|
||||
return (
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition placeholder:text-zinc-600 focus:border-zinc-600"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectInput({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition focus:border-zinc-600"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
value={value}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoCallout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-zinc-800 bg-zinc-900/30 px-4 py-3 text-[12px] leading-relaxed text-zinc-500">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function updateMcpServer(
|
||||
server: McpServerDefinition,
|
||||
patch: Partial<McpServerDefinition>,
|
||||
): McpServerDefinition;
|
||||
function updateMcpServer<T extends McpServerDefinition>(server: T, patch: Partial<T>): T;
|
||||
function updateMcpServer<T extends McpServerDefinition>(server: T, patch: Partial<T>): T {
|
||||
return {
|
||||
...server,
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function changeMcpTransport(
|
||||
server: McpServerDefinition,
|
||||
transport: McpServerDefinition['transport'],
|
||||
): McpServerDefinition {
|
||||
if (transport === server.transport) {
|
||||
return server;
|
||||
}
|
||||
|
||||
if (transport === 'local') {
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport: 'local',
|
||||
command: '',
|
||||
args: [],
|
||||
cwd: undefined,
|
||||
tools: server.tools,
|
||||
timeoutMs: server.timeoutMs,
|
||||
createdAt: server.createdAt,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
transport,
|
||||
url: server.transport === 'local' ? '' : server.url,
|
||||
tools: server.tools,
|
||||
timeoutMs: server.timeoutMs,
|
||||
createdAt: server.createdAt,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function updateLspProfile(
|
||||
profile: LspProfileDefinition,
|
||||
patch: Partial<LspProfileDefinition>,
|
||||
): LspProfileDefinition {
|
||||
return {
|
||||
...profile,
|
||||
...patch,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
}
|
||||
|
||||
function splitMultiline(value: string): string[] {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function splitTokens(value: string): string[] {
|
||||
return value
|
||||
.split(/[\r\n,]+/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function joinMultiline(value: string[]): string {
|
||||
return value.join('\n');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,11 @@ export const ipcChannels = {
|
||||
savePattern: 'patterns:save',
|
||||
deletePattern: 'patterns:delete',
|
||||
setPatternFavorite: 'patterns:set-favorite',
|
||||
saveMcpServer: 'tooling:mcp:save',
|
||||
deleteMcpServer: 'tooling:mcp:delete',
|
||||
saveLspProfile: 'tooling:lsp:save',
|
||||
deleteLspProfile: 'tooling:lsp:delete',
|
||||
updateSessionTooling: 'sessions:update-tooling',
|
||||
createSession: 'sessions:create',
|
||||
duplicateSession: 'sessions:duplicate',
|
||||
renameSession: 'sessions:rename',
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type {
|
||||
LspProfileDefinition,
|
||||
McpServerDefinition,
|
||||
SessionToolingSelection,
|
||||
} from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
export interface CreateSessionInput {
|
||||
@@ -49,6 +54,18 @@ export interface SetPatternFavoriteInput {
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface SaveMcpServerInput {
|
||||
server: McpServerDefinition;
|
||||
}
|
||||
|
||||
export interface SaveLspProfileInput {
|
||||
profile: LspProfileDefinition;
|
||||
}
|
||||
|
||||
export interface UpdateSessionToolingInput extends SessionToolingSelection {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
@@ -58,6 +75,11 @@ export interface ElectronApi {
|
||||
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||
deleteLspProfile(profileId: string): Promise<WorkspaceState>;
|
||||
updateSessionTooling(input: UpdateSessionToolingInput): Promise<WorkspaceState>;
|
||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||
duplicateSession(input: DuplicateSessionInput): Promise<WorkspaceState>;
|
||||
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
||||
|
||||
@@ -73,10 +73,47 @@ export interface RunTurnCommand {
|
||||
workspaceKind?: 'project' | 'scratchpad';
|
||||
pattern: PatternDefinition;
|
||||
messages: ChatMessageRecord[];
|
||||
tooling?: RunTurnToolingConfig;
|
||||
}
|
||||
|
||||
export type SidecarCommand = DescribeCapabilitiesCommand | ValidatePatternCommand | RunTurnCommand;
|
||||
|
||||
export interface RunTurnLocalMcpServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: 'local';
|
||||
tools: string[];
|
||||
timeoutMs?: number;
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface RunTurnRemoteMcpServerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: 'http' | 'sse';
|
||||
tools: string[];
|
||||
timeoutMs?: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type RunTurnMcpServerConfig = RunTurnLocalMcpServerConfig | RunTurnRemoteMcpServerConfig;
|
||||
|
||||
export interface RunTurnLspProfileConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
languageId: string;
|
||||
fileExtensions: string[];
|
||||
}
|
||||
|
||||
export interface RunTurnToolingConfig {
|
||||
mcpServers: RunTurnMcpServerConfig[];
|
||||
lspProfiles: RunTurnLspProfileConfig[];
|
||||
}
|
||||
|
||||
export interface CapabilitiesEvent {
|
||||
type: 'capabilities';
|
||||
requestId: string;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { buildSessionTitle, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
||||
import {
|
||||
createSessionToolingSelection,
|
||||
normalizeSessionToolingSelection,
|
||||
type SessionToolingSelection,
|
||||
} from '@shared/domain/tooling';
|
||||
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||
@@ -32,6 +37,7 @@ export interface SessionRecord {
|
||||
messages: ChatMessageRecord[];
|
||||
lastError?: string;
|
||||
scratchpadConfig?: ScratchpadSessionConfig;
|
||||
tooling?: SessionToolingSelection;
|
||||
}
|
||||
|
||||
export function resolveSessionTitle(
|
||||
@@ -60,6 +66,12 @@ export function createScratchpadSessionConfig(
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveSessionToolingSelection(
|
||||
session: Pick<SessionRecord, 'tooling'>,
|
||||
): SessionToolingSelection {
|
||||
return normalizeSessionToolingSelection(session.tooling ?? createSessionToolingSelection());
|
||||
}
|
||||
|
||||
export function resolveScratchpadSessionConfig(
|
||||
session: SessionRecord,
|
||||
pattern: PatternDefinition,
|
||||
|
||||
@@ -175,6 +175,12 @@ export function duplicateSessionRecord(
|
||||
isArchived: false,
|
||||
lastError: undefined,
|
||||
scratchpadConfig: session.scratchpadConfig ? { ...session.scratchpadConfig } : undefined,
|
||||
tooling: session.tooling
|
||||
? {
|
||||
enabledMcpServerIds: [...session.tooling.enabledMcpServerIds],
|
||||
enabledLspProfileIds: [...session.tooling.enabledLspProfileIds],
|
||||
}
|
||||
: undefined,
|
||||
messages: session.messages.map((message): ChatMessageRecord => ({
|
||||
...message,
|
||||
pending: false,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
export type McpServerTransport = 'local' | 'http' | 'sse';
|
||||
|
||||
export interface BaseMcpServerDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
transport: McpServerTransport;
|
||||
tools: string[];
|
||||
timeoutMs?: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface LocalMcpServerDefinition extends BaseMcpServerDefinition {
|
||||
transport: 'local';
|
||||
command: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface RemoteMcpServerDefinition extends BaseMcpServerDefinition {
|
||||
transport: 'http' | 'sse';
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type McpServerDefinition = LocalMcpServerDefinition | RemoteMcpServerDefinition;
|
||||
|
||||
export interface LspProfileDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
command: string;
|
||||
args: string[];
|
||||
languageId: string;
|
||||
fileExtensions: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceToolingSettings {
|
||||
mcpServers: McpServerDefinition[];
|
||||
lspProfiles: LspProfileDefinition[];
|
||||
}
|
||||
|
||||
export interface WorkspaceSettings {
|
||||
tooling: WorkspaceToolingSettings;
|
||||
}
|
||||
|
||||
export interface SessionToolingSelection {
|
||||
enabledMcpServerIds: string[];
|
||||
enabledLspProfileIds: string[];
|
||||
}
|
||||
|
||||
export function createWorkspaceSettings(): WorkspaceSettings {
|
||||
return {
|
||||
tooling: {
|
||||
mcpServers: [],
|
||||
lspProfiles: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createSessionToolingSelection(): SessionToolingSelection {
|
||||
return {
|
||||
enabledMcpServerIds: [],
|
||||
enabledLspProfileIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>): WorkspaceSettings {
|
||||
return {
|
||||
tooling: {
|
||||
mcpServers: (settings?.tooling?.mcpServers ?? []).map(normalizeMcpServerDefinition),
|
||||
lspProfiles: (settings?.tooling?.lspProfiles ?? []).map(normalizeLspProfileDefinition),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSessionToolingSelection(
|
||||
selection?: Partial<SessionToolingSelection>,
|
||||
): SessionToolingSelection {
|
||||
return {
|
||||
enabledMcpServerIds: normalizeStringArray(selection?.enabledMcpServerIds),
|
||||
enabledLspProfileIds: normalizeStringArray(selection?.enabledLspProfileIds),
|
||||
};
|
||||
}
|
||||
|
||||
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
|
||||
if (!server.name.trim()) {
|
||||
return 'MCP server name is required.';
|
||||
}
|
||||
|
||||
if (server.transport === 'local') {
|
||||
if (!server.command.trim()) {
|
||||
return `MCP server "${server.name}" needs a command.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (!server.url.trim()) {
|
||||
return `MCP server "${server.name}" needs a URL.`;
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(server.url);
|
||||
} catch {
|
||||
return `MCP server "${server.name}" has an invalid URL.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateLspProfileDefinition(profile: LspProfileDefinition): string | undefined {
|
||||
if (!profile.name.trim()) {
|
||||
return 'LSP profile name is required.';
|
||||
}
|
||||
|
||||
if (!profile.command.trim()) {
|
||||
return `LSP profile "${profile.name}" needs a command.`;
|
||||
}
|
||||
|
||||
if (!profile.languageId.trim()) {
|
||||
return `LSP profile "${profile.name}" needs a language ID.`;
|
||||
}
|
||||
|
||||
if (normalizeStringArray(profile.fileExtensions).length === 0) {
|
||||
return `LSP profile "${profile.name}" needs at least one file extension.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function normalizeMcpServerDefinition(server: McpServerDefinition): McpServerDefinition {
|
||||
const base = {
|
||||
...server,
|
||||
name: server.name.trim(),
|
||||
tools: normalizeStringArray(server.tools),
|
||||
};
|
||||
|
||||
if (server.transport === 'local') {
|
||||
return {
|
||||
...base,
|
||||
transport: 'local',
|
||||
command: server.command.trim(),
|
||||
args: normalizeStringArray(server.args),
|
||||
cwd: server.cwd?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
transport: server.transport,
|
||||
url: server.url.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLspProfileDefinition(profile: LspProfileDefinition): LspProfileDefinition {
|
||||
return {
|
||||
...profile,
|
||||
name: profile.name.trim(),
|
||||
command: profile.command.trim(),
|
||||
args: normalizeStringArray(profile.args),
|
||||
languageId: profile.languageId.trim(),
|
||||
fileExtensions: normalizeFileExtensions(profile.fileExtensions),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFileExtensions(fileExtensions: string[]): string[] {
|
||||
return normalizeStringArray(fileExtensions).map((value) => (value.startsWith('.') ? value : `.${value}`));
|
||||
}
|
||||
|
||||
function normalizeStringArray(values?: ReadonlyArray<string>): string[] {
|
||||
if (!values) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
export interface WorkspaceState {
|
||||
projects: ProjectRecord[];
|
||||
patterns: PatternDefinition[];
|
||||
sessions: SessionRecord[];
|
||||
settings: WorkspaceSettings;
|
||||
selectedProjectId?: string;
|
||||
selectedPatternId?: string;
|
||||
selectedSessionId?: string;
|
||||
@@ -20,6 +22,7 @@ export function createWorkspaceSeed(): WorkspaceState {
|
||||
projects: [],
|
||||
patterns: createBuiltinPatterns(timestamp),
|
||||
sessions: [],
|
||||
settings: createWorkspaceSettings(),
|
||||
lastUpdatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user