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:
Copilot CLI
2026-03-21 11:22:51 +01:00
co-authored by Copilot
parent 88ca754e7b
commit 32f8de6485
11 changed files with 911 additions and 598 deletions
+8 -4
View File
@@ -3,13 +3,17 @@ import type { ReactNode } from 'react';
interface AppShellProps {
sidebar: ReactNode;
content: ReactNode;
overlay?: ReactNode;
}
export function AppShell({ sidebar, content }: AppShellProps) {
export function AppShell({ sidebar, content, overlay }: 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 className="relative flex h-screen bg-[var(--color-surface-0)] text-zinc-100">
<aside className="flex w-72 shrink-0 flex-col border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
{sidebar}
</aside>
<main className="relative min-w-0 flex-1">{content}</main>
{overlay}
</div>
);
}
+120 -91
View File
@@ -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 { ProjectRecord } from '@shared/domain/project';
@@ -14,6 +15,7 @@ interface ChatPaneProps {
export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
const [input, setInput] = useState('');
const transcriptRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
transcriptRef.current?.scrollTo({
@@ -23,117 +25,144 @@ export function ChatPane({ project, pattern, session, onSend }: ChatPaneProps) {
}, [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],
);
async function handleSubmit() {
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 (
<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 className="flex h-full flex-col">
{/* Header */}
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-6 py-3">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-zinc-100">{session.title}</h2>
<p className="mt-0.5 truncate text-[12px] text-zinc-500">
{project.name} · {pattern.name} · {pattern.mode}
</p>
</div>
<div className="flex items-center gap-2">
{session.status === 'running' && (
<div className="flex items-center gap-1.5 text-[12px] text-blue-400">
<Loader2 className="size-3.5 animate-spin" />
Running
</div>
)}
{session.status === 'error' && (
<div className="flex items-center gap-1.5 text-[12px] text-red-400">
<AlertCircle className="size-3.5" />
Error
</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>
</header>
<div
className="flex-1 overflow-y-auto px-8 py-6"
ref={transcriptRef}
>
{/* Messages */}
<div className="flex-1 overflow-y-auto" 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 className="flex h-full flex-col items-center justify-center gap-2 px-6 text-center">
<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 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 className="mx-auto max-w-3xl px-6 py-4">
<div className="space-y-1">
{session.messages.map((message) => {
const isUser = message.role === 'user';
return (
<div className="group py-3" key={message.id}>
<div className="flex gap-3">
<div
className={`mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full ${
isUser
? 'bg-indigo-600 text-white'
: 'bg-zinc-800 text-zinc-400'
}`}
>
{isUser ? <User className="size-3.5" /> : <Bot className="size-3.5" />}
</div>
<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 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 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}
{/* Input area */}
<div className="border-t border-[var(--color-border)] px-6 py-4">
{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>
) : 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>
<div className="mx-auto max-w-3xl">
<div className="relative rounded-xl border border-zinc-700 bg-zinc-900 transition-colors focus-within:border-indigo-500/50">
<textarea
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"
disabled={isBusy}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={isBusy ? 'Waiting for response...' : 'Message...'}
ref={textareaRef}
rows={1}
value={input}
/>
<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()}
type="submit"
onClick={() => void handleSubmit()}
type="button"
>
{isBusy ? 'Running…' : 'Send'}
{isBusy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ArrowUp className="size-4" />
)}
</button>
</div>
</form>
</div>
</div>
</div>
);
+102
View File
@@ -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>
);
}
+218 -206
View File
@@ -1,3 +1,5 @@
import { AlertCircle, CheckCircle, ChevronLeft, Plus, Trash2 } from 'lucide-react';
import { validatePatternDefinition, type OrchestrationMode, type PatternDefinition } from '@shared/domain/pattern';
interface PatternEditorProps {
@@ -6,244 +8,254 @@ interface PatternEditorProps {
onChange: (pattern: PatternDefinition) => void;
onDelete?: () => void;
onSave: () => void;
onBack: () => void;
}
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);
function updateAgent(agentId: string, patch: Record<string, string>) {
onChange({
...pattern,
agents: pattern.agents.map((a) => (a.id === agentId ? { ...a, ...patch } : a)),
});
}
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">
{/* Header */}
<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 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.
<h3 className="text-sm font-semibold text-zinc-100">
{pattern.name || 'Untitled pattern'}
</h3>
<p className="text-[12px] text-zinc-500">
{isBuiltin ? 'Built-in pattern' : 'Custom pattern'}
</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}
</div>
<div className="flex items-center gap-2">
{!isBuiltin && onDelete && (
<button
className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-medium text-slate-950 hover:bg-sky-400"
onClick={onSave}
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={onDelete}
type="button"
>
Save Pattern
<Trash2 className="size-3.5" />
Delete
</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 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>
{/* Body */}
<div className="flex-1 overflow-y-auto px-6 py-5">
<div className="mx-auto max-w-2xl space-y-6">
{/* Validation banner */}
{issues.length > 0 ? (
<div className="space-y-2">
{issues.map((issue, i) => (
<div
className={`flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] ${
issue.level === 'error'
? 'bg-red-500/10 text-red-300'
: 'bg-amber-500/10 text-amber-300'
}`}
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
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,
})
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={(e) =>
onChange({ ...pattern, mode: e.target.value as OrchestrationMode })
}
value={pattern.mode}
>
{modes.map((mode) => (
<option
key={mode}
value={mode}
>
{mode}
{modes.map((m) => (
<option key={m} value={m}>
{m}
</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>
</div>
<InputField
label="Description"
multiline
onChange={(v) => onChange({ ...pattern, description: v })}
placeholder="What this pattern does..."
value={pattern.description}
/>
</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"
{/* Agents */}
<section className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-[12px] font-semibold uppercase tracking-wider text-zinc-500">
Agents ({pattern.agents.length})
</h4>
<button
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"
onClick={() =>
onChange({
...pattern,
agents: [
...pattern.agents,
{
id: `agent-${crypto.randomUUID()}`,
name: `Agent ${pattern.agents.length + 1}`,
description: '',
instructions: '',
model: 'gpt-5.4',
},
],
})
}
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
</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 className="mb-3 flex items-center justify-between">
<span className="text-[12px] font-medium text-zinc-500">
Agent {index + 1}
</span>
{pattern.agents.length > 1 && (
<button
className="text-[12px] text-zinc-600 transition hover:text-red-400"
onClick={() =>
onChange({
...pattern,
agents: pattern.agents.filter((a) => a.id !== agent.id),
})
}
type="button"
>
Remove
</button>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<InputField
label="Name"
onChange={(v) => updateAgent(agent.id, { name: v })}
value={agent.name}
/>
<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 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 className="sm:col-span-2">
<InputField
label="Instructions"
multiline
onChange={(v) => updateAgent(agent.id, { instructions: v })}
placeholder="System prompt for this agent..."
value={agent.instructions}
/>
</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>
</section>
</div>
</div>
</div>
);
+119
View File
@@ -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
View File
@@ -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 { SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
@@ -6,183 +15,164 @@ import type { WorkspaceState } from '@shared/domain/workspace';
interface SidebarProps {
workspace: WorkspaceState;
onAddProject: () => void;
onCreateSession: () => void;
onNewPattern: () => void;
onNewSession: () => void;
onProjectSelect: (projectId?: string) => void;
onPatternSelect: (patternId?: string) => void;
onSessionSelect: (sessionId?: string) => void;
onSessionSelect: (sessionId: string) => void;
onOpenSettings: () => 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 statusDot(status: SessionRecord['status']) {
if (status === 'running') return 'bg-blue-400';
if (status === 'error') return 'bg-red-400';
return 'bg-zinc-600';
}
function modeBadge(pattern: PatternDefinition) {
if (pattern.availability === 'unavailable') {
return 'bg-amber-500/15 text-amber-200';
}
function ProjectGroup({
project,
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[]) {
const count = sessions.filter((session) => session.projectId === project.id).length;
return `${count} session${count === 1 ? '' : 's'}`;
{expanded && (
<div className="ml-3 mt-0.5 space-y-0.5 border-l border-zinc-800 pl-3">
{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({
workspace,
onAddProject,
onCreateSession,
onNewPattern,
onNewSession,
onProjectSelect,
onPatternSelect,
onSessionSelect,
onOpenSettings,
}: 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 className="flex h-full flex-col">
{/* Header */}
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex size-7 items-center justify-center rounded-lg bg-indigo-600 text-[11px] font-bold text-white">
K
</div>
<span className="text-sm font-semibold text-zinc-100">kopaya</span>
</div>
<div className="flex items-center gap-1">
<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}
className="flex size-8 items-center justify-center rounded-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-200"
onClick={onOpenSettings}
title="Settings"
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>
</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">
{/* Project + Session Tree */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{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>
<h2 className="text-sm font-semibold text-slate-200">Patterns</h2>
<p className="text-xs text-slate-500">Global orchestration library</p>
<p className="text-sm text-zinc-400">No projects yet</p>
<p className="mt-1 text-[12px] text-zinc-600">
Add a project folder to start orchestrating
</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}
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={onAddProject}
type="button"
>
New Pattern
Add Project
</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 className="space-y-1">
{workspace.projects.map((project) => (
<ProjectGroup
key={project.id}
onSessionSelect={onSessionSelect}
project={project}
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((s) => s.projectId === project.id)}
/>
))}
</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>
{/* 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>
);
}
+63
View File
@@ -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>
);
}