mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-26 06:28:47 +02:00
feat: scaffold electron orchestrator foundation
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
export {};
|
||||
@@ -0,0 +1,353 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { basename } from 'node:path';
|
||||
|
||||
import { dialog } from 'electron';
|
||||
|
||||
import type { TurnDeltaEvent } from '@shared/contracts/sidecar';
|
||||
import { buildSessionTitle, validatePatternDefinition, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { createId, nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||
import { SecretStore } from '@main/secrets/secretStore';
|
||||
import { SidecarClient } from '@main/sidecar/sidecarProcess';
|
||||
|
||||
type AppServiceEvents = {
|
||||
'workspace-updated': [WorkspaceState];
|
||||
'session-event': [SessionEventRecord];
|
||||
};
|
||||
|
||||
function isBuiltinPattern(patternId: string): boolean {
|
||||
return patternId.startsWith('pattern-');
|
||||
}
|
||||
|
||||
export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly workspaceRepository = new WorkspaceRepository();
|
||||
private readonly sidecar = new SidecarClient();
|
||||
private readonly secretStore = new SecretStore();
|
||||
private workspace?: WorkspaceState;
|
||||
|
||||
async loadWorkspace(): Promise<WorkspaceState> {
|
||||
if (!this.workspace) {
|
||||
this.workspace = await this.workspaceRepository.load();
|
||||
}
|
||||
|
||||
return this.workspace;
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
await this.sidecar.dispose();
|
||||
void this.secretStore;
|
||||
}
|
||||
|
||||
async addProject(): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: 'Open project folder',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
|
||||
if (result.canceled || result.filePaths.length === 0) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
const folderPath = result.filePaths[0];
|
||||
const existing = workspace.projects.find((project) => project.path === folderPath);
|
||||
if (existing) {
|
||||
workspace.selectedProjectId = existing.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
const project: ProjectRecord = {
|
||||
id: createId('project'),
|
||||
name: basename(folderPath),
|
||||
path: folderPath,
|
||||
addedAt: nowIso(),
|
||||
};
|
||||
|
||||
workspace.projects.push(project);
|
||||
workspace.selectedProjectId = project.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async removeProject(projectId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.projects = workspace.projects.filter((project) => project.id !== projectId);
|
||||
workspace.sessions = workspace.sessions.filter((session) => session.projectId !== projectId);
|
||||
|
||||
if (workspace.selectedProjectId === projectId) {
|
||||
workspace.selectedProjectId = workspace.projects[0]?.id;
|
||||
}
|
||||
|
||||
if (
|
||||
workspace.selectedSessionId &&
|
||||
!workspace.sessions.some((session) => session.id === workspace.selectedSessionId)
|
||||
) {
|
||||
workspace.selectedSessionId = undefined;
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const issues = validatePatternDefinition(pattern).filter((issue) => issue.level === 'error');
|
||||
if (issues.length > 0) {
|
||||
throw new Error(issues[0].message);
|
||||
}
|
||||
|
||||
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
|
||||
const candidate: PatternDefinition = {
|
||||
...pattern,
|
||||
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
workspace.patterns[existingIndex] = candidate;
|
||||
} else {
|
||||
workspace.patterns.push(candidate);
|
||||
}
|
||||
|
||||
workspace.selectedPatternId = candidate.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async deletePattern(patternId: string): Promise<WorkspaceState> {
|
||||
if (isBuiltinPattern(patternId)) {
|
||||
throw new Error('Built-in patterns cannot be deleted.');
|
||||
}
|
||||
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.patterns = workspace.patterns.filter((pattern) => pattern.id !== patternId);
|
||||
|
||||
if (workspace.selectedPatternId === patternId) {
|
||||
workspace.selectedPatternId = workspace.patterns[0]?.id;
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
const pattern = this.requirePattern(workspace, patternId);
|
||||
|
||||
const session: SessionRecord = {
|
||||
id: createId('session'),
|
||||
projectId: project.id,
|
||||
patternId: pattern.id,
|
||||
title: pattern.name,
|
||||
createdAt: nowIso(),
|
||||
updatedAt: nowIso(),
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
};
|
||||
|
||||
workspace.sessions.unshift(session);
|
||||
workspace.selectedProjectId = project.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
workspace.selectedSessionId = session.id;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async sendSessionMessage(sessionId: string, content: string): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
session.messages.push({
|
||||
id: createId('msg'),
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: trimmed,
|
||||
createdAt: nowIso(),
|
||||
});
|
||||
session.title = buildSessionTitle(pattern, session.messages);
|
||||
session.status = 'running';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
this.emitSessionEvent({
|
||||
sessionId: session.id,
|
||||
kind: 'status',
|
||||
status: 'running',
|
||||
occurredAt: nowIso(),
|
||||
});
|
||||
|
||||
const requestId = createId('turn');
|
||||
try {
|
||||
const responseMessages = await this.sidecar.runTurn(
|
||||
{
|
||||
type: 'run-turn',
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: project.path,
|
||||
pattern,
|
||||
messages: session.messages,
|
||||
},
|
||||
async (event) => {
|
||||
await this.applyTurnDelta(workspace, session.id, event);
|
||||
},
|
||||
);
|
||||
|
||||
this.finalizeTurn(workspace, session.id, responseMessages);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
} catch (error) {
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId: session.id,
|
||||
kind: 'error',
|
||||
occurredAt: nowIso(),
|
||||
error: session.lastError,
|
||||
});
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
async selectProject(projectId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.selectedProjectId = projectId;
|
||||
workspace.selectedSessionId = workspace.selectedSessionId;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async selectPattern(patternId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.selectedPatternId = patternId;
|
||||
workspace.selectedSessionId = workspace.selectedSessionId;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async selectSession(sessionId?: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.selectedSessionId = sessionId;
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
private requireProject(workspace: WorkspaceState, projectId: string): ProjectRecord {
|
||||
const project = workspace.projects.find((current) => current.id === projectId);
|
||||
if (!project) {
|
||||
throw new Error(`Project "${projectId}" was not found.`);
|
||||
}
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
|
||||
const pattern = workspace.patterns.find((current) => current.id === patternId);
|
||||
if (!pattern) {
|
||||
throw new Error(`Pattern "${patternId}" was not found.`);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord {
|
||||
const session = workspace.sessions.find((current) => current.id === sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Session "${sessionId}" was not found.`);
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async applyTurnDelta(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: TurnDeltaEvent,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const existing = session.messages.find((message) => message.id === event.messageId);
|
||||
|
||||
if (existing) {
|
||||
existing.content += event.contentDelta;
|
||||
existing.pending = true;
|
||||
} else {
|
||||
session.messages.push({
|
||||
id: event.messageId,
|
||||
role: 'assistant',
|
||||
authorName: event.authorName,
|
||||
content: event.contentDelta,
|
||||
createdAt: nowIso(),
|
||||
pending: true,
|
||||
});
|
||||
}
|
||||
|
||||
session.updatedAt = nowIso();
|
||||
await this.workspaceRepository.save(workspace);
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'message-delta',
|
||||
occurredAt: nowIso(),
|
||||
messageId: event.messageId,
|
||||
authorName: event.authorName,
|
||||
contentDelta: event.contentDelta,
|
||||
});
|
||||
}
|
||||
|
||||
private finalizeTurn(workspace: WorkspaceState, sessionId: string, messages: ChatMessageRecord[]): void {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const incomingIds = new Set(messages.map((message) => message.id));
|
||||
|
||||
for (const message of messages) {
|
||||
const existing = session.messages.find((current) => current.id === message.id);
|
||||
if (existing) {
|
||||
existing.authorName = message.authorName;
|
||||
existing.content = message.content;
|
||||
existing.pending = false;
|
||||
} else {
|
||||
session.messages.push({ ...message, pending: false });
|
||||
}
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'message-complete',
|
||||
occurredAt: nowIso(),
|
||||
messageId: message.id,
|
||||
authorName: message.authorName,
|
||||
});
|
||||
}
|
||||
|
||||
for (const message of session.messages) {
|
||||
if (message.pending && incomingIds.has(message.id)) {
|
||||
message.pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
session.status = 'idle';
|
||||
session.lastError = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'status',
|
||||
occurredAt: nowIso(),
|
||||
status: 'idle',
|
||||
});
|
||||
}
|
||||
|
||||
private async persistAndBroadcast(workspace: WorkspaceState): Promise<WorkspaceState> {
|
||||
await this.workspaceRepository.save(workspace);
|
||||
this.emit('workspace-updated', workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
private emitSessionEvent(event: SessionEventRecord): void {
|
||||
this.emit('session-event', event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
|
||||
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
|
||||
import { KopayaAppService } from '@main/KopayaAppService';
|
||||
import { createMainWindow } from '@main/windows/createMainWindow';
|
||||
|
||||
let mainWindow: BrowserWindow | undefined;
|
||||
let appService: KopayaAppService | undefined;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
appService = new KopayaAppService();
|
||||
|
||||
mainWindow = createMainWindow();
|
||||
registerIpcHandlers(mainWindow, appService);
|
||||
|
||||
if (!app.isPackaged) {
|
||||
mainWindow.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
}
|
||||
|
||||
app.whenReady().then(bootstrap);
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
await bootstrap();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
await appService?.dispose();
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type { CreateSessionInput, SavePatternInput, SendSessionMessageInput } from '@shared/contracts/ipc';
|
||||
|
||||
import { KopayaAppService } from '@main/KopayaAppService';
|
||||
|
||||
export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppService): void {
|
||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
||||
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
|
||||
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||
service.createSession(input.projectId, input.patternId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||
service.sendSessionMessage(input.sessionId, input.content),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
|
||||
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
|
||||
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
|
||||
|
||||
service.on('workspace-updated', (workspace) => {
|
||||
window.webContents.send(ipcChannels.workspaceUpdated, workspace);
|
||||
});
|
||||
|
||||
service.on('session-event', (event) => {
|
||||
window.webContents.send(ipcChannels.sessionEvent, event);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { app } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function getWorkspaceFilePath(): string {
|
||||
return join(app.getPath('userData'), 'workspace.json');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export async function readJsonFile<T>(filePath: string): Promise<T | undefined> {
|
||||
try {
|
||||
const contents = await readFile(filePath, 'utf8');
|
||||
return JSON.parse(contents) as T;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeJsonFile<T>(filePath: string, value: T): Promise<void> {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { getWorkspaceFilePath } from '@main/persistence/appPaths';
|
||||
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
|
||||
|
||||
function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] {
|
||||
const builtinTimestamp = nowIso();
|
||||
const builtinPatterns = createBuiltinPatterns(builtinTimestamp);
|
||||
const builtinIds = new Set(builtinPatterns.map((pattern) => pattern.id));
|
||||
const existingMap = new Map(existingPatterns.map((pattern) => [pattern.id, pattern]));
|
||||
|
||||
const mergedBuiltins = builtinPatterns.map((builtin) => {
|
||||
const existing = existingMap.get(builtin.id);
|
||||
if (!existing) {
|
||||
return builtin;
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
availability: builtin.availability,
|
||||
unavailabilityReason: builtin.unavailabilityReason,
|
||||
mode: builtin.mode,
|
||||
};
|
||||
});
|
||||
|
||||
const customPatterns = existingPatterns.filter((pattern) => !builtinIds.has(pattern.id));
|
||||
return [...mergedBuiltins, ...customPatterns];
|
||||
}
|
||||
|
||||
export class WorkspaceRepository {
|
||||
readonly filePath = getWorkspaceFilePath();
|
||||
|
||||
async load(): Promise<WorkspaceState> {
|
||||
const stored = await readJsonFile<WorkspaceState>(this.filePath);
|
||||
if (!stored) {
|
||||
const seeded = createWorkspaceSeed();
|
||||
await this.save(seeded);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
const workspace: WorkspaceState = {
|
||||
...stored,
|
||||
patterns: mergePatterns(stored.patterns ?? []),
|
||||
projects: stored.projects ?? [],
|
||||
sessions: stored.sessions ?? [],
|
||||
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
|
||||
};
|
||||
|
||||
await this.save(workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async save(workspace: WorkspaceState): Promise<void> {
|
||||
await writeJsonFile(this.filePath, {
|
||||
...workspace,
|
||||
lastUpdatedAt: nowIso(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import keytar from 'keytar';
|
||||
|
||||
const serviceName = 'kopaya';
|
||||
|
||||
export class SecretStore {
|
||||
async get(account: string): Promise<string | null> {
|
||||
return keytar.getPassword(serviceName, account);
|
||||
}
|
||||
|
||||
async set(account: string, secret: string): Promise<void> {
|
||||
await keytar.setPassword(serviceName, account, secret);
|
||||
}
|
||||
|
||||
async delete(account: string): Promise<boolean> {
|
||||
return keytar.deletePassword(serviceName, account);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { app } from 'electron';
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type {
|
||||
SidecarCapabilities,
|
||||
SidecarCommand,
|
||||
SidecarEvent,
|
||||
TurnDeltaEvent,
|
||||
ValidatePatternCommand,
|
||||
RunTurnCommand,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
type PendingCommand =
|
||||
| {
|
||||
kind: 'capabilities';
|
||||
resolve: (capabilities: SidecarCapabilities) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| {
|
||||
kind: 'validate-pattern';
|
||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
| {
|
||||
kind: 'run-turn';
|
||||
resolve: (messages: ChatMessageRecord[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
onDelta: (event: TurnDeltaEvent) => void;
|
||||
};
|
||||
|
||||
function getProjectRoot(): string {
|
||||
return app.getAppPath();
|
||||
}
|
||||
|
||||
function resolveSidecarProcess(): { command: string; args: string[] } {
|
||||
if (app.isPackaged) {
|
||||
return {
|
||||
command: join(process.resourcesPath, 'sidecar', 'Kopaya.AgentHost.exe'),
|
||||
args: ['--stdio'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: 'dotnet',
|
||||
args: [
|
||||
'run',
|
||||
'--project',
|
||||
join(getProjectRoot(), 'sidecar', 'src', 'Kopaya.AgentHost', 'Kopaya.AgentHost.csproj'),
|
||||
'--',
|
||||
'--stdio',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export class SidecarClient {
|
||||
private process?: ChildProcessWithoutNullStreams;
|
||||
private stdoutBuffer = '';
|
||||
private readonly pending = new Map<string, PendingCommand>();
|
||||
|
||||
async describeCapabilities(): Promise<SidecarCapabilities> {
|
||||
const command = await this.dispatch<SidecarCapabilities>({
|
||||
type: 'describe-capabilities',
|
||||
requestId: `cap-${Date.now()}`,
|
||||
});
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
async validatePattern(pattern: ValidatePatternCommand['pattern']): Promise<unknown> {
|
||||
return this.dispatch<unknown>({
|
||||
type: 'validate-pattern',
|
||||
requestId: `validate-${Date.now()}`,
|
||||
pattern,
|
||||
});
|
||||
}
|
||||
|
||||
async runTurn(command: RunTurnCommand, onDelta: (event: TurnDeltaEvent) => void): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta);
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (!this.process) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.process.kill();
|
||||
this.process = undefined;
|
||||
}
|
||||
|
||||
private async ensureProcess(): Promise<ChildProcessWithoutNullStreams> {
|
||||
if (this.process && !this.process.killed) {
|
||||
return this.process;
|
||||
}
|
||||
|
||||
const sidecar = resolveSidecarProcess();
|
||||
this.process = spawn(sidecar.command, sidecar.args, {
|
||||
cwd: getProjectRoot(),
|
||||
stdio: 'pipe',
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
this.process.stdout.setEncoding('utf8');
|
||||
this.process.stdout.on('data', (chunk: string) => {
|
||||
this.stdoutBuffer += chunk;
|
||||
this.flushStdoutBuffer();
|
||||
});
|
||||
|
||||
this.process.stderr.setEncoding('utf8');
|
||||
this.process.stderr.on('data', (chunk: string) => {
|
||||
console.error('[kopaya sidecar]', chunk.trim());
|
||||
});
|
||||
|
||||
this.process.on('exit', (code) => {
|
||||
const error = new Error(`The .NET sidecar exited unexpectedly with code ${code ?? 'unknown'}.`);
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
this.process = undefined;
|
||||
this.stdoutBuffer = '';
|
||||
});
|
||||
|
||||
return this.process;
|
||||
}
|
||||
|
||||
private async dispatch<TResult>(
|
||||
command: SidecarCommand,
|
||||
onDelta?: (event: TurnDeltaEvent) => void,
|
||||
): Promise<TResult> {
|
||||
const process = await this.ensureProcess();
|
||||
|
||||
return new Promise<TResult>((resolve, reject) => {
|
||||
if (command.type === 'run-turn') {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'run-turn',
|
||||
resolve: resolve as (messages: ChatMessageRecord[]) => void,
|
||||
reject,
|
||||
onDelta: onDelta ?? (() => undefined),
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'validate-pattern',
|
||||
resolve: resolve as (issues: unknown) => void,
|
||||
reject,
|
||||
});
|
||||
} else {
|
||||
this.pending.set(command.requestId, {
|
||||
kind: 'capabilities',
|
||||
resolve: resolve as (capabilities: SidecarCapabilities) => void,
|
||||
reject,
|
||||
});
|
||||
}
|
||||
|
||||
process.stdin.write(`${JSON.stringify(command)}\n`);
|
||||
});
|
||||
}
|
||||
|
||||
private flushStdoutBuffer(): void {
|
||||
let newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
|
||||
while (newlineIndex >= 0) {
|
||||
const rawLine = this.stdoutBuffer.slice(0, newlineIndex).trim();
|
||||
this.stdoutBuffer = this.stdoutBuffer.slice(newlineIndex + 1);
|
||||
|
||||
if (rawLine) {
|
||||
this.handleEvent(JSON.parse(rawLine) as SidecarEvent);
|
||||
}
|
||||
|
||||
newlineIndex = this.stdoutBuffer.indexOf('\n');
|
||||
}
|
||||
}
|
||||
|
||||
private handleEvent(event: SidecarEvent): void {
|
||||
const pending = this.pending.get(event.requestId);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'capabilities':
|
||||
if (pending.kind === 'capabilities') {
|
||||
pending.resolve(event.capabilities);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'pattern-validation':
|
||||
if (pending.kind === 'validate-pattern') {
|
||||
pending.resolve(event.issues);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'turn-delta':
|
||||
if (pending.kind === 'run-turn') {
|
||||
pending.onDelta(event);
|
||||
}
|
||||
return;
|
||||
case 'turn-complete':
|
||||
if (pending.kind === 'run-turn') {
|
||||
pending.resolve(event.messages);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'command-error':
|
||||
pending.reject(new Error(event.message));
|
||||
this.pending.delete(event.requestId);
|
||||
return;
|
||||
case 'command-complete':
|
||||
if (pending.kind !== 'run-turn') {
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { BrowserWindow, shell } from 'electron';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export function createMainWindow(): BrowserWindow {
|
||||
const window = new BrowserWindow({
|
||||
width: 1440,
|
||||
height: 960,
|
||||
minWidth: 1120,
|
||||
minHeight: 720,
|
||||
title: 'kopaya',
|
||||
backgroundColor: '#0f172a',
|
||||
webPreferences: {
|
||||
preload: join(__dirname, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
|
||||
|
||||
if (rendererUrl) {
|
||||
void window.loadURL(rendererUrl);
|
||||
} else {
|
||||
void window.loadFile(join(__dirname, '../../dist/renderer/index.html'));
|
||||
}
|
||||
|
||||
window.webContents.setWindowOpenHandler(({ url }) => {
|
||||
void shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
return window;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
|
||||
import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type { ElectronApi } from '@shared/contracts/ipc';
|
||||
|
||||
const api: ElectronApi = {
|
||||
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
|
||||
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
|
||||
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
|
||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||
createSession: (input) => ipcRenderer.invoke(ipcChannels.createSession, input),
|
||||
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
||||
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
|
||||
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
|
||||
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
|
||||
onWorkspaceUpdated: (listener) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, workspace: Awaited<ReturnType<ElectronApi['loadWorkspace']>>) =>
|
||||
listener(workspace);
|
||||
|
||||
ipcRenderer.on(ipcChannels.workspaceUpdated, handler);
|
||||
return () => ipcRenderer.off(ipcChannels.workspaceUpdated, handler);
|
||||
},
|
||||
onSessionEvent: (listener) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, sessionEvent: Parameters<typeof listener>[0]) =>
|
||||
listener(sessionEvent);
|
||||
|
||||
ipcRenderer.on(ipcChannels.sessionEvent, handler);
|
||||
return () => ipcRenderer.off(ipcChannels.sessionEvent, handler);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('kopayaApi', api);
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AppShell } from '@renderer/components/AppShell';
|
||||
import { ChatPane } from '@renderer/components/ChatPane';
|
||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||
import { Sidebar } from '@renderer/components/Sidebar';
|
||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import { createId, nowIso } from '@shared/utils/ids';
|
||||
|
||||
function clonePattern(pattern: PatternDefinition): PatternDefinition {
|
||||
return structuredClone(pattern);
|
||||
}
|
||||
|
||||
function createDraftPattern(): PatternDefinition {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
id: createId('pattern'),
|
||||
name: 'New Pattern',
|
||||
description: 'Reusable orchestration pattern.',
|
||||
mode: 'single',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: createId('agent'),
|
||||
name: 'Primary Agent',
|
||||
description: 'General-purpose project assistant.',
|
||||
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function EmptyDetail() {
|
||||
const patterns = createBuiltinPatterns(nowIso());
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center px-10">
|
||||
<div className="max-w-3xl rounded-3xl border border-slate-800 bg-slate-900/70 p-10">
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">Workspace Overview</div>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-white">Chat-first orchestration across projects</h2>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
Select a pattern to edit it, add one or more projects on the left, and start sessions that bind a
|
||||
project folder to a reusable orchestration blueprint.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-2">
|
||||
{patterns.map((pattern) => (
|
||||
<div
|
||||
className="rounded-2xl border border-slate-800 bg-slate-950/70 p-4"
|
||||
key={pattern.id}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h3 className="text-sm font-semibold text-slate-100">{pattern.name}</h3>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] uppercase tracking-wide ${
|
||||
pattern.availability === 'unavailable'
|
||||
? 'bg-amber-500/15 text-amber-200'
|
||||
: 'bg-emerald-500/10 text-emerald-200'
|
||||
}`}
|
||||
>
|
||||
{pattern.mode}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-400">{pattern.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const api = getElectronApi();
|
||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||
const [draftPattern, setDraftPattern] = useState<PatternDefinition | null>(null);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
|
||||
void api
|
||||
.loadWorkspace()
|
||||
.then((nextWorkspace) => {
|
||||
if (!disposed) {
|
||||
setWorkspace(nextWorkspace);
|
||||
}
|
||||
})
|
||||
.catch((nextError) => {
|
||||
if (!disposed) {
|
||||
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
||||
}
|
||||
});
|
||||
|
||||
const offWorkspace = api.onWorkspaceUpdated((nextWorkspace) => {
|
||||
setWorkspace(nextWorkspace);
|
||||
setError(undefined);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
offWorkspace();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace?.selectedPatternId) {
|
||||
setDraftPattern(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPattern = workspace.patterns.find((pattern) => pattern.id === workspace.selectedPatternId);
|
||||
setDraftPattern(selectedPattern ? clonePattern(selectedPattern) : null);
|
||||
}, [workspace?.lastUpdatedAt, workspace?.selectedPatternId, workspace?.patterns]);
|
||||
|
||||
const selectedSession = useMemo(
|
||||
() => workspace?.sessions.find((session) => session.id === workspace.selectedSessionId),
|
||||
[workspace?.selectedSessionId, workspace?.sessions],
|
||||
);
|
||||
const selectedPattern = useMemo(
|
||||
() =>
|
||||
draftPattern ??
|
||||
workspace?.patterns.find((pattern) => pattern.id === workspace.selectedPatternId),
|
||||
[draftPattern, workspace?.patterns, workspace?.selectedPatternId],
|
||||
);
|
||||
const selectedProject = useMemo(
|
||||
() => workspace?.projects.find((project) => project.id === workspace.selectedProjectId),
|
||||
[workspace?.projects, workspace?.selectedProjectId],
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
|
||||
Loading workspace…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const patternForSession = selectedSession
|
||||
? workspace.patterns.find((pattern) => pattern.id === selectedSession.patternId)
|
||||
: undefined;
|
||||
const projectForSession = selectedSession
|
||||
? workspace.projects.find((project) => project.id === selectedSession.projectId)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
content={
|
||||
error ? (
|
||||
<div className="flex h-screen items-center justify-center px-10">
|
||||
<div className="max-w-lg rounded-3xl border border-rose-500/40 bg-rose-500/10 p-8 text-rose-100">
|
||||
<div className="text-xs uppercase tracking-[0.2em] text-rose-200">Error</div>
|
||||
<h2 className="mt-3 text-2xl font-semibold">Something went wrong</h2>
|
||||
<p className="mt-3 text-sm leading-7">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedSession && patternForSession && projectForSession ? (
|
||||
<ChatPane
|
||||
onSend={(content) => api.sendSessionMessage({ sessionId: selectedSession.id, content })}
|
||||
pattern={patternForSession}
|
||||
project={projectForSession}
|
||||
session={selectedSession}
|
||||
/>
|
||||
) : selectedPattern ? (
|
||||
<PatternEditor
|
||||
isBuiltin={selectedPattern.id.startsWith('pattern-')}
|
||||
onChange={setDraftPattern}
|
||||
onDelete={
|
||||
selectedPattern.id.startsWith('pattern-')
|
||||
? undefined
|
||||
: async () => {
|
||||
await api.deletePattern(selectedPattern.id);
|
||||
}
|
||||
}
|
||||
onSave={async () => {
|
||||
await api.savePattern({ pattern: draftPattern ?? selectedPattern });
|
||||
}}
|
||||
pattern={draftPattern ?? selectedPattern}
|
||||
/>
|
||||
) : (
|
||||
<EmptyDetail />
|
||||
)
|
||||
}
|
||||
sidebar={
|
||||
<Sidebar
|
||||
onAddProject={() => {
|
||||
void api.addProject();
|
||||
}}
|
||||
onCreateSession={() => {
|
||||
if (!workspace.selectedProjectId || !workspace.selectedPatternId) {
|
||||
return;
|
||||
}
|
||||
|
||||
void api.createSession({
|
||||
projectId: workspace.selectedProjectId,
|
||||
patternId: workspace.selectedPatternId,
|
||||
});
|
||||
}}
|
||||
onNewPattern={() => {
|
||||
setDraftPattern(createDraftPattern());
|
||||
void api.selectPattern(undefined);
|
||||
void api.selectSession(undefined);
|
||||
}}
|
||||
onPatternSelect={(patternId) => {
|
||||
void api.selectPattern(patternId);
|
||||
void api.selectSession(undefined);
|
||||
}}
|
||||
onProjectSelect={(projectId) => {
|
||||
void api.selectProject(projectId);
|
||||
void api.selectSession(undefined);
|
||||
}}
|
||||
onSessionSelect={(sessionId) => {
|
||||
void api.selectSession(sessionId);
|
||||
}}
|
||||
workspace={workspace}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface AppShellProps {
|
||||
sidebar: ReactNode;
|
||||
content: ReactNode;
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, content }: AppShellProps) {
|
||||
return (
|
||||
<div className="flex min-h-screen bg-slate-950 text-slate-100">
|
||||
<aside className="w-[360px] shrink-0 border-r border-slate-800 bg-slate-900/90">{sidebar}</aside>
|
||||
<main className="min-w-0 flex-1">{content}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
|
||||
interface ChatPaneProps {
|
||||
project: ProjectRecord;
|
||||
pattern: PatternDefinition;
|
||||
session: SessionRecord;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
|
||||
const [input, setInput] = useState('');
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
transcriptRef.current?.scrollTo({
|
||||
top: transcriptRef.current.scrollHeight,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
}, [session.messages.length]);
|
||||
|
||||
const isBusy = session.status === 'running';
|
||||
const sessionStats = useMemo(
|
||||
() => `${session.messages.length} messages • ${pattern.agents.length} agent${pattern.agents.length === 1 ? '' : 's'}`,
|
||||
[pattern.agents.length, session.messages.length],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<header className="border-b border-slate-800 px-8 py-6">
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">{project.name}</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{session.title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Pattern: <span className="font-medium text-slate-200">{pattern.name}</span> • Mode:{' '}
|
||||
<span className="font-medium text-slate-200">{pattern.mode}</span>
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-500">{sessionStats}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`rounded-full px-3 py-1.5 text-xs font-medium uppercase tracking-wide ${
|
||||
session.status === 'error'
|
||||
? 'bg-rose-500/15 text-rose-200'
|
||||
: session.status === 'running'
|
||||
? 'bg-sky-500/15 text-sky-200'
|
||||
: 'bg-slate-800 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{session.status}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-8 py-6"
|
||||
ref={transcriptRef}
|
||||
>
|
||||
{session.messages.length === 0 ? (
|
||||
<div className="rounded-3xl border border-dashed border-slate-800 bg-slate-900/60 px-6 py-8 text-sm text-slate-400">
|
||||
Start the conversation to launch this orchestration against <span className="font-medium text-slate-200">{project.path}</span>.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-4">
|
||||
{session.messages.map((message) => {
|
||||
const isUser = message.role === 'user';
|
||||
return (
|
||||
<div
|
||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
||||
key={message.id}
|
||||
>
|
||||
<div
|
||||
className={`max-w-3xl rounded-3xl px-5 py-4 shadow-sm ${
|
||||
isUser
|
||||
? 'bg-sky-500 text-slate-950'
|
||||
: 'border border-slate-800 bg-slate-900/85 text-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className={`text-xs font-semibold uppercase tracking-wide ${isUser ? 'text-slate-800' : 'text-slate-400'}`}>
|
||||
{message.authorName}
|
||||
</div>
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-6">{message.content}</div>
|
||||
{message.pending ? (
|
||||
<div className="mt-3 text-xs text-slate-400">Streaming…</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-800 px-8 py-5">
|
||||
{session.lastError ? (
|
||||
<div className="mb-4 rounded-xl border border-rose-500/40 bg-rose-500/10 px-4 py-3 text-sm text-rose-200">
|
||||
{session.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<form
|
||||
className="mx-auto flex max-w-4xl flex-col gap-3"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
if (!input.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextInput = input;
|
||||
setInput('');
|
||||
await onSend(nextInput);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-3xl border border-slate-700 bg-slate-900 px-5 py-4 text-sm text-slate-100 shadow-inner outline-none transition focus:border-sky-500"
|
||||
disabled={isBusy}
|
||||
onChange={(event) => setInput(event.target.value)}
|
||||
placeholder="Ask the selected orchestration to reason about the current project..."
|
||||
value={input}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs text-slate-500">
|
||||
The .NET sidecar replays the saved transcript so sessions can resume after app restart.
|
||||
</p>
|
||||
<button
|
||||
className="rounded-full bg-sky-500 px-5 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={isBusy || !input.trim()}
|
||||
type="submit"
|
||||
>
|
||||
{isBusy ? 'Running…' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { validatePatternDefinition, type OrchestrationMode, type PatternDefinition } from '@shared/domain/pattern';
|
||||
|
||||
interface PatternEditorProps {
|
||||
pattern: PatternDefinition;
|
||||
isBuiltin: boolean;
|
||||
onChange: (pattern: PatternDefinition) => void;
|
||||
onDelete?: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
const modes: OrchestrationMode[] = ['single', 'sequential', 'concurrent', 'handoff', 'group-chat', 'magentic'];
|
||||
|
||||
export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave }: PatternEditorProps) {
|
||||
const issues = validatePatternDefinition(pattern);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-slate-800 px-8 py-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">Pattern</div>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">{pattern.name || 'Untitled pattern'}</h2>
|
||||
<p className="mt-2 max-w-3xl text-sm text-slate-400">
|
||||
Define a reusable orchestration blueprint that can be launched against any project in the
|
||||
workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{!isBuiltin && onDelete ? (
|
||||
<button
|
||||
className="rounded-lg border border-rose-500/40 px-4 py-2 text-sm font-medium text-rose-200 hover:bg-rose-500/10"
|
||||
onClick={onDelete}
|
||||
type="button"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-slate-950 hover:bg-sky-400"
|
||||
onClick={onSave}
|
||||
type="button"
|
||||
>
|
||||
Save Pattern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-1 grid-cols-[minmax(0,1fr)_320px] gap-0 overflow-hidden">
|
||||
<div className="overflow-y-auto px-8 py-6">
|
||||
<div className="space-y-8">
|
||||
<section className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-200">Name</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
onChange={(event) => onChange({ ...pattern, name: event.target.value })}
|
||||
value={pattern.name}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-200">Mode</span>
|
||||
<select
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...pattern,
|
||||
mode: event.target.value as OrchestrationMode,
|
||||
})
|
||||
}
|
||||
value={pattern.mode}
|
||||
>
|
||||
{modes.map((mode) => (
|
||||
<option
|
||||
key={mode}
|
||||
value={mode}
|
||||
>
|
||||
{mode}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-200">Description</span>
|
||||
<textarea
|
||||
className="min-h-24 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
||||
onChange={(event) => onChange({ ...pattern, description: event.target.value })}
|
||||
value={pattern.description}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Agents</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Configure the participating Copilot-backed agents and their model selections.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-lg border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: [
|
||||
...pattern.agents,
|
||||
{
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: 'New participant',
|
||||
instructions: 'You are a helpful specialist in this orchestration.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Add Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{pattern.agents.map((agent, index) => (
|
||||
<div
|
||||
className="rounded-2xl border border-slate-800 bg-slate-900/70 p-4"
|
||||
key={agent.id}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div className="text-sm font-medium text-slate-200">Agent {index + 1}</div>
|
||||
{pattern.agents.length > 1 ? (
|
||||
<button
|
||||
className="text-sm text-rose-200 hover:text-rose-100"
|
||||
onClick={() =>
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.filter((current) => current.id !== agent.id),
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-300">Name</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.map((current) =>
|
||||
current.id === agent.id ? { ...current, name: event.target.value } : current,
|
||||
),
|
||||
})
|
||||
}
|
||||
value={agent.name}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2">
|
||||
<span className="text-sm font-medium text-slate-300">Model</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.map((current) =>
|
||||
current.id === agent.id ? { ...current, model: event.target.value } : current,
|
||||
),
|
||||
})
|
||||
}
|
||||
value={agent.model}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-300">Description</span>
|
||||
<input
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.map((current) =>
|
||||
current.id === agent.id ? { ...current, description: event.target.value } : current,
|
||||
),
|
||||
})
|
||||
}
|
||||
value={agent.description}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-2 md:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-300">Instructions</span>
|
||||
<textarea
|
||||
className="min-h-28 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"
|
||||
onChange={(event) =>
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.map((current) =>
|
||||
current.id === agent.id ? { ...current, instructions: event.target.value } : current,
|
||||
),
|
||||
})
|
||||
}
|
||||
value={agent.instructions}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="border-l border-slate-800 bg-slate-900/70 px-6 py-6">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-400">Validation</h3>
|
||||
<div className="mt-4 space-y-3">
|
||||
{issues.length === 0 ? (
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-200">
|
||||
This pattern is ready to launch.
|
||||
</div>
|
||||
) : (
|
||||
issues.map((issue, index) => (
|
||||
<div
|
||||
className={`rounded-xl px-4 py-3 text-sm ${
|
||||
issue.level === 'error'
|
||||
? 'border border-rose-500/40 bg-rose-500/10 text-rose-200'
|
||||
: 'border border-amber-500/40 bg-amber-500/10 text-amber-200'
|
||||
}`}
|
||||
key={`${issue.field ?? 'issue'}-${index}`}
|
||||
>
|
||||
<div className="font-medium">{issue.level.toUpperCase()}</div>
|
||||
<div className="mt-1">{issue.message}</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{isBuiltin ? (
|
||||
<div className="rounded-xl border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-slate-400">
|
||||
Built-in patterns can be edited and saved, but they cannot be deleted from the global library.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
interface SidebarProps {
|
||||
workspace: WorkspaceState;
|
||||
onAddProject: () => void;
|
||||
onCreateSession: () => void;
|
||||
onNewPattern: () => void;
|
||||
onProjectSelect: (projectId?: string) => void;
|
||||
onPatternSelect: (patternId?: string) => void;
|
||||
onSessionSelect: (sessionId?: string) => void;
|
||||
}
|
||||
|
||||
function itemClasses(active: boolean) {
|
||||
return active
|
||||
? 'w-full rounded-lg border border-sky-500/60 bg-sky-500/10 px-3 py-2 text-left text-sm text-sky-200'
|
||||
: 'w-full rounded-lg border border-transparent px-3 py-2 text-left text-sm text-slate-300 transition hover:border-slate-700 hover:bg-slate-800/70';
|
||||
}
|
||||
|
||||
function modeBadge(pattern: PatternDefinition) {
|
||||
if (pattern.availability === 'unavailable') {
|
||||
return 'bg-amber-500/15 text-amber-200';
|
||||
}
|
||||
|
||||
return 'bg-emerald-500/10 text-emerald-200';
|
||||
}
|
||||
|
||||
function sessionCountLabel(project: ProjectRecord, sessions: SessionRecord[]) {
|
||||
const count = sessions.filter((session) => session.projectId === project.id).length;
|
||||
return `${count} session${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
workspace,
|
||||
onAddProject,
|
||||
onCreateSession,
|
||||
onNewPattern,
|
||||
onProjectSelect,
|
||||
onPatternSelect,
|
||||
onSessionSelect,
|
||||
}: SidebarProps) {
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
<div className="border-b border-slate-800 px-5 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">kopaya</div>
|
||||
<h1 className="mt-1 text-xl font-semibold text-white">Agent Orchestrator</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
React + Electron frontend with a bundled .NET sidecar.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-lg border border-slate-700 px-3 py-2 text-sm font-medium text-slate-200 hover:bg-slate-800"
|
||||
onClick={onAddProject}
|
||||
type="button"
|
||||
>
|
||||
Add Project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-6 overflow-y-auto px-4 py-4">
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-200">Patterns</h2>
|
||||
<p className="text-xs text-slate-500">Global orchestration library</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md border border-slate-700 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800"
|
||||
onClick={onNewPattern}
|
||||
type="button"
|
||||
>
|
||||
New Pattern
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{workspace.patterns.map((pattern) => (
|
||||
<button
|
||||
className={itemClasses(workspace.selectedPatternId === pattern.id && !workspace.selectedSessionId)}
|
||||
key={pattern.id}
|
||||
onClick={() => onPatternSelect(pattern.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium text-slate-100">{pattern.name}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{pattern.description}</div>
|
||||
</div>
|
||||
<span className={`rounded-full px-2 py-0.5 text-[11px] uppercase tracking-wide ${modeBadge(pattern)}`}>
|
||||
{pattern.mode}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-200">Projects</h2>
|
||||
<p className="text-xs text-slate-500">Workspace folders and their sessions</p>
|
||||
</div>
|
||||
<button
|
||||
className="rounded-md border border-slate-700 px-2.5 py-1.5 text-xs font-medium text-slate-200 hover:bg-slate-800"
|
||||
disabled={!workspace.selectedProjectId || !workspace.selectedPatternId}
|
||||
onClick={onCreateSession}
|
||||
type="button"
|
||||
>
|
||||
New Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{workspace.projects.map((project) => {
|
||||
const sessions = workspace.sessions.filter((session) => session.projectId === project.id);
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border border-slate-800 bg-slate-900/60 p-3"
|
||||
key={project.id}
|
||||
>
|
||||
<button
|
||||
className={itemClasses(workspace.selectedProjectId === project.id && !workspace.selectedSessionId)}
|
||||
onClick={() => onProjectSelect(project.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="font-medium text-slate-100">{project.name}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">{project.path}</div>
|
||||
<div className="mt-2 text-[11px] uppercase tracking-wide text-slate-500">
|
||||
{sessionCountLabel(project, sessions)}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{sessions.length > 0 ? (
|
||||
<div className="mt-3 space-y-2 border-t border-slate-800 pt-3">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
className={itemClasses(workspace.selectedSessionId === session.id)}
|
||||
key={session.id}
|
||||
onClick={() => onSessionSelect(session.id)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-slate-100">{session.title}</div>
|
||||
<div className="mt-1 text-xs text-slate-400">
|
||||
{session.messages.length} message{session.messages.length === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[11px] uppercase tracking-wide ${
|
||||
session.status === 'error'
|
||||
? 'bg-rose-500/15 text-rose-200'
|
||||
: session.status === 'running'
|
||||
? 'bg-sky-500/15 text-sky-200'
|
||||
: 'bg-slate-700 text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-slate-800 px-3 py-2 text-xs text-slate-500">
|
||||
No sessions yet. Select a pattern, then start one.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{workspace.projects.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-slate-800 bg-slate-900/50 px-4 py-5 text-sm text-slate-400">
|
||||
Add one or more project folders to begin orchestrating sessions.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { ElectronApi } from '@shared/contracts/ipc';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
kopayaApi: ElectronApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<title>kopaya</title>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100">
|
||||
<div id="root"></div>
|
||||
<script
|
||||
type="module"
|
||||
src="./main.tsx"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,7 @@
|
||||
export function getElectronApi() {
|
||||
if (!window.kopayaApi) {
|
||||
throw new Error('The Electron preload API is unavailable.');
|
||||
}
|
||||
|
||||
return window.kopayaApi;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import App from '@renderer/App';
|
||||
import '@renderer/styles.css';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
throw new Error('Could not find the root element.');
|
||||
}
|
||||
|
||||
createRoot(container).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const ipcChannels = {
|
||||
loadWorkspace: 'workspace:load',
|
||||
addProject: 'workspace:add-project',
|
||||
removeProject: 'workspace:remove-project',
|
||||
savePattern: 'patterns:save',
|
||||
deletePattern: 'patterns:delete',
|
||||
createSession: 'sessions:create',
|
||||
sendSessionMessage: 'sessions:send-message',
|
||||
selectProject: 'selection:project',
|
||||
selectPattern: 'selection:pattern',
|
||||
selectSession: 'selection:session',
|
||||
workspaceUpdated: 'workspace:updated',
|
||||
sessionEvent: 'sessions:event',
|
||||
} as const;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
export interface CreateSessionInput {
|
||||
projectId: string;
|
||||
patternId: string;
|
||||
}
|
||||
|
||||
export interface SavePatternInput {
|
||||
pattern: PatternDefinition;
|
||||
}
|
||||
|
||||
export interface SendSessionMessageInput {
|
||||
sessionId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
loadWorkspace(): Promise<WorkspaceState>;
|
||||
addProject(): Promise<WorkspaceState>;
|
||||
removeProject(projectId: string): Promise<WorkspaceState>;
|
||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
||||
selectProject(projectId?: string): Promise<WorkspaceState>;
|
||||
selectPattern(patternId?: string): Promise<WorkspaceState>;
|
||||
selectSession(sessionId?: string): Promise<WorkspaceState>;
|
||||
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
|
||||
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
|
||||
}
|
||||
|
||||
export interface RendererSelectionState {
|
||||
selectedProject?: ProjectRecord;
|
||||
selectedPattern?: PatternDefinition;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { PatternDefinition, PatternValidationIssue } from '@shared/domain/pattern';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
export interface SidecarModeCapability {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface SidecarCapabilities {
|
||||
runtime: 'dotnet-maf';
|
||||
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
|
||||
}
|
||||
|
||||
export interface DescribeCapabilitiesCommand {
|
||||
type: 'describe-capabilities';
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export interface ValidatePatternCommand {
|
||||
type: 'validate-pattern';
|
||||
requestId: string;
|
||||
pattern: PatternDefinition;
|
||||
}
|
||||
|
||||
export interface RunTurnCommand {
|
||||
type: 'run-turn';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
pattern: PatternDefinition;
|
||||
messages: ChatMessageRecord[];
|
||||
}
|
||||
|
||||
export type SidecarCommand = DescribeCapabilitiesCommand | ValidatePatternCommand | RunTurnCommand;
|
||||
|
||||
export interface CapabilitiesEvent {
|
||||
type: 'capabilities';
|
||||
requestId: string;
|
||||
capabilities: SidecarCapabilities;
|
||||
}
|
||||
|
||||
export interface PatternValidationEvent {
|
||||
type: 'pattern-validation';
|
||||
requestId: string;
|
||||
issues: PatternValidationIssue[];
|
||||
}
|
||||
|
||||
export interface TurnDeltaEvent {
|
||||
type: 'turn-delta';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
authorName: string;
|
||||
contentDelta: string;
|
||||
}
|
||||
|
||||
export interface TurnCompleteEvent {
|
||||
type: 'turn-complete';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
messages: ChatMessageRecord[];
|
||||
}
|
||||
|
||||
export interface CommandErrorEvent {
|
||||
type: 'command-error';
|
||||
requestId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CommandCompleteEvent {
|
||||
type: 'command-complete';
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export type SidecarEvent =
|
||||
| CapabilitiesEvent
|
||||
| PatternValidationEvent
|
||||
| TurnDeltaEvent
|
||||
| TurnCompleteEvent
|
||||
| CommandErrorEvent
|
||||
| CommandCompleteEvent;
|
||||
@@ -0,0 +1,12 @@
|
||||
export type SessionEventKind = 'status' | 'message-delta' | 'message-complete' | 'error';
|
||||
|
||||
export interface SessionEventRecord {
|
||||
sessionId: string;
|
||||
kind: SessionEventKind;
|
||||
occurredAt: string;
|
||||
status?: 'idle' | 'running' | 'error';
|
||||
messageId?: string;
|
||||
authorName?: string;
|
||||
contentDelta?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
export type OrchestrationMode =
|
||||
| 'single'
|
||||
| 'sequential'
|
||||
| 'concurrent'
|
||||
| 'handoff'
|
||||
| 'group-chat'
|
||||
| 'magentic';
|
||||
|
||||
export type PatternAvailability = 'available' | 'preview' | 'unavailable';
|
||||
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
||||
|
||||
export interface PatternAgentDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
model: string;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
}
|
||||
|
||||
export interface PatternDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mode: OrchestrationMode;
|
||||
availability: PatternAvailability;
|
||||
unavailabilityReason?: string;
|
||||
maxIterations: number;
|
||||
agents: PatternAgentDefinition[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PatternValidationIssue {
|
||||
level: 'error' | 'warning';
|
||||
field?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const defaultModels = {
|
||||
claude: 'claude-opus-4.5',
|
||||
gpt54: 'gpt-5.4',
|
||||
gpt53: 'gpt-5.3-codex',
|
||||
} as const;
|
||||
|
||||
export function createBuiltinPatterns(timestamp: string): PatternDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: 'pattern-single-chat',
|
||||
name: '1-on-1 Copilot Chat',
|
||||
description: 'Direct human-agent conversation for a selected project.',
|
||||
mode: 'single',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-single-primary',
|
||||
name: 'Primary Agent',
|
||||
description: 'General-purpose project assistant.',
|
||||
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
||||
model: defaultModels.gpt54,
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
id: 'pattern-sequential-review',
|
||||
name: 'Sequential Trio Review',
|
||||
description: 'Three Copilot-backed agents execute in order, refining the answer each step.',
|
||||
mode: 'sequential',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-sequential-analyst',
|
||||
name: 'Analyst',
|
||||
description: 'Breaks the task down and captures risks.',
|
||||
instructions: 'Analyze the request, identify constraints, and produce a short working plan.',
|
||||
model: defaultModels.gpt54,
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
{
|
||||
id: 'agent-sequential-builder',
|
||||
name: 'Builder',
|
||||
description: 'Translates the plan into a practical implementation.',
|
||||
instructions: 'Use the prior context to propose a concrete implementation.',
|
||||
model: defaultModels.gpt53,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'agent-sequential-reviewer',
|
||||
name: 'Reviewer',
|
||||
description: 'Checks the proposal for gaps and edge cases.',
|
||||
instructions: 'Review the previous answer, tighten it, and call out any missing edge cases.',
|
||||
model: defaultModels.claude,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
id: 'pattern-concurrent-brainstorm',
|
||||
name: 'Concurrent Brainstorm',
|
||||
description: 'Multiple agents respond in parallel for comparison or voting.',
|
||||
mode: 'concurrent',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-concurrent-architect',
|
||||
name: 'Architect',
|
||||
description: 'Focuses on architecture and boundaries.',
|
||||
instructions: 'Answer from an architecture-first perspective.',
|
||||
model: defaultModels.gpt54,
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
{
|
||||
id: 'agent-concurrent-product',
|
||||
name: 'Product',
|
||||
description: 'Focuses on UX and scope.',
|
||||
instructions: 'Answer from a product and UX perspective.',
|
||||
model: defaultModels.claude,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'agent-concurrent-implementer',
|
||||
name: 'Implementer',
|
||||
description: 'Focuses on practical delivery.',
|
||||
instructions: 'Answer from an implementation and testing perspective.',
|
||||
model: defaultModels.gpt53,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
id: 'pattern-handoff-support',
|
||||
name: 'Handoff Support Flow',
|
||||
description: 'A triage agent routes the task to specialists and can reclaim control.',
|
||||
mode: 'handoff',
|
||||
availability: 'available',
|
||||
maxIterations: 4,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-handoff-triage',
|
||||
name: 'Triage',
|
||||
description: 'Routes the request to the right specialist.',
|
||||
instructions: 'You triage requests and must hand them off to the most appropriate specialist.',
|
||||
model: defaultModels.gpt54,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'agent-handoff-ux',
|
||||
name: 'UX Specialist',
|
||||
description: 'Handles user experience questions.',
|
||||
instructions: 'You focus on navigation, UX, and interaction details.',
|
||||
model: defaultModels.claude,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'agent-handoff-runtime',
|
||||
name: 'Runtime Specialist',
|
||||
description: 'Handles backend and execution details.',
|
||||
instructions: 'You focus on runtime, orchestration, and backend integration details.',
|
||||
model: defaultModels.gpt53,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
id: 'pattern-group-chat',
|
||||
name: 'Collaborative Group Chat',
|
||||
description: 'Two or more agents iterate together under a round-robin manager.',
|
||||
mode: 'group-chat',
|
||||
availability: 'available',
|
||||
maxIterations: 5,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-group-writer',
|
||||
name: 'Writer',
|
||||
description: 'Produces candidate answers.',
|
||||
instructions: 'You draft a concise, useful answer for the task.',
|
||||
model: defaultModels.gpt54,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
{
|
||||
id: 'agent-group-reviewer',
|
||||
name: 'Reviewer',
|
||||
description: 'Critiques and refines the answer.',
|
||||
instructions: 'You review the latest answer and improve it when needed.',
|
||||
model: defaultModels.claude,
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
{
|
||||
id: 'pattern-magentic',
|
||||
name: 'Magentic Planning',
|
||||
description: 'Reserved for future .NET support when Magentic becomes available in C#.',
|
||||
mode: 'magentic',
|
||||
availability: 'unavailable',
|
||||
unavailabilityReason: 'Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.',
|
||||
maxIterations: 0,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-magentic-manager',
|
||||
name: 'Manager',
|
||||
description: 'Future manager agent.',
|
||||
instructions: 'Reserved until the .NET runtime supports Magentic orchestration.',
|
||||
model: defaultModels.gpt54,
|
||||
},
|
||||
{
|
||||
id: 'agent-magentic-specialist',
|
||||
name: 'Specialist',
|
||||
description: 'Future specialist agent.',
|
||||
instructions: 'Reserved until the .NET runtime supports Magentic orchestration.',
|
||||
model: defaultModels.claude,
|
||||
},
|
||||
],
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function validatePatternDefinition(pattern: PatternDefinition): PatternValidationIssue[] {
|
||||
const issues: PatternValidationIssue[] = [];
|
||||
|
||||
if (!pattern.name.trim()) {
|
||||
issues.push({ level: 'error', field: 'name', message: 'Pattern name is required.' });
|
||||
}
|
||||
|
||||
if (pattern.availability === 'unavailable') {
|
||||
issues.push({
|
||||
level: 'error',
|
||||
field: 'availability',
|
||||
message: pattern.unavailabilityReason ?? 'This orchestration mode is currently unavailable.',
|
||||
});
|
||||
}
|
||||
|
||||
if (pattern.agents.length === 0) {
|
||||
issues.push({ level: 'error', field: 'agents', message: 'At least one agent is required.' });
|
||||
}
|
||||
|
||||
if (pattern.mode === 'single' && pattern.agents.length !== 1) {
|
||||
issues.push({ level: 'error', field: 'agents', message: 'Single-agent chat requires exactly one agent.' });
|
||||
}
|
||||
|
||||
if (pattern.mode === 'handoff' && pattern.agents.length < 2) {
|
||||
issues.push({ level: 'error', field: 'agents', message: 'Handoff orchestration requires at least two agents.' });
|
||||
}
|
||||
|
||||
if (pattern.mode === 'group-chat' && pattern.agents.length < 2) {
|
||||
issues.push({ level: 'error', field: 'agents', message: 'Group chat requires at least two agents.' });
|
||||
}
|
||||
|
||||
if (pattern.mode === 'magentic') {
|
||||
issues.push({
|
||||
level: 'error',
|
||||
field: 'mode',
|
||||
message:
|
||||
pattern.unavailabilityReason ??
|
||||
'Magentic orchestration is currently documented as unsupported in the .NET Agent Framework.',
|
||||
});
|
||||
}
|
||||
|
||||
for (const agent of pattern.agents) {
|
||||
if (!agent.name.trim()) {
|
||||
issues.push({ level: 'error', field: 'agents.name', message: 'Every agent needs a name.' });
|
||||
}
|
||||
|
||||
if (!agent.instructions.trim()) {
|
||||
issues.push({
|
||||
level: 'warning',
|
||||
field: 'agents.instructions',
|
||||
message: `Agent "${agent.name || agent.id}" should have instructions.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!agent.model.trim()) {
|
||||
issues.push({
|
||||
level: 'error',
|
||||
field: 'agents.model',
|
||||
message: `Agent "${agent.name || agent.id}" requires a model identifier.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return issues;
|
||||
}
|
||||
|
||||
export function buildSessionTitle(pattern: PatternDefinition, messages: ChatMessageRecord[]): string {
|
||||
const firstUserMessage = messages.find((message) => message.role === 'user');
|
||||
if (!firstUserMessage) {
|
||||
return pattern.name;
|
||||
}
|
||||
|
||||
return firstUserMessage.content.slice(0, 48).trim() || pattern.name;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface ProjectRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
addedAt: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||
|
||||
export interface ChatMessageRecord {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
authorName: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
pending?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
id: string;
|
||||
projectId: string;
|
||||
patternId: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: SessionStatus;
|
||||
messages: ChatMessageRecord[];
|
||||
lastError?: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
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 { nowIso } from '@shared/utils/ids';
|
||||
|
||||
export interface WorkspaceState {
|
||||
projects: ProjectRecord[];
|
||||
patterns: PatternDefinition[];
|
||||
sessions: SessionRecord[];
|
||||
selectedProjectId?: string;
|
||||
selectedPatternId?: string;
|
||||
selectedSessionId?: string;
|
||||
lastUpdatedAt: string;
|
||||
}
|
||||
|
||||
export function createWorkspaceSeed(): WorkspaceState {
|
||||
const timestamp = nowIso();
|
||||
return {
|
||||
projects: [],
|
||||
patterns: createBuiltinPatterns(timestamp),
|
||||
sessions: [],
|
||||
lastUpdatedAt: timestamp,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function createId(prefix: string): string {
|
||||
const random = Math.random().toString(36).slice(2, 10);
|
||||
return `${prefix}-${random}`;
|
||||
}
|
||||
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
Reference in New Issue
Block a user