mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 05:43:57 +02:00
feat: redesign UI for modern look and clean UX
- Refined dark theme with zinc palette and indigo accents - Clean sidebar showing only project/session tree - Moved pattern management to full-screen settings panel - New session modal with project + pattern picker - Modern chat with avatar icons, inline send button, Enter-to-send - Welcome pane when no session is selected - Added lucide-react for consistent iconography - Custom scrollbar styling and auto-resize textarea - Removed clutter: no sidebar pattern list, no verbose headers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
"name": "kopaya",
|
"name": "kopaya",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"keytar": "^7.9.0",
|
"keytar": "^7.9.0",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
},
|
},
|
||||||
@@ -404,6 +405,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@0.577.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
"matcher": ["matcher@3.0.0", "", { "dependencies": { "escape-string-regexp": "^4.0.0" } }, "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng=="],
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"keytar": "^7.9.0",
|
"keytar": "^7.9.0",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4"
|
"react-dom": "^19.2.4"
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-153
@@ -2,24 +2,21 @@ import { useEffect, useMemo, useState } from 'react';
|
|||||||
|
|
||||||
import { AppShell } from '@renderer/components/AppShell';
|
import { AppShell } from '@renderer/components/AppShell';
|
||||||
import { ChatPane } from '@renderer/components/ChatPane';
|
import { ChatPane } from '@renderer/components/ChatPane';
|
||||||
import { PatternEditor } from '@renderer/components/PatternEditor';
|
import { NewSessionModal } from '@renderer/components/NewSessionModal';
|
||||||
|
import { SettingsPanel } from '@renderer/components/SettingsPanel';
|
||||||
import { Sidebar } from '@renderer/components/Sidebar';
|
import { Sidebar } from '@renderer/components/Sidebar';
|
||||||
|
import { WelcomePane } from '@renderer/components/WelcomePane';
|
||||||
import { getElectronApi } from '@renderer/lib/electronApi';
|
import { getElectronApi } from '@renderer/lib/electronApi';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { createId, nowIso } from '@shared/utils/ids';
|
import { createId, nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
function clonePattern(pattern: PatternDefinition): PatternDefinition {
|
|
||||||
return structuredClone(pattern);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDraftPattern(): PatternDefinition {
|
function createDraftPattern(): PatternDefinition {
|
||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
return {
|
return {
|
||||||
id: createId('pattern'),
|
id: createId('custom-pattern'),
|
||||||
name: 'New Pattern',
|
name: 'New Pattern',
|
||||||
description: 'Reusable orchestration pattern.',
|
description: '',
|
||||||
mode: 'single',
|
mode: 'single',
|
||||||
availability: 'available',
|
availability: 'available',
|
||||||
maxIterations: 1,
|
maxIterations: 1,
|
||||||
@@ -27,7 +24,7 @@ function createDraftPattern(): PatternDefinition {
|
|||||||
{
|
{
|
||||||
id: createId('agent'),
|
id: createId('agent'),
|
||||||
name: 'Primary Agent',
|
name: 'Primary Agent',
|
||||||
description: 'General-purpose project assistant.',
|
description: 'General-purpose assistant.',
|
||||||
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
instructions: 'You are a helpful coding assistant working inside the selected project.',
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
reasoningEffort: 'high',
|
reasoningEffort: 'high',
|
||||||
@@ -38,68 +35,25 @@ function createDraftPattern(): PatternDefinition {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export default function App() {
|
||||||
const api = getElectronApi();
|
const api = getElectronApi();
|
||||||
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
const [workspace, setWorkspace] = useState<WorkspaceState>();
|
||||||
const [draftPattern, setDraftPattern] = useState<PatternDefinition | null>(null);
|
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
const [showNewSession, setShowNewSession] = useState(false);
|
||||||
|
|
||||||
|
// Load workspace on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
|
||||||
void api
|
void api
|
||||||
.loadWorkspace()
|
.loadWorkspace()
|
||||||
.then((nextWorkspace) => {
|
.then((ws) => !disposed && setWorkspace(ws))
|
||||||
if (!disposed) {
|
.catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e)));
|
||||||
setWorkspace(nextWorkspace);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((nextError) => {
|
|
||||||
if (!disposed) {
|
|
||||||
setError(nextError instanceof Error ? nextError.message : String(nextError));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const offWorkspace = api.onWorkspaceUpdated((nextWorkspace) => {
|
const offWorkspace = api.onWorkspaceUpdated((ws) => {
|
||||||
setWorkspace(nextWorkspace);
|
setWorkspace(ws);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -109,118 +63,114 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Derived state
|
||||||
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(
|
const selectedSession = useMemo(
|
||||||
() => workspace?.sessions.find((session) => session.id === workspace.selectedSessionId),
|
() => workspace?.sessions.find((s) => s.id === workspace.selectedSessionId),
|
||||||
[workspace?.selectedSessionId, workspace?.sessions],
|
[workspace?.selectedSessionId, workspace?.sessions],
|
||||||
);
|
);
|
||||||
const selectedPattern = useMemo(
|
const patternForSession = useMemo(
|
||||||
() =>
|
() =>
|
||||||
draftPattern ??
|
selectedSession
|
||||||
workspace?.patterns.find((pattern) => pattern.id === workspace.selectedPatternId),
|
? workspace?.patterns.find((p) => p.id === selectedSession.patternId)
|
||||||
[draftPattern, workspace?.patterns, workspace?.selectedPatternId],
|
: undefined,
|
||||||
|
[selectedSession, workspace?.patterns],
|
||||||
);
|
);
|
||||||
const selectedProject = useMemo(
|
const projectForSession = useMemo(
|
||||||
() => workspace?.projects.find((project) => project.id === workspace.selectedProjectId),
|
() =>
|
||||||
[workspace?.projects, workspace?.selectedProjectId],
|
selectedSession
|
||||||
|
? workspace?.projects.find((p) => p.id === selectedSession.projectId)
|
||||||
|
: undefined,
|
||||||
|
[selectedSession, workspace?.projects],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 text-slate-100">
|
<div className="flex h-screen items-center justify-center bg-[var(--color-surface-0)]">
|
||||||
Loading workspace…
|
<div className="text-sm text-zinc-500">Loading workspace…</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const patternForSession = selectedSession
|
// Determine main content
|
||||||
? workspace.patterns.find((pattern) => pattern.id === selectedSession.patternId)
|
let content: React.ReactNode;
|
||||||
: undefined;
|
if (error) {
|
||||||
const projectForSession = selectedSession
|
content = (
|
||||||
? workspace.projects.find((project) => project.id === selectedSession.projectId)
|
<div className="flex h-full items-center justify-center px-8">
|
||||||
: undefined;
|
<div className="max-w-md rounded-xl border border-red-500/20 bg-red-500/5 p-6 text-center">
|
||||||
|
<h2 className="text-sm font-semibold text-red-300">Something went wrong</h2>
|
||||||
|
<p className="mt-2 text-[13px] leading-relaxed text-red-400/80">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (selectedSession && patternForSession && projectForSession) {
|
||||||
|
content = (
|
||||||
|
<ChatPane
|
||||||
|
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
|
||||||
|
pattern={patternForSession}
|
||||||
|
project={projectForSession}
|
||||||
|
session={selectedSession}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
content = (
|
||||||
|
<WelcomePane
|
||||||
|
hasProjects={workspace.projects.length > 0}
|
||||||
|
onAddProject={() => void api.addProject()}
|
||||||
|
onNewSession={() => setShowNewSession(true)}
|
||||||
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings overlay
|
||||||
|
const overlay = showSettings ? (
|
||||||
|
<SettingsPanel
|
||||||
|
onClose={() => setShowSettings(false)}
|
||||||
|
onDeletePattern={async (id) => {
|
||||||
|
await api.deletePattern(id);
|
||||||
|
}}
|
||||||
|
onNewPattern={createDraftPattern}
|
||||||
|
onSavePattern={async (pattern) => {
|
||||||
|
await api.savePattern({ pattern });
|
||||||
|
}}
|
||||||
|
patterns={workspace.patterns}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<>
|
||||||
content={
|
<AppShell
|
||||||
error ? (
|
content={content}
|
||||||
<div className="flex h-screen items-center justify-center px-10">
|
overlay={overlay}
|
||||||
<div className="max-w-lg rounded-3xl border border-rose-500/40 bg-rose-500/10 p-8 text-rose-100">
|
sidebar={
|
||||||
<div className="text-xs uppercase tracking-[0.2em] text-rose-200">Error</div>
|
<Sidebar
|
||||||
<h2 className="mt-3 text-2xl font-semibold">Something went wrong</h2>
|
onAddProject={() => void api.addProject()}
|
||||||
<p className="mt-3 text-sm leading-7">{error}</p>
|
onNewSession={() => setShowNewSession(true)}
|
||||||
</div>
|
onOpenSettings={() => setShowSettings(true)}
|
||||||
</div>
|
onProjectSelect={(projectId) => {
|
||||||
) : selectedSession && patternForSession && projectForSession ? (
|
void api.selectProject(projectId);
|
||||||
<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}
|
onSessionSelect={(sessionId) => {
|
||||||
|
void api.selectSession(sessionId);
|
||||||
|
}}
|
||||||
|
workspace={workspace}
|
||||||
/>
|
/>
|
||||||
) : (
|
}
|
||||||
<EmptyDetail />
|
/>
|
||||||
)
|
|
||||||
}
|
|
||||||
sidebar={
|
|
||||||
<Sidebar
|
|
||||||
onAddProject={() => {
|
|
||||||
void api.addProject();
|
|
||||||
}}
|
|
||||||
onCreateSession={() => {
|
|
||||||
if (!workspace.selectedProjectId || !workspace.selectedPatternId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void api.createSession({
|
{showNewSession && (
|
||||||
projectId: workspace.selectedProjectId,
|
<NewSessionModal
|
||||||
patternId: workspace.selectedPatternId,
|
defaultProjectId={workspace.selectedProjectId}
|
||||||
});
|
onClose={() => setShowNewSession(false)}
|
||||||
|
onCreate={(projectId, patternId) => {
|
||||||
|
setShowNewSession(false);
|
||||||
|
void api.createSession({ projectId, patternId });
|
||||||
}}
|
}}
|
||||||
onNewPattern={() => {
|
patterns={workspace.patterns}
|
||||||
setDraftPattern(createDraftPattern());
|
projects={workspace.projects}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,17 @@ import type { ReactNode } from 'react';
|
|||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
sidebar: ReactNode;
|
sidebar: ReactNode;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
|
overlay?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ sidebar, content }: AppShellProps) {
|
export function AppShell({ sidebar, content, overlay }: AppShellProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-slate-950 text-slate-100">
|
<div className="relative flex h-screen bg-[var(--color-surface-0)] text-zinc-100">
|
||||||
<aside className="w-[360px] shrink-0 border-r border-slate-800 bg-slate-900/90">{sidebar}</aside>
|
<aside className="flex w-72 shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||||
<main className="min-w-0 flex-1">{content}</main>
|
{sidebar}
|
||||||
|
</aside>
|
||||||
|
<main className="relative min-w-0 flex-1">{content}</main>
|
||||||
|
{overlay}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { type KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||||
|
import { AlertCircle, ArrowUp, Bot, Loader2, User } from 'lucide-react';
|
||||||
|
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
@@ -14,6 +15,7 @@ interface ChatPaneProps {
|
|||||||
export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
|
export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
transcriptRef.current?.scrollTo({
|
transcriptRef.current?.scrollTo({
|
||||||
@@ -23,117 +25,144 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
|
|||||||
}, [session.messages.length]);
|
}, [session.messages.length]);
|
||||||
|
|
||||||
const isBusy = session.status === 'running';
|
const isBusy = session.status === 'running';
|
||||||
const sessionStats = useMemo(
|
|
||||||
() => `${session.messages.length} messages • ${pattern.agents.length} agent${pattern.agents.length === 1 ? '' : 's'}`,
|
async function handleSubmit() {
|
||||||
[pattern.agents.length, session.messages.length],
|
const text = input.trim();
|
||||||
);
|
if (!text || isBusy) return;
|
||||||
|
setInput('');
|
||||||
|
await onSend(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<header className="border-b border-slate-800 px-8 py-6">
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-6">
|
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-3">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">{project.name}</div>
|
<h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2>
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-white">{session.title}</h2>
|
<p className="mt-0.5 truncate text-[12px] text-zinc-500">
|
||||||
<p className="mt-2 text-sm text-slate-400">
|
{project.name} · {pattern.name} · {pattern.mode}
|
||||||
Pattern: <span className="font-medium text-slate-200">{pattern.name}</span> • Mode:{' '}
|
</p>
|
||||||
<span className="font-medium text-slate-200">{pattern.mode}</span>
|
</div>
|
||||||
</p>
|
<div className="flex items-center gap-2">
|
||||||
<p className="mt-1 text-sm text-slate-500">{sessionStats}</p>
|
{session.status === 'running' && (
|
||||||
</div>
|
<div className="flex items-center gap-1.5 text-[12px] text-blue-400">
|
||||||
<div
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
className={`rounded-full px-3 py-1.5 text-xs font-medium uppercase tracking-wide ${
|
Running
|
||||||
session.status === 'error'
|
</div>
|
||||||
? 'bg-rose-500/15 text-rose-200'
|
)}
|
||||||
: session.status === 'running'
|
{session.status === 'error' && (
|
||||||
? 'bg-sky-500/15 text-sky-200'
|
<div className="flex items-center gap-1.5 text-[12px] text-red-400">
|
||||||
: 'bg-slate-800 text-slate-200'
|
<AlertCircle className="size-3.5" />
|
||||||
}`}
|
Error
|
||||||
>
|
</div>
|
||||||
{session.status}
|
)}
|
||||||
</div>
|
{session.status === 'idle' && session.messages.length > 0 && (
|
||||||
|
<span className="text-[12px] text-zinc-600">
|
||||||
|
{session.messages.length} message{session.messages.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div
|
{/* Messages */}
|
||||||
className="flex-1 overflow-y-auto px-8 py-6"
|
<div className="flex-1 overflow-y-auto" ref={transcriptRef}>
|
||||||
ref={transcriptRef}
|
|
||||||
>
|
|
||||||
{session.messages.length === 0 ? (
|
{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">
|
<div className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
|
||||||
Start the conversation to launch this orchestration against <span className="font-medium text-slate-200">{project.path}</span>.
|
<Bot className="size-10 text-zinc-800" />
|
||||||
|
<p className="text-sm text-zinc-500">
|
||||||
|
Send a message to start the conversation
|
||||||
|
</p>
|
||||||
|
<p className="text-[12px] text-zinc-700">
|
||||||
|
Using <span className="text-zinc-500">{pattern.name}</span> in{' '}
|
||||||
|
<span className="text-zinc-500">{project.name}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mx-auto flex max-w-4xl flex-col gap-4">
|
<div className="mx-auto max-w-3xl px-6 py-4">
|
||||||
{session.messages.map((message) => {
|
<div className="space-y-1">
|
||||||
const isUser = message.role === 'user';
|
{session.messages.map((message) => {
|
||||||
return (
|
const isUser = message.role === 'user';
|
||||||
<div
|
return (
|
||||||
className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}
|
<div className="group py-3" key={message.id}>
|
||||||
key={message.id}
|
<div className="flex gap-3">
|
||||||
>
|
<div
|
||||||
<div
|
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
|
||||||
className={`max-w-3xl rounded-3xl px-5 py-4 shadow-sm ${
|
isUser
|
||||||
isUser
|
? 'bg-indigo-600 text-white'
|
||||||
? 'bg-sky-500 text-slate-950'
|
: 'bg-zinc-800 text-zinc-400'
|
||||||
: 'border border-slate-800 bg-slate-900/85 text-slate-100'
|
}`}
|
||||||
}`}
|
>
|
||||||
>
|
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
|
||||||
<div className={`text-xs font-semibold uppercase tracking-wide ${isUser ? 'text-slate-800' : 'text-slate-400'}`}>
|
</div>
|
||||||
{message.authorName}
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="mb-1 text-[12px] font-medium text-zinc-400">
|
||||||
|
{message.authorName}
|
||||||
|
</div>
|
||||||
|
<div className="whitespace-pre-wrap text-[14px] leading-relaxed text-zinc-200">
|
||||||
|
{message.content}
|
||||||
|
</div>
|
||||||
|
{message.pending && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-[12px] text-zinc-500">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
Generating...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-slate-800 px-8 py-5">
|
{/* Input area */}
|
||||||
{session.lastError ? (
|
<div className="border-t border-[var(--color-border)] px-6 py-4">
|
||||||
<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 && (
|
||||||
{session.lastError}
|
<div className="mb-3 flex items-start gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-[13px] text-red-300">
|
||||||
|
<AlertCircle className="mt-0.5 size-4 shrink-0 text-red-400" />
|
||||||
|
<span>{session.lastError}</span>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
<form
|
<div className="mx-auto max-w-3xl">
|
||||||
className="mx-auto flex max-w-4xl flex-col gap-3"
|
<div className="relative rounded-xl border border-zinc-700 bg-zinc-900 transition-colors focus-within:border-indigo-500/50">
|
||||||
onSubmit={async (event) => {
|
<textarea
|
||||||
event.preventDefault();
|
className="auto-resize-textarea block w-full resize-none bg-transparent px-4 py-3 pr-12 text-[14px] text-zinc-100 placeholder-zinc-600 outline-none"
|
||||||
if (!input.trim()) {
|
disabled={isBusy}
|
||||||
return;
|
onChange={(e) => setInput(e.target.value)}
|
||||||
}
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={isBusy ? 'Waiting for response...' : 'Message...'}
|
||||||
const nextInput = input;
|
ref={textareaRef}
|
||||||
setInput('');
|
rows={1}
|
||||||
await onSend(nextInput);
|
value={input}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<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
|
<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"
|
className={`absolute bottom-2 right-2 flex size-8 items-center justify-center rounded-lg transition ${
|
||||||
|
input.trim() && !isBusy
|
||||||
|
? 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||||
|
: 'bg-zinc-800 text-zinc-600'
|
||||||
|
}`}
|
||||||
disabled={isBusy || !input.trim()}
|
disabled={isBusy || !input.trim()}
|
||||||
type="submit"
|
onClick={() => void handleSubmit()}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
{isBusy ? 'Running…' : 'Send'}
|
{isBusy ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowUp className="size-4" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
|
|
||||||
|
interface NewSessionModalProps {
|
||||||
|
projects: ProjectRecord[];
|
||||||
|
patterns: PatternDefinition[];
|
||||||
|
defaultProjectId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreate: (projectId: string, patternId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewSessionModal({
|
||||||
|
projects,
|
||||||
|
patterns,
|
||||||
|
defaultProjectId,
|
||||||
|
onClose,
|
||||||
|
onCreate,
|
||||||
|
}: NewSessionModalProps) {
|
||||||
|
const availablePatterns = patterns.filter((p) => p.availability !== 'unavailable');
|
||||||
|
const [projectId, setProjectId] = useState(defaultProjectId ?? projects[0]?.id ?? '');
|
||||||
|
const [patternId, setPatternId] = useState(availablePatterns[0]?.id ?? '');
|
||||||
|
|
||||||
|
const canCreate = projectId && patternId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||||
|
<div className="w-full max-w-md rounded-xl border border-zinc-800 bg-zinc-900 shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-100">New Session</h2>
|
||||||
|
<button
|
||||||
|
className="flex size-7 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="space-y-4 px-5 py-5">
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-zinc-400">Project</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition focus:border-indigo-500/50"
|
||||||
|
onChange={(e) => setProjectId(e.target.value)}
|
||||||
|
value={projectId}
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-zinc-400">Pattern</span>
|
||||||
|
<select
|
||||||
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-950 px-3 py-2 text-[13px] text-zinc-100 outline-none transition focus:border-indigo-500/50"
|
||||||
|
onChange={(e) => setPatternId(e.target.value)}
|
||||||
|
value={patternId}
|
||||||
|
>
|
||||||
|
{availablePatterns.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} ({p.mode})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{patternId && (
|
||||||
|
<p className="text-[12px] text-zinc-600">
|
||||||
|
{availablePatterns.find((p) => p.id === patternId)?.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-zinc-800 px-5 py-3">
|
||||||
|
<button
|
||||||
|
className="rounded-lg px-4 py-1.5 text-[13px] text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-1.5 text-[13px] font-medium text-white transition hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
disabled={!canCreate}
|
||||||
|
onClick={() => canCreate && onCreate(projectId, patternId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Start Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { AlertCircle, CheckCircle, ChevronLeft, Plus, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
import { validatePatternDefinition, type OrchestrationMode, type PatternDefinition } from '@shared/domain/pattern';
|
import { validatePatternDefinition, type OrchestrationMode, type PatternDefinition } from '@shared/domain/pattern';
|
||||||
|
|
||||||
interface PatternEditorProps {
|
interface PatternEditorProps {
|
||||||
@@ -6,244 +8,254 @@ interface PatternEditorProps {
|
|||||||
onChange: (pattern: PatternDefinition) => void;
|
onChange: (pattern: PatternDefinition) => void;
|
||||||
onDelete?: () => void;
|
onDelete?: () => void;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
onBack: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modes: OrchestrationMode[] = ['single', 'sequential', 'concurrent', 'handoff', 'group-chat', 'magentic'];
|
const modes: OrchestrationMode[] = ['single', 'sequential', 'concurrent', 'handoff', 'group-chat', 'magentic'];
|
||||||
|
|
||||||
export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave }: PatternEditorProps) {
|
function InputField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
multiline,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
multiline?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const baseClasses =
|
||||||
|
'w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-[13px] text-zinc-100 placeholder-zinc-600 outline-none transition focus:border-indigo-500/50';
|
||||||
|
return (
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-zinc-400">{label}</span>
|
||||||
|
{multiline ? (
|
||||||
|
<textarea
|
||||||
|
className={`${baseClasses} min-h-20 resize-y`}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className={baseClasses}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PatternEditor({ pattern, isBuiltin, onChange, onDelete, onSave, onBack }: PatternEditorProps) {
|
||||||
const issues = validatePatternDefinition(pattern);
|
const issues = validatePatternDefinition(pattern);
|
||||||
|
|
||||||
|
function updateAgent(agentId: string, patch: Record<string, string>) {
|
||||||
|
onChange({
|
||||||
|
...pattern,
|
||||||
|
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="border-b border-slate-800 px-8 py-6">
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-center justify-between border-b border-zinc-800 px-6 py-3">
|
||||||
|
<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>
|
<div>
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">Pattern</div>
|
<h3 className="text-sm font-semibold text-zinc-100">
|
||||||
<h2 className="mt-2 text-2xl font-semibold text-white">{pattern.name || 'Untitled pattern'}</h2>
|
{pattern.name || 'Untitled pattern'}
|
||||||
<p className="mt-2 max-w-3xl text-sm text-slate-400">
|
</h3>
|
||||||
Define a reusable orchestration blueprint that can be launched against any project in the
|
<p className="text-[12px] text-zinc-500">
|
||||||
workspace.
|
{isBuiltin ? 'Built-in pattern' : 'Custom pattern'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
</div>
|
||||||
{!isBuiltin && onDelete ? (
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{!isBuiltin && onDelete && (
|
||||||
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
|
<button
|
||||||
className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-slate-950 hover:bg-sky-400"
|
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[13px] text-red-400 transition hover:bg-red-500/10"
|
||||||
onClick={onSave}
|
onClick={onDelete}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Save Pattern
|
<Trash2 className="size-3.5" />
|
||||||
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
)}
|
||||||
|
<button
|
||||||
|
className="rounded-lg bg-indigo-600 px-4 py-1.5 text-[13px] font-medium text-white transition hover:bg-indigo-500"
|
||||||
|
onClick={onSave}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid flex-1 grid-cols-[minmax(0,1fr)_320px] gap-0 overflow-hidden">
|
{/* Body */}
|
||||||
<div className="overflow-y-auto px-8 py-6">
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
<div className="space-y-8">
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
<section className="grid gap-4 md:grid-cols-2">
|
{/* Validation banner */}
|
||||||
<label className="space-y-2">
|
{issues.length > 0 ? (
|
||||||
<span className="text-sm font-medium text-slate-200">Name</span>
|
<div className="space-y-2">
|
||||||
<input
|
{issues.map((issue, i) => (
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
<div
|
||||||
onChange={(event) => onChange({ ...pattern, name: event.target.value })}
|
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] ${
|
||||||
value={pattern.name}
|
issue.level === 'error'
|
||||||
/>
|
? 'bg-red-500/10 text-red-300'
|
||||||
</label>
|
: 'bg-amber-500/10 text-amber-300'
|
||||||
<label className="space-y-2">
|
}`}
|
||||||
<span className="text-sm font-medium text-slate-200">Mode</span>
|
key={`${issue.field ?? 'v'}-${i}`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
{issue.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-emerald-500/10 px-3 py-2 text-[13px] text-emerald-300">
|
||||||
|
<CheckCircle className="size-3.5" />
|
||||||
|
Pattern is valid and ready to use
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Basic fields */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
|
General
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<InputField
|
||||||
|
label="Name"
|
||||||
|
onChange={(v) => onChange({ ...pattern, name: v })}
|
||||||
|
placeholder="Pattern name"
|
||||||
|
value={pattern.name}
|
||||||
|
/>
|
||||||
|
<label className="block space-y-1.5">
|
||||||
|
<span className="text-[12px] font-medium text-zinc-400">Mode</span>
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2 text-[13px] text-zinc-100 outline-none transition focus:border-indigo-500/50"
|
||||||
onChange={(event) =>
|
onChange={(e) =>
|
||||||
onChange({
|
onChange({ ...pattern, mode: e.target.value as OrchestrationMode })
|
||||||
...pattern,
|
|
||||||
mode: event.target.value as OrchestrationMode,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
value={pattern.mode}
|
value={pattern.mode}
|
||||||
>
|
>
|
||||||
{modes.map((mode) => (
|
{modes.map((m) => (
|
||||||
<option
|
<option key={m} value={m}>
|
||||||
key={mode}
|
{m}
|
||||||
value={mode}
|
|
||||||
>
|
|
||||||
{mode}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="space-y-2 md:col-span-2">
|
</div>
|
||||||
<span className="text-sm font-medium text-slate-200">Description</span>
|
<InputField
|
||||||
<textarea
|
label="Description"
|
||||||
className="min-h-24 w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100"
|
multiline
|
||||||
onChange={(event) => onChange({ ...pattern, description: event.target.value })}
|
onChange={(v) => onChange({ ...pattern, description: v })}
|
||||||
value={pattern.description}
|
placeholder="What this pattern does..."
|
||||||
/>
|
value={pattern.description}
|
||||||
</label>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
{/* Agents */}
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
<section className="space-y-4">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-white">Agents</h3>
|
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
Agents ({pattern.agents.length})
|
||||||
Configure the participating Copilot-backed agents and their model selections.
|
</h4>
|
||||||
</p>
|
<button
|
||||||
</div>
|
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-[12px] font-medium text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
<button
|
onClick={() =>
|
||||||
className="rounded-lg border border-slate-700 px-3 py-2 text-sm font-medium text-slate-100 hover:bg-slate-800"
|
onChange({
|
||||||
onClick={() =>
|
...pattern,
|
||||||
onChange({
|
agents: [
|
||||||
...pattern,
|
...pattern.agents,
|
||||||
agents: [
|
{
|
||||||
...pattern.agents,
|
id: `agent-${crypto.randomUUID()}`,
|
||||||
{
|
name: `Agent ${pattern.agents.length + 1}`,
|
||||||
id: `agent-${crypto.randomUUID()}`,
|
description: '',
|
||||||
name: `Agent ${pattern.agents.length + 1}`,
|
instructions: '',
|
||||||
description: 'New participant',
|
model: 'gpt-5.4',
|
||||||
instructions: 'You are a helpful specialist in this orchestration.',
|
},
|
||||||
model: 'gpt-5.4',
|
],
|
||||||
},
|
})
|
||||||
],
|
}
|
||||||
})
|
type="button"
|
||||||
}
|
>
|
||||||
type="button"
|
<Plus className="size-3" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{pattern.agents.map((agent, index) => (
|
||||||
|
<div
|
||||||
|
className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-4"
|
||||||
|
key={agent.id}
|
||||||
>
|
>
|
||||||
Add Agent
|
<div className="mb-3 flex items-center justify-between">
|
||||||
</button>
|
<span className="text-[12px] font-medium text-zinc-500">
|
||||||
</div>
|
Agent {index + 1}
|
||||||
|
</span>
|
||||||
<div className="space-y-4">
|
{pattern.agents.length > 1 && (
|
||||||
{pattern.agents.map((agent, index) => (
|
<button
|
||||||
<div
|
className="text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||||
className="rounded-2xl border border-slate-800 bg-slate-900/70 p-4"
|
onClick={() =>
|
||||||
key={agent.id}
|
onChange({
|
||||||
>
|
...pattern,
|
||||||
<div className="mb-4 flex items-center justify-between gap-4">
|
agents: pattern.agents.filter((a) => a.id !== agent.id),
|
||||||
<div className="text-sm font-medium text-slate-200">Agent {index + 1}</div>
|
})
|
||||||
{pattern.agents.length > 1 ? (
|
}
|
||||||
<button
|
type="button"
|
||||||
className="text-sm text-rose-200 hover:text-rose-100"
|
>
|
||||||
onClick={() =>
|
Remove
|
||||||
onChange({
|
</button>
|
||||||
...pattern,
|
)}
|
||||||
agents: pattern.agents.filter((current) => current.id !== agent.id),
|
</div>
|
||||||
})
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
}
|
<InputField
|
||||||
type="button"
|
label="Name"
|
||||||
>
|
onChange={(v) => updateAgent(agent.id, { name: v })}
|
||||||
Remove
|
value={agent.name}
|
||||||
</button>
|
/>
|
||||||
) : null}
|
<InputField
|
||||||
|
label="Model"
|
||||||
|
onChange={(v) => updateAgent(agent.id, { model: v })}
|
||||||
|
placeholder="e.g. gpt-5.4"
|
||||||
|
value={agent.model}
|
||||||
|
/>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<InputField
|
||||||
|
label="Description"
|
||||||
|
onChange={(v) => updateAgent(agent.id, { description: v })}
|
||||||
|
value={agent.description}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<InputField
|
||||||
<label className="space-y-2">
|
label="Instructions"
|
||||||
<span className="text-sm font-medium text-slate-300">Name</span>
|
multiline
|
||||||
<input
|
onChange={(v) => updateAgent(agent.id, { instructions: v })}
|
||||||
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100"
|
placeholder="System prompt for this agent..."
|
||||||
onChange={(event) =>
|
value={agent.instructions}
|
||||||
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>
|
</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>
|
</div>
|
||||||
))
|
))}
|
||||||
)}
|
</div>
|
||||||
|
</section>
|
||||||
{isBuiltin ? (
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronRight, Plus, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||||
|
import { PatternEditor } from '@renderer/components/PatternEditor';
|
||||||
|
|
||||||
|
interface SettingsPanelProps {
|
||||||
|
patterns: PatternDefinition[];
|
||||||
|
onClose: () => void;
|
||||||
|
onSavePattern: (pattern: PatternDefinition) => Promise<void>;
|
||||||
|
onDeletePattern: (patternId: string) => Promise<void>;
|
||||||
|
onNewPattern: () => PatternDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeBadgeClasses(pattern: PatternDefinition) {
|
||||||
|
if (pattern.availability === 'unavailable') return 'bg-amber-500/10 text-amber-400';
|
||||||
|
return 'bg-zinc-800 text-zinc-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsPanel({
|
||||||
|
patterns,
|
||||||
|
onClose,
|
||||||
|
onSavePattern,
|
||||||
|
onDeletePattern,
|
||||||
|
onNewPattern,
|
||||||
|
}: SettingsPanelProps) {
|
||||||
|
const [editingPattern, setEditingPattern] = useState<PatternDefinition | null>(null);
|
||||||
|
|
||||||
|
if (editingPattern) {
|
||||||
|
const isBuiltin = editingPattern.id.startsWith('pattern-');
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||||
|
<PatternEditor
|
||||||
|
isBuiltin={isBuiltin}
|
||||||
|
onBack={() => setEditingPattern(null)}
|
||||||
|
onChange={setEditingPattern}
|
||||||
|
onDelete={
|
||||||
|
isBuiltin
|
||||||
|
? undefined
|
||||||
|
: async () => {
|
||||||
|
await onDeletePattern(editingPattern.id);
|
||||||
|
setEditingPattern(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onSave={async () => {
|
||||||
|
await onSavePattern(editingPattern);
|
||||||
|
setEditingPattern(null);
|
||||||
|
}}
|
||||||
|
pattern={editingPattern}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-[var(--color-surface-0)]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-zinc-100">Settings</h2>
|
||||||
|
<button
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
|
onClick={onClose}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<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={() => setEditingPattern(onNewPattern())}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
New Pattern
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{patterns.map((pattern) => (
|
||||||
|
<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"
|
||||||
|
key={pattern.id}
|
||||||
|
onClick={() => setEditingPattern(structuredClone(pattern))}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[13px] font-medium text-zinc-200">{pattern.name}</span>
|
||||||
|
<span className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ${modeBadgeClasses(pattern)}`}>
|
||||||
|
{pattern.mode}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 truncate text-[12px] text-zinc-500">{pattern.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-[12px] text-zinc-600">
|
||||||
|
{pattern.agents.length} agent{pattern.agents.length === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
<ChevronRight className="size-4 text-zinc-700 transition group-hover:text-zinc-500" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+134
-144
@@ -1,4 +1,13 @@
|
|||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
FolderOpen,
|
||||||
|
MessageSquare,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
@@ -6,183 +15,164 @@ import type { WorkspaceState } from '@shared/domain/workspace';
|
|||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
workspace: WorkspaceState;
|
workspace: WorkspaceState;
|
||||||
onAddProject: () => void;
|
onAddProject: () => void;
|
||||||
onCreateSession: () => void;
|
onNewSession: () => void;
|
||||||
onNewPattern: () => void;
|
|
||||||
onProjectSelect: (projectId?: string) => void;
|
onProjectSelect: (projectId?: string) => void;
|
||||||
onPatternSelect: (patternId?: string) => void;
|
onSessionSelect: (sessionId: string) => void;
|
||||||
onSessionSelect: (sessionId?: string) => void;
|
onOpenSettings: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemClasses(active: boolean) {
|
function statusDot(status: SessionRecord['status']) {
|
||||||
return active
|
if (status === 'running') return 'bg-blue-400';
|
||||||
? 'w-full rounded-lg border border-sky-500/60 bg-sky-500/10 px-3 py-2 text-left text-sm text-sky-200'
|
if (status === 'error') return 'bg-red-400';
|
||||||
: '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';
|
return 'bg-zinc-600';
|
||||||
}
|
}
|
||||||
|
|
||||||
function modeBadge(pattern: PatternDefinition) {
|
function ProjectGroup({
|
||||||
if (pattern.availability === 'unavailable') {
|
project,
|
||||||
return 'bg-amber-500/15 text-amber-200';
|
sessions,
|
||||||
}
|
selectedSessionId,
|
||||||
|
onSessionSelect,
|
||||||
|
}: {
|
||||||
|
project: ProjectRecord;
|
||||||
|
sessions: SessionRecord[];
|
||||||
|
selectedSessionId?: string;
|
||||||
|
onSessionSelect: (sessionId: string) => void;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(true);
|
||||||
|
|
||||||
return 'bg-emerald-500/10 text-emerald-200';
|
return (
|
||||||
}
|
<div>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] font-medium text-zinc-400 transition hover:bg-zinc-800/60 hover:text-zinc-200"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<ChevronDown className="size-3.5 shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-3.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<FolderOpen className="size-3.5 shrink-0 text-zinc-500" />
|
||||||
|
<span className="truncate">{project.name}</span>
|
||||||
|
<span className="ml-auto text-[11px] text-zinc-600">{sessions.length}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
function sessionCountLabel(project: ProjectRecord, sessions: SessionRecord[]) {
|
{expanded && (
|
||||||
const count = sessions.filter((session) => session.projectId === project.id).length;
|
<div className="ml-3 mt-0.5 space-y-0.5 border-l border-zinc-800 pl-3">
|
||||||
return `${count} session${count === 1 ? '' : 's'}`;
|
{sessions.length === 0 ? (
|
||||||
|
<div className="px-2 py-1.5 text-[12px] text-zinc-600">No sessions yet</div>
|
||||||
|
) : (
|
||||||
|
sessions.map((session) => {
|
||||||
|
const isActive = selectedSessionId === session.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-[13px] transition ${
|
||||||
|
isActive
|
||||||
|
? 'bg-[var(--color-accent-muted)] text-indigo-200'
|
||||||
|
: 'text-zinc-300 hover:bg-zinc-800/60 hover:text-zinc-100'
|
||||||
|
}`}
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => onSessionSelect(session.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<MessageSquare className="size-3.5 shrink-0 text-zinc-500" />
|
||||||
|
<span className="truncate">{session.title}</span>
|
||||||
|
<span className={`ml-auto size-2 shrink-0 rounded-full ${statusDot(session.status)}`} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
workspace,
|
workspace,
|
||||||
onAddProject,
|
onAddProject,
|
||||||
onCreateSession,
|
onNewSession,
|
||||||
onNewPattern,
|
|
||||||
onProjectSelect,
|
onProjectSelect,
|
||||||
onPatternSelect,
|
|
||||||
onSessionSelect,
|
onSessionSelect,
|
||||||
|
onOpenSettings,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="border-b border-slate-800 px-5 py-4">
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-xs uppercase tracking-[0.22em] text-slate-400">kopaya</div>
|
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-600 text-[11px] font-bold text-white">
|
||||||
<h1 className="mt-1 text-xl font-semibold text-white">Agent Orchestrator</h1>
|
K
|
||||||
<p className="mt-1 text-sm text-slate-400">
|
|
||||||
React + Electron frontend with a bundled .NET sidecar.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-zinc-100">kopaya</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
className="rounded-lg border border-slate-700 px-3 py-2 text-sm font-medium text-slate-200 hover:bg-slate-800"
|
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
|
||||||
onClick={onAddProject}
|
onClick={onOpenSettings}
|
||||||
|
title="Settings"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
Add Project
|
<Settings className="size-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg bg-indigo-600 text-white transition hover:bg-indigo-500"
|
||||||
|
onClick={onNewSession}
|
||||||
|
title="New session"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-6 overflow-y-auto px-4 py-4">
|
{/* Project + Session Tree */}
|
||||||
<section>
|
<div className="flex-1 overflow-y-auto px-2 py-2">
|
||||||
<div className="mb-3 flex items-center justify-between px-1">
|
{workspace.projects.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
|
||||||
|
<FolderOpen className="size-8 text-zinc-700" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-slate-200">Patterns</h2>
|
<p className="text-sm text-zinc-400">No projects yet</p>
|
||||||
<p className="text-xs text-slate-500">Global orchestration library</p>
|
<p className="mt-1 text-[12px] text-zinc-600">
|
||||||
|
Add a project folder to start orchestrating
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
className="mt-1 rounded-lg bg-zinc-800 px-3 py-1.5 text-[13px] font-medium text-zinc-200 transition hover:bg-zinc-700"
|
||||||
onClick={onNewPattern}
|
onClick={onAddProject}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
New Pattern
|
Add Project
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
{workspace.patterns.map((pattern) => (
|
<div className="space-y-1">
|
||||||
<button
|
{workspace.projects.map((project) => (
|
||||||
className={itemClasses(workspace.selectedPatternId === pattern.id && !workspace.selectedSessionId)}
|
<ProjectGroup
|
||||||
key={pattern.id}
|
key={project.id}
|
||||||
onClick={() => onPatternSelect(pattern.id)}
|
onSessionSelect={onSessionSelect}
|
||||||
type="button"
|
project={project}
|
||||||
>
|
selectedSessionId={workspace.selectedSessionId}
|
||||||
<div className="flex items-start justify-between gap-3">
|
sessions={workspace.sessions.filter((s) => s.projectId === project.id)}
|
||||||
<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>
|
</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>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{workspace.projects.length > 0 && (
|
||||||
|
<div className="border-t border-[var(--color-border)] px-3 py-2">
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-[13px] text-zinc-500 transition hover:bg-zinc-800/60 hover:text-zinc-300"
|
||||||
|
onClick={onAddProject}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MessageSquare, Plus, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
interface WelcomePaneProps {
|
||||||
|
hasProjects: boolean;
|
||||||
|
onNewSession: () => void;
|
||||||
|
onAddProject: () => void;
|
||||||
|
onOpenSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WelcomePane({
|
||||||
|
hasProjects,
|
||||||
|
onNewSession,
|
||||||
|
onAddProject,
|
||||||
|
onOpenSettings,
|
||||||
|
}: WelcomePaneProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center px-8">
|
||||||
|
<div className="flex flex-col items-center gap-6 text-center">
|
||||||
|
<div className="flex size-16 items-center justify-center rounded-2xl bg-indigo-600/10">
|
||||||
|
<MessageSquare className="size-8 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-zinc-100">Welcome to kopaya</h1>
|
||||||
|
<p className="mt-2 max-w-md text-sm leading-relaxed text-zinc-500">
|
||||||
|
Orchestrate AI agents across your projects. Start a session to begin a conversation
|
||||||
|
with one or more Copilot-backed agents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
{hasProjects ? (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-500"
|
||||||
|
onClick={onNewSession}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Session
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-indigo-500"
|
||||||
|
onClick={onAddProject}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add Your First Project
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="flex items-center gap-2 rounded-lg px-4 py-2 text-sm text-zinc-500 transition hover:bg-zinc-900 hover:text-zinc-300"
|
||||||
|
onClick={onOpenSettings}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Settings className="size-3.5" />
|
||||||
|
Manage patterns
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-surface-0: #09090b;
|
||||||
|
--color-surface-1: #18181b;
|
||||||
|
--color-surface-2: #27272a;
|
||||||
|
--color-surface-3: #3f3f46;
|
||||||
|
--color-border: #27272a;
|
||||||
|
--color-border-subtle: #1e1e22;
|
||||||
|
--color-accent: #6366f1;
|
||||||
|
--color-accent-hover: #818cf8;
|
||||||
|
--color-accent-muted: rgba(99, 102, 241, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
font-family:
|
font-family:
|
||||||
@@ -10,11 +22,14 @@
|
|||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
"Segoe UI",
|
"Segoe UI",
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
|
background: var(--color-surface-0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@@ -27,3 +42,28 @@ select,
|
|||||||
textarea {
|
textarea {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scrollbar styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auto-resize textarea helper */
|
||||||
|
.auto-resize-textarea {
|
||||||
|
field-sizing: content;
|
||||||
|
min-height: 44px;
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user