feat: settings page

This commit is contained in:
Carlos Florêncio
2026-01-10 22:41:51 +00:00
parent a797335ecd
commit 3d9dfb2171
27 changed files with 3541 additions and 4 deletions
+258
View File
@@ -0,0 +1,258 @@
'use client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IconTrash } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Slider } from '@/components/ui/slider';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import type { AgentProfile, AgentType } from '@/lib/settings/types';
const AGENT_LABELS: Record<AgentType, string> = {
'claude-code': 'Claude Code',
'codex': 'Codex',
'auggie': 'Auggie',
};
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
'claude-code': [
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
],
'codex': [
{ value: 'codex-v2', label: 'Codex v2' },
{ value: 'codex-v1', label: 'Codex v1' },
],
'auggie': [
{ value: 'auggie-v1', label: 'Auggie v1' },
{ value: 'auggie-lite', label: 'Auggie Lite' },
],
};
export default function AgentEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [agent, setAgent] = useState<AgentProfile | undefined>(
SETTINGS_DATA.agents.find((a) => a.id === id)
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
if (!agent) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Agent not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/agents')}>
Go to Agents
</Button>
</CardContent>
</Card>
</div>
);
}
const handleDeleteAgent = () => {
if (deleteConfirmText === 'delete') {
router.push('/settings/agents');
setDeleteDialogOpen(false);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{agent.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure agent behavior and capabilities
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle>Agent Name</CardTitle>
</CardHeader>
<CardContent>
<Input
value={agent.name}
onChange={(e) => setAgent({ ...agent, name: e.target.value })}
placeholder="Code Assistant"
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Agent Configuration</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Agent Type</Label>
<Select
value={agent.agent}
onValueChange={(value) => {
const agentType = value as AgentType;
setAgent({
...agent,
agent: agentType,
model: MODEL_OPTIONS[agentType][0].value
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude-code">Claude Code</SelectItem>
<SelectItem value="codex">Codex</SelectItem>
<SelectItem value="auggie">Auggie</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Model</Label>
<Select
value={agent.model}
onValueChange={(value) => setAgent({ ...agent, model: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_OPTIONS[agent.agent].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Advanced Settings</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Temperature</Label>
<p className="text-sm text-muted-foreground">
Controls randomness in responses (0 = deterministic, 1 = creative)
</p>
</div>
<span className="text-sm font-medium">{agent.temperature.toFixed(1)}</span>
</div>
<Slider
value={[agent.temperature]}
onValueChange={([value]) => setAgent({ ...agent, temperature: value })}
min={0}
max={1}
step={0.1}
className="w-full"
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Auto-approve Actions</Label>
<p className="text-sm text-muted-foreground">
Automatically execute tool calls without confirmation
</p>
</div>
<Switch
checked={agent.autoApprove}
onCheckedChange={(checked) => setAgent({ ...agent, autoApprove: checked })}
/>
</div>
</CardContent>
</Card>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently delete this agent
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this agent</p>
<p className="text-sm text-muted-foreground">
Once deleted, all configuration will be permanently removed
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Agent
</Button>
</div>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Agent</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the agent &quot;
{agent.name}&quot; and all its data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteAgent}
disabled={deleteConfirmText !== 'delete'}
>
Delete Agent
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+238
View File
@@ -0,0 +1,238 @@
'use client';
import { useState } from 'react';
import { IconPlus } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { AgentCard } from '@/components/settings/agent-card';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { AgentProfile, AgentType } from '@/lib/settings/types';
const AGENT_OPTIONS: { value: AgentType; label: string }[] = [
{ value: 'claude-code', label: 'Claude Code' },
{ value: 'codex', label: 'Codex' },
{ value: 'auggie', label: 'Auggie' },
];
const MODEL_OPTIONS: Record<AgentType, { value: string; label: string }[]> = {
'claude-code': [
{ value: 'claude-sonnet-4.5', label: 'Claude Sonnet 4.5' },
{ value: 'claude-opus-4', label: 'Claude Opus 4' },
{ value: 'claude-haiku-4', label: 'Claude Haiku 4' },
],
'codex': [
{ value: 'codex-v2', label: 'Codex v2' },
{ value: 'codex-v1', label: 'Codex v1' },
],
'auggie': [
{ value: 'auggie-v1', label: 'Auggie v1' },
{ value: 'auggie-lite', label: 'Auggie Lite' },
],
};
export default function AgentsSettingsPage() {
const [agents, setAgents] = useState<AgentProfile[]>(SETTINGS_DATA.agents);
const [isAdding, setIsAdding] = useState(false);
const [newAgent, setNewAgent] = useState<{
agent: AgentType;
name: string;
model: string;
autoApprove: boolean;
temperature: number;
}>({
agent: 'claude-code',
name: '',
model: 'claude-sonnet-4.5',
autoApprove: false,
temperature: 0.7,
});
const handleUpdate = (agent: AgentProfile) => {
setAgents(agents.map((a) => (a.id === agent.id ? agent : a)));
};
const handleDelete = (id: string) => {
setAgents(agents.filter((a) => a.id !== id));
};
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const profile: AgentProfile = {
id: crypto.randomUUID(),
...newAgent,
};
setAgents([...agents, profile]);
setIsAdding(false);
setNewAgent({
agent: 'claude-code',
name: '',
model: 'claude-sonnet-4.5',
autoApprove: false,
temperature: 0.7,
});
};
const handleAgentTypeChange = (type: AgentType) => {
const defaultModel = MODEL_OPTIONS[type][0].value;
setNewAgent({ ...newAgent, agent: type, model: defaultModel });
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">Agents</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure agent profiles and their behavior
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Agent Profile
</Button>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-3">
{isAdding && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAdd} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profile-name">Profile Name</Label>
<Input
id="profile-name"
value={newAgent.name}
onChange={(e) => setNewAgent({ ...newAgent, name: e.target.value })}
placeholder="My Profile"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="agent-type">Agent Type</Label>
<Select
value={newAgent.agent}
onValueChange={(value) => handleAgentTypeChange(value as AgentType)}
>
<SelectTrigger id="agent-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
{AGENT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="model">Model</Label>
<Select
value={newAgent.model}
onValueChange={(value) => setNewAgent({ ...newAgent, model: value })}
>
<SelectTrigger id="model">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODEL_OPTIONS[newAgent.agent].map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="temperature">Temperature: {newAgent.temperature}</Label>
<input
id="temperature"
type="range"
min="0"
max="1"
step="0.1"
value={newAgent.temperature}
onChange={(e) =>
setNewAgent({ ...newAgent, temperature: parseFloat(e.target.value) })
}
className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>Precise</span>
<span>Creative</span>
</div>
</div>
<div className="flex items-center justify-between p-4 border rounded-md">
<div>
<Label htmlFor="auto-approve" className="text-base font-medium">
Auto-approve all actions
</Label>
<p className="text-sm text-muted-foreground">
Automatically approve agent actions without prompting
</p>
</div>
<button
id="auto-approve"
type="button"
role="switch"
aria-checked={newAgent.autoApprove}
onClick={() =>
setNewAgent({ ...newAgent, autoApprove: !newAgent.autoApprove })
}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
newAgent.autoApprove ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
newAgent.autoApprove ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
Cancel
</Button>
<Button type="submit">Add Profile</Button>
</div>
</form>
</CardContent>
</Card>
)}
{agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
/>
))}
{agents.length === 0 && !isAdding && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No agent profiles configured. Add your first profile to get started.
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,366 @@
'use client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IconPlus, IconTrash, IconX } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Environment, EnvironmentType, BaseDocker } from '@/lib/settings/types';
export default function EnvironmentEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [environment, setEnvironment] = useState<Environment | undefined>(
SETTINGS_DATA.environments.find((e) => e.id === id)
);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [newEnvKey, setNewEnvKey] = useState('');
const [newEnvValue, setNewEnvValue] = useState('');
const [newSecretKey, setNewSecretKey] = useState('');
const [newSecretValue, setNewSecretValue] = useState('');
if (!environment) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Environment not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/environments')}>
Go to Environments
</Button>
</CardContent>
</Card>
</div>
);
}
const handleAddEnvVar = () => {
if (newEnvKey.trim()) {
setEnvironment({
...environment,
envVariables: [
...environment.envVariables,
{ id: crypto.randomUUID(), key: newEnvKey, value: newEnvValue },
],
});
setNewEnvKey('');
setNewEnvValue('');
}
};
const handleRemoveEnvVar = (id: string) => {
setEnvironment({
...environment,
envVariables: environment.envVariables.filter((v) => v.id !== id),
});
};
const handleAddSecret = () => {
if (newSecretKey.trim()) {
setEnvironment({
...environment,
secrets: [
...environment.secrets,
{ id: crypto.randomUUID(), key: newSecretKey, value: newSecretValue },
],
});
setNewSecretKey('');
setNewSecretValue('');
}
};
const handleRemoveSecret = (id: string) => {
setEnvironment({
...environment,
secrets: environment.secrets.filter((s) => s.id !== id),
});
};
const handleToggleAgent = (agentId: string) => {
const installed = environment.installedAgents || [];
if (installed.includes(agentId)) {
setEnvironment({
...environment,
installedAgents: installed.filter((id) => id !== agentId),
});
} else {
setEnvironment({
...environment,
installedAgents: [...installed, agentId],
});
}
};
const handleDeleteEnvironment = () => {
if (deleteConfirmText === 'delete') {
router.push('/settings/environments');
setDeleteDialogOpen(false);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{environment.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure Docker environment and runtime settings
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle>Environment Name</CardTitle>
</CardHeader>
<CardContent>
<Input
value={environment.name}
onChange={(e) => setEnvironment({ ...environment, name: e.target.value })}
/>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Docker Configuration</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Environment Type</Label>
<Select
value={environment.type}
onValueChange={(value) => setEnvironment({ ...environment, type: value as EnvironmentType })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local-docker">Local Docker</SelectItem>
<SelectItem value="remote-docker">Remote Docker</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Base Docker Image</Label>
<Select
value={environment.baseDocker}
onValueChange={(value) => setEnvironment({ ...environment, baseDocker: value as BaseDocker })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="universal">Universal</SelectItem>
<SelectItem value="golang">Golang</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Setup Script</Label>
<Textarea
value={environment.setupScript}
onChange={(e) => setEnvironment({ ...environment, setupScript: e.target.value })}
placeholder="#!/bin/bash&#10;apt-get update"
rows={5}
className="font-mono text-sm"
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Environment Variables</CardTitle>
<CardDescription>Key-value pairs available in the container</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{environment.envVariables.map((envVar) => (
<div key={envVar.id} className="flex items-center gap-2">
<Input value={envVar.key} disabled className="flex-1" />
<Input value={envVar.value} disabled className="flex-1" />
<Button
variant="ghost"
onClick={() => handleRemoveEnvVar(envVar.id)}
className="shrink-0"
>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="KEY"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value)}
className="flex-1"
/>
<Input
placeholder="value"
value={newEnvValue}
onChange={(e) => setNewEnvValue(e.target.value)}
className="flex-1"
/>
<Button onClick={handleAddEnvVar} className="shrink-0">
<IconPlus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Secrets</CardTitle>
<CardDescription>Sensitive values (not shown in plain text)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{environment.secrets.map((secret) => (
<div key={secret.id} className="flex items-center gap-2">
<Input value={secret.key} disabled className="flex-1" />
<Input value="••••••••" type="password" disabled className="flex-1" />
<Button
variant="ghost"
onClick={() => handleRemoveSecret(secret.id)}
className="shrink-0"
>
<IconTrash className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
))}
</div>
<div className="flex gap-2">
<Input
placeholder="SECRET_KEY"
value={newSecretKey}
onChange={(e) => setNewSecretKey(e.target.value)}
className="flex-1"
/>
<Input
placeholder="secret value"
type="password"
value={newSecretValue}
onChange={(e) => setNewSecretValue(e.target.value)}
className="flex-1"
/>
<Button onClick={handleAddSecret} className="shrink-0">
<IconPlus className="h-4 w-4 mr-2" />
Add
</Button>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Installed Agents</CardTitle>
<CardDescription>Select agents available in this environment</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{SETTINGS_DATA.agents.map((agent) => (
<label
key={agent.id}
className="flex items-center gap-3 p-3 border rounded-lg cursor-pointer hover:bg-accent"
>
<input
type="checkbox"
checked={(environment.installedAgents || []).includes(agent.id)}
onChange={() => handleToggleAgent(agent.id)}
className="h-4 w-4"
/>
<div className="flex-1">
<div className="font-medium">{agent.name}</div>
<div className="text-sm text-muted-foreground">{agent.model}</div>
</div>
</label>
))}
</div>
</CardContent>
</Card>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently delete this environment
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this environment</p>
<p className="text-sm text-muted-foreground">
Once deleted, all configuration will be permanently removed
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Environment
</Button>
</div>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Environment</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the environment &quot;
{environment.name}&quot; and all its data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteEnvironment}
disabled={deleteConfirmText !== 'delete'}
>
Delete Environment
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+205
View File
@@ -0,0 +1,205 @@
'use client';
import { useState } from 'react';
import { IconPlus } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { EnvironmentCard } from '@/components/settings/environment-card';
import { KeyValueInput } from '@/components/settings/key-value-input';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Environment, EnvironmentType, BaseDocker, KeyValue, AgentType } from '@/lib/settings/types';
export default function EnvironmentsSettingsPage() {
const [environments, setEnvironments] = useState<Environment[]>(SETTINGS_DATA.environments);
const [isAdding, setIsAdding] = useState(false);
const [newEnv, setNewEnv] = useState<{
name: string;
type: EnvironmentType;
baseDocker: BaseDocker;
envVariables: KeyValue[];
secrets: KeyValue[];
setupScript: string;
installedAgents: AgentType[];
}>({
name: '',
type: 'local-docker',
baseDocker: 'universal',
envVariables: [],
secrets: [],
setupScript: '',
installedAgents: [],
});
const handleDelete = (id: string) => {
setEnvironments(environments.filter((e) => e.id !== id));
};
const handleAdd = (e: React.FormEvent) => {
e.preventDefault();
const environment: Environment = {
id: crypto.randomUUID(),
...newEnv,
};
setEnvironments([...environments, environment]);
setIsAdding(false);
setNewEnv({
name: '',
type: 'local-docker',
baseDocker: 'universal',
envVariables: [],
secrets: [],
setupScript: '',
installedAgents: [],
});
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">Environments</h2>
<p className="text-sm text-muted-foreground mt-1">
Configure execution environments for your agents
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Environment
</Button>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-3">
{isAdding && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAdd} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="env-name">Environment Name</Label>
<Input
id="env-name"
value={newEnv.name}
onChange={(e) => setNewEnv({ ...newEnv, name: e.target.value })}
placeholder="Development"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="env-type">Environment Type</Label>
<Select
value={newEnv.type}
onValueChange={(value) =>
setNewEnv({ ...newEnv, type: value as EnvironmentType })
}
>
<SelectTrigger id="env-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local-docker">Local Docker</SelectItem>
<SelectItem value="remote-docker">Remote Docker</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="base-docker">Base Docker Image</Label>
<Select
value={newEnv.baseDocker}
onValueChange={(value) =>
setNewEnv({ ...newEnv, baseDocker: value as BaseDocker })
}
>
<SelectTrigger id="base-docker">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="universal">Universal</SelectItem>
<SelectItem value="golang">Golang</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>Environment Variables</Label>
<KeyValueInput
items={newEnv.envVariables}
onChange={(items) => setNewEnv({ ...newEnv, envVariables: items })}
keyPlaceholder="Variable name"
valuePlaceholder="Value"
addButtonLabel="Add Variable"
/>
</div>
<div className="space-y-2">
<Label>Secrets</Label>
<KeyValueInput
items={newEnv.secrets}
onChange={(items) => setNewEnv({ ...newEnv, secrets: items })}
keyPlaceholder="Secret name"
valuePlaceholder="Secret value"
addButtonLabel="Add Secret"
masked
/>
</div>
<div className="space-y-2">
<Label htmlFor="setup-script">Setup Script</Label>
<Textarea
id="setup-script"
value={newEnv.setupScript}
onChange={(e) =>
setNewEnv({ ...newEnv, setupScript: e.target.value })
}
placeholder="#!/bin/bash&#10;apt-get update&#10;apt-get install -y git"
rows={5}
className="font-mono text-sm"
/>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => setIsAdding(false)}
>
Cancel
</Button>
<Button type="submit">Add Environment</Button>
</div>
</form>
</CardContent>
</Card>
)}
{environments.map((env) => (
<EnvironmentCard
key={env.id}
environment={env}
/>
))}
{environments.length === 0 && !isAdding && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No environments configured. Add your first environment to get started.
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
'use client';
import { useState, useEffect } from 'react';
import { useTheme } from 'next-themes';
import { IconPalette, IconCode, IconBell } from '@tabler/icons-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { SettingsSection } from '@/components/settings/settings-section';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Theme, Editor, Notifications } from '@/lib/settings/types';
export default function GeneralSettingsPage() {
const { theme: currentTheme, setTheme } = useTheme();
const [editor, setEditor] = useState<Editor>(SETTINGS_DATA.general.editor);
const [customEditorCommand, setCustomEditorCommand] = useState<string>(
SETTINGS_DATA.general.customEditorCommand || ''
);
const [notifications, setNotifications] = useState<Notifications>(
SETTINGS_DATA.general.notifications
);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleNotificationToggle = (key: keyof Notifications) => {
setNotifications((prev) => ({ ...prev, [key]: !prev[key] }));
};
if (!mounted) {
return null;
}
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">General Settings</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage your application preferences and notifications
</p>
</div>
<Separator />
<SettingsSection
icon={<IconPalette className="h-5 w-5" />}
title="Appearance"
description="Customize how the application looks"
>
<Card>
<CardHeader>
<CardTitle className="text-base">Theme</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="theme">Color Theme</Label>
<Select
value={currentTheme}
onValueChange={(value) => setTheme(value as Theme)}
>
<SelectTrigger id="theme">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">System</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconCode className="h-5 w-5" />}
title="Editor"
description="Choose your preferred code editor"
>
<Card>
<CardHeader>
<CardTitle className="text-base">Editor Preference</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="editor">Default Editor</Label>
<Select value={editor} onValueChange={(value) => setEditor(value as Editor)}>
<SelectTrigger id="editor">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="vscode">VS Code</SelectItem>
<SelectItem value="cursor">Cursor</SelectItem>
<SelectItem value="zed">Zed</SelectItem>
<SelectItem value="vim">Vim</SelectItem>
<SelectItem value="custom">Custom</SelectItem>
</SelectContent>
</Select>
</div>
{editor === 'custom' && (
<div className="space-y-2">
<Label htmlFor="custom-command">Custom Editor Command</Label>
<Input
id="custom-command"
value={customEditorCommand}
onChange={(e) => setCustomEditorCommand(e.target.value)}
placeholder="code --goto {file}:{line}"
/>
<p className="text-xs text-muted-foreground">
Use {'{file}'} and {'{line}'} as placeholders
</p>
</div>
)}
</CardContent>
</Card>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconBell className="h-5 w-5" />}
title="Notifications"
description="Control what notifications you receive"
>
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="task-updates" className="text-base font-medium">
Task Updates
</Label>
<p className="text-sm text-muted-foreground">
Get notified when tasks change status
</p>
</div>
<button
id="task-updates"
role="switch"
aria-checked={notifications.taskUpdates}
onClick={() => handleNotificationToggle('taskUpdates')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
notifications.taskUpdates ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
notifications.taskUpdates ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label htmlFor="agent-completion" className="text-base font-medium">
Agent Completion
</Label>
<p className="text-sm text-muted-foreground">
Get notified when agents complete tasks
</p>
</div>
<button
id="agent-completion"
role="switch"
aria-checked={notifications.agentCompletion}
onClick={() => handleNotificationToggle('agentCompletion')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
notifications.agentCompletion ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
notifications.agentCompletion ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label htmlFor="errors" className="text-base font-medium">
Errors
</Label>
<p className="text-sm text-muted-foreground">
Get notified about errors and failures
</p>
</div>
<button
id="errors"
role="switch"
aria-checked={notifications.errors}
onClick={() => handleNotificationToggle('errors')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
notifications.errors ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
notifications.errors ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</CardContent>
</Card>
</SettingsSection>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
'use client';
import React, { useState } from 'react';
import {
IconBrandGithub,
IconBrandOpenai,
IconBrandGoogle,
IconKey,
IconBox,
IconCircleDot,
} from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
type Integration = {
id: string;
name: string;
description: string;
icon: React.ReactNode;
connected: boolean;
category: 'ai' | 'project-management';
};
export default function IntegrationsPage() {
const [integrations, setIntegrations] = useState<Integration[]>([
{
id: 'github',
name: 'GitHub',
description: 'Connect your GitHub repositories for code management and CI/CD',
icon: <IconBrandGithub className="h-5 w-5" />,
connected: false,
category: 'project-management',
},
{
id: 'anthropic',
name: 'Anthropic',
description: 'Connect to Anthropic API for Claude models',
icon: <IconBox className="h-5 w-5" />,
connected: false,
category: 'ai',
},
{
id: 'openai',
name: 'OpenAI',
description: 'Integrate OpenAI models and services',
icon: <IconBrandOpenai className="h-5 w-5" />,
connected: false,
category: 'ai',
},
{
id: 'augment',
name: 'Augment',
description: 'Connect Augment AI coding assistant',
icon: <IconBox className="h-5 w-5" />,
connected: false,
category: 'ai',
},
{
id: 'gemini',
name: 'Gemini',
description: 'Google Gemini AI models integration',
icon: <IconBrandGoogle className="h-5 w-5" />,
connected: false,
category: 'ai',
},
{
id: 'jira',
name: 'Jira',
description: 'Sync tasks with Atlassian Jira',
icon: <IconBox className="h-5 w-5" />,
connected: false,
category: 'project-management',
},
{
id: 'linear',
name: 'Linear',
description: 'Integrate with Linear for issue tracking',
icon: <IconCircleDot className="h-5 w-5" />,
connected: false,
category: 'project-management',
},
]);
const handleConnect = (id: string) => {
setIntegrations(integrations.map(int =>
int.id === id ? { ...int, connected: !int.connected } : int
));
};
const groupedIntegrations = {
'AI Models': integrations.filter(i => i.category === 'ai'),
'Project Management': integrations.filter(i => i.category === 'project-management'),
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">Integrations</h2>
<p className="text-sm text-muted-foreground mt-1">
Connect external services and tools to enhance your workflow
</p>
</div>
<Separator />
<div className="space-y-8">
{Object.entries(groupedIntegrations).map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-lg font-semibold">{category}</h3>
<div className="space-y-2">
{items.map((integration) => (
<Card key={integration.id}>
<CardContent className="p-3">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 flex-1">
<div className="p-2 bg-muted rounded-lg">
{React.cloneElement(integration.icon as React.ReactElement, {
className: 'h-5 w-5',
})}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h4 className="font-medium">{integration.name}</h4>
{integration.connected && (
<span className="text-sm text-primary">Connected</span>
)}
</div>
{!integration.connected && (
<p className="text-sm text-muted-foreground">Disconnected</p>
)}
</div>
</div>
<Button
variant={integration.connected ? 'outline' : 'default'}
onClick={() => handleConnect(integration.id)}
>
{integration.connected ? 'Disconnect' : 'Connect'}
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
'use client';
import { IconArrowLeft } from '@tabler/icons-react';
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
} from '@/components/ui/breadcrumb';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
import { SettingsAppSidebar } from '@/components/settings/settings-app-sidebar';
export default function SettingsLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<SidebarProvider>
<SettingsAppSidebar />
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="/" className="flex items-center gap-2">
<IconArrowLeft className="h-4 w-4" />
Home
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
{children}
</div>
</SidebarInset>
</SidebarProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function SettingsPage() {
redirect('/settings/general');
}
+144
View File
@@ -0,0 +1,144 @@
'use client';
import { useState } from 'react';
import {
IconBrandAws,
IconBrandDocker,
IconCloud,
IconServer,
IconKey,
IconShield,
} from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Badge } from '@/components/ui/badge';
type Plugin = {
id: string;
name: string;
description: string;
icon: React.ReactNode;
installed: boolean;
category: 'infrastructure' | 'security';
};
export default function PluginsPage() {
const [plugins, setPlugins] = useState<Plugin[]>([
{
id: 'aws',
name: 'AWS Cloud',
description: 'Deploy and manage resources on Amazon Web Services',
icon: <IconBrandAws className="h-5 w-5" />,
installed: false,
category: 'infrastructure',
},
{
id: 'hetzner',
name: 'Hetzner',
description: 'Deploy applications to Hetzner Cloud infrastructure',
icon: <IconServer className="h-5 w-5" />,
installed: false,
category: 'infrastructure',
},
{
id: 'flyio',
name: 'Fly.io',
description: 'Deploy apps globally with Fly.io edge infrastructure',
icon: <IconCloud className="h-5 w-5" />,
installed: false,
category: 'infrastructure',
},
{
id: 'cloudflare',
name: 'Cloudflare',
description: 'Use Cloudflare Workers and edge compute platform',
icon: <IconShield className="h-5 w-5" />,
installed: false,
category: 'infrastructure',
},
{
id: 'docker',
name: 'Docker',
description: 'Container runtime for local and remote environments',
icon: <IconBrandDocker className="h-5 w-5" />,
installed: true,
category: 'infrastructure',
},
{
id: 'sso',
name: 'Single Sign-On',
description: 'Enterprise SSO authentication provider',
icon: <IconKey className="h-5 w-5" />,
installed: false,
category: 'security',
},
]);
const handleToggleInstall = (id: string) => {
setPlugins(plugins.map(plugin =>
plugin.id === id ? { ...plugin, installed: !plugin.installed } : plugin
));
};
const groupedPlugins = {
'Infrastructure': plugins.filter(p => p.category === 'infrastructure'),
'Security': plugins.filter(p => p.category === 'security'),
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">Plugins</h2>
<p className="text-sm text-muted-foreground mt-1">
Extend functionality with infrastructure and security plugins
</p>
</div>
<Separator />
<div className="space-y-8">
{Object.entries(groupedPlugins).map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-lg font-semibold">{category}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{items.map((plugin) => (
<Card key={plugin.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-muted rounded-md">
{plugin.icon}
</div>
<div>
<CardTitle className="text-base">{plugin.name}</CardTitle>
{plugin.installed && (
<Badge variant="secondary" className="mt-1">
Installed
</Badge>
)}
</div>
</div>
</div>
<CardDescription className="mt-2">
{plugin.description}
</CardDescription>
</CardHeader>
<CardContent>
<Button
variant={plugin.installed ? 'outline' : 'default'}
className="w-full"
onClick={() => handleToggleInstall(plugin.id)}
>
{plugin.installed ? 'Uninstall' : 'Install'}
</Button>
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,378 @@
'use client';
import { use, useState } from 'react';
import { useRouter } from 'next/navigation';
import { IconGitBranch, IconFolder, IconPlus, IconTrash } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { RepositoryCard } from '@/components/settings/repository-card';
import { ContextCard } from '@/components/settings/context-card';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Workspace, Repository, Context } from '@/lib/settings/types';
export default function WorkspaceEditPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const router = useRouter();
const [workspace, setWorkspace] = useState<Workspace | undefined>(
SETTINGS_DATA.workspaces.find((w) => w.id === id)
);
const [isAddingRepo, setIsAddingRepo] = useState(false);
const [isAddingContext, setIsAddingContext] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
if (!workspace) {
return (
<div>
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Workspace not found</p>
<Button className="mt-4" onClick={() => router.push('/settings/workspace/new')}>
Create New Workspace
</Button>
</CardContent>
</Card>
</div>
);
}
const handleUpdateWorkspaceName = (name: string) => {
setWorkspace({ ...workspace, name });
};
const handleAddRepository = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newRepo: Repository = {
id: crypto.randomUUID(),
name: formData.get('name') as string,
path: formData.get('path') as string,
setupScript: formData.get('setupScript') as string,
cleanupScript: formData.get('cleanupScript') as string,
customScripts: [],
};
setWorkspace({
...workspace,
repositories: [...workspace.repositories, newRepo],
});
setIsAddingRepo(false);
};
const handleUpdateRepository = (repo: Repository) => {
setWorkspace({
...workspace,
repositories: workspace.repositories.map((r) => (r.id === repo.id ? repo : r)),
});
};
const handleDeleteRepository = (id: string) => {
setWorkspace({
...workspace,
repositories: workspace.repositories.filter((r) => r.id !== id),
});
};
const handleAddContext = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const newContext: Context = {
id: crypto.randomUUID(),
name: formData.get('name') as string,
columns: [
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
};
setWorkspace({
...workspace,
contexts: [...workspace.contexts, newContext],
});
setIsAddingContext(false);
};
const handleUpdateContext = (context: Context) => {
setWorkspace({
...workspace,
contexts: workspace.contexts.map((c) => (c.id === context.id ? context : c)),
});
};
const handleDeleteContext = (id: string) => {
setWorkspace({
...workspace,
contexts: workspace.contexts.filter((c) => c.id !== id),
});
};
const handleFolderPick = (callback: (path: string) => void) => {
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.onchange = (e: any) => {
const files = e.target.files;
if (files && files[0]) {
const fullPath = files[0].webkitRelativePath;
const folderName = fullPath.split('/')[0];
callback(`/${folderName}`);
}
};
input.click();
};
const handleDeleteWorkspace = () => {
if (deleteConfirmText === 'delete') {
// In real app, this would delete from database
router.push('/settings/general');
setDeleteDialogOpen(false);
}
};
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">{workspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage repositories and contexts for this workspace
</p>
</div>
<Separator />
<Card>
<CardHeader>
<CardTitle>Workspace Name</CardTitle>
</CardHeader>
<CardContent>
<Input
value={workspace.name}
onChange={(e) => handleUpdateWorkspaceName(e.target.value)}
/>
</CardContent>
</Card>
<Separator />
<SettingsSection
icon={<IconGitBranch className="h-5 w-5" />}
title="Repositories"
description="Repositories in this workspace"
action={
<Button size="sm" onClick={() => setIsAddingRepo(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Repository
</Button>
}
>
<div className="grid gap-3">
{isAddingRepo && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddRepository} className="space-y-4">
<div className="space-y-2">
<Label>Repository Name</Label>
<Input name="name" placeholder="my-project" required />
</div>
<div className="space-y-2">
<Label>Directory Path</Label>
<div className="flex gap-2">
<Input
id="repo-path"
name="path"
placeholder="/path/to/repository"
required
/>
<Button
type="button"
variant="outline"
onClick={() =>
handleFolderPick((path) => {
const input = document.getElementById('repo-path') as HTMLInputElement;
if (input) input.value = path;
})
}
>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Setup Script</Label>
<Textarea
name="setupScript"
placeholder="#!/bin/bash&#10;npm install"
rows={3}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>Cleanup Script</Label>
<Textarea
name="cleanupScript"
placeholder="#!/bin/bash&#10;rm -rf node_modules"
rows={3}
className="font-mono text-sm"
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setIsAddingRepo(false)}>
Cancel
</Button>
<Button type="submit">Add</Button>
</div>
</form>
</CardContent>
</Card>
)}
{workspace.repositories.map((repo) => (
<RepositoryCard
key={repo.id}
repository={repo}
onUpdate={handleUpdateRepository}
onDelete={() => handleDeleteRepository(repo.id)}
/>
))}
{workspace.repositories.length === 0 && !isAddingRepo && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">No repositories in this workspace</p>
</CardContent>
</Card>
)}
</div>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconFolder className="h-5 w-5" />}
title="Contexts"
description="Kanban view contexts for this workspace"
action={
<Button size="sm" onClick={() => setIsAddingContext(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Context
</Button>
}
>
<div className="grid gap-3">
{isAddingContext && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddContext} className="space-y-4">
<div className="space-y-2">
<Label>Context Name</Label>
<Input name="name" placeholder="Dev" required />
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={() => setIsAddingContext(false)}
>
Cancel
</Button>
<Button type="submit">Add</Button>
</div>
</form>
</CardContent>
</Card>
)}
{workspace.contexts.map((context) => (
<ContextCard
key={context.id}
context={context}
onUpdate={handleUpdateContext}
onDelete={() => handleDeleteContext(context.id)}
/>
))}
{workspace.contexts.length === 0 && !isAddingContext && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">No contexts in this workspace</p>
</CardContent>
</Card>
)}
</div>
</SettingsSection>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Irreversible actions that will permanently delete this workspace
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">Delete this workspace</p>
<p className="text-sm text-muted-foreground">
Once deleted, all repositories and contexts will be permanently removed
</p>
</div>
<Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
<IconTrash className="h-4 w-4 mr-2" />
Delete Workspace
</Button>
</div>
</CardContent>
</Card>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Workspace</DialogTitle>
<DialogDescription>
This action cannot be undone. This will permanently delete the workspace &quot;
{workspace.name}&quot; and all its data.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm">
Please type <span className="font-mono font-bold">delete</span> to confirm:
</p>
<Input
value={deleteConfirmText}
onChange={(e) => setDeleteConfirmText(e.target.value)}
placeholder="delete"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteWorkspace}
disabled={deleteConfirmText !== 'delete'}
>
Delete Workspace
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import { IconFolder, IconPlus, IconChevronRight } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import type { Workspace } from '@/lib/settings/types';
export default function WorkspacesPage() {
const [workspaces, setWorkspaces] = useState<Workspace[]>(SETTINGS_DATA.workspaces);
const [isAdding, setIsAdding] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState('');
const handleAddWorkspace = (e: React.FormEvent) => {
e.preventDefault();
if (newWorkspaceName.trim()) {
const newWorkspace: Workspace = {
id: crypto.randomUUID(),
name: newWorkspaceName,
repositories: [],
contexts: [],
};
setWorkspaces([...workspaces, newWorkspace]);
setNewWorkspaceName('');
setIsAdding(false);
}
};
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">Workspaces</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage your workspaces with repositories and contexts
</p>
</div>
<Button size="sm" onClick={() => setIsAdding(true)}>
<IconPlus className="h-4 w-4 mr-2" />
Add Workspace
</Button>
</div>
<Separator />
<div className="space-y-4">
<div className="grid gap-3">
{isAdding && (
<Card>
<CardContent className="pt-6">
<form onSubmit={handleAddWorkspace} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="workspace-name">Workspace Name</Label>
<Input
id="workspace-name"
value={newWorkspaceName}
onChange={(e) => setNewWorkspaceName(e.target.value)}
placeholder="My Workspace"
required
autoFocus
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setIsAdding(false)}>
Cancel
</Button>
<Button type="submit">Add Workspace</Button>
</div>
</form>
</CardContent>
</Card>
)}
{workspaces.map((workspace) => (
<Link key={workspace.id} href={`/settings/workspace/${workspace.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconFolder className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium">{workspace.name}</h4>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{workspace.repositories.length} repositories</span>
<span>{workspace.contexts.length} contexts</span>
</div>
</div>
</div>
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
))}
{workspaces.length === 0 && (
<Card>
<CardContent className="py-8 text-center">
<p className="text-sm text-muted-foreground">
No workspaces configured. Add your first workspace to get started.
</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}
+8 -2
View File
@@ -14,8 +14,9 @@ import { KanbanColumn, Column } from './kanban-column';
import { KanbanCardPreview, Task } from './kanban-card';
import { ThemeToggle } from './theme-toggle';
import { Button } from '@/components/ui/button';
import { IconPlus } from '@tabler/icons-react';
import { IconPlus, IconSettings } from '@tabler/icons-react';
import { TaskCreateDialog } from './task-create-dialog';
import Link from 'next/link';
import {
Select,
SelectContent,
@@ -166,7 +167,12 @@ export function KanbanBoard() {
<IconPlus className="h-4 w-4" />
Add task
</Button>
<ThemeToggle />
<Link href="/settings" className="cursor-pointer">
<Button variant="outline">
<IconSettings className="h-4 w-4 mr-2" />
Settings
</Button>
</Link>
</div>
</header>
<TaskCreateDialog
@@ -0,0 +1,55 @@
'use client';
import Link from 'next/link';
import { IconRobot, IconChevronRight } from '@tabler/icons-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { AgentProfile, AgentType } from '@/lib/settings/types';
type AgentCardProps = {
agent: AgentProfile;
};
const AGENT_LABELS: Record<AgentType, string> = {
'claude-code': 'Claude Code',
'codex': 'Codex',
'auggie': 'Auggie',
};
export function AgentCard({ agent }: AgentCardProps) {
const agentLabel = AGENT_LABELS[agent.agent] || agent.agent;
return (
<Link href={`/settings/agent/${agent.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconRobot className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium">{agent.name}</h4>
<Badge variant="secondary" className="text-xs">
{agentLabel}
</Badge>
{agent.autoApprove && (
<Badge variant="outline" className="text-xs text-green-600">
Auto-approve
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
<p>Model: {agent.model}</p>
<p>Temperature: {agent.temperature}</p>
</div>
</div>
</div>
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
);
}
@@ -0,0 +1,214 @@
'use client';
import { useState } from 'react';
import { IconFolder, IconTrash, IconEdit, IconCheck, IconX, IconPlus } from '@tabler/icons-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import type { Context, Column } from '@/lib/settings/types';
type ContextCardProps = {
context: Context;
onUpdate: (context: Context) => void;
onDelete: () => void;
};
const COLOR_OPTIONS = [
{ value: 'bg-neutral-400', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-violet-500', label: 'Violet' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-sky-500', label: 'Sky' },
];
export function ContextCard({ context, onUpdate, onDelete }: ContextCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editedContext, setEditedContext] = useState<Context>(context);
const handleSave = () => {
onUpdate(editedContext);
setIsEditing(false);
};
const handleCancel = () => {
setEditedContext(context);
setIsEditing(false);
};
const handleAddColumn = () => {
setEditedContext({
...editedContext,
columns: [
...editedContext.columns,
{ id: crypto.randomUUID(), title: '', color: 'bg-neutral-400' },
],
});
};
const handleRemoveColumn = (id: string) => {
setEditedContext({
...editedContext,
columns: editedContext.columns.filter((c) => c.id !== id),
});
};
const handleUpdateColumn = (id: string, field: 'title' | 'color' | 'id', value: string) => {
setEditedContext({
...editedContext,
columns: editedContext.columns.map((c) =>
c.id === id ? { ...c, [field]: value } : c
),
});
};
if (isEditing) {
return (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>Context Name</Label>
<Input
value={editedContext.name}
onChange={(e) => setEditedContext({ ...editedContext, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Kanban Columns</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddColumn}>
<IconPlus className="h-4 w-4 mr-1" />
Add Column
</Button>
</div>
<div className="space-y-2">
{editedContext.columns.map((column, index) => (
<div key={column.id} className="flex gap-2 items-start">
<div className="flex-1 grid grid-cols-2 gap-2">
<div className="space-y-1">
<Input
placeholder="Column title"
value={column.title}
onChange={(e) =>
handleUpdateColumn(column.id, 'title', e.target.value)
}
/>
</div>
<div className="space-y-1">
<select
value={column.color}
onChange={(e) =>
handleUpdateColumn(column.id, 'color', e.target.value)
}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{COLOR_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="col-span-2 space-y-1">
<Input
placeholder="Column ID (e.g., todo, in-progress)"
value={column.id}
onChange={(e) =>
handleUpdateColumn(column.id, 'id', e.target.value)
}
/>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveColumn(column.id)}
>
<IconX className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}>
<IconX className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={handleSave}>
<IconCheck className="h-4 w-4 mr-2" />
Save
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="hover:bg-accent transition-colors cursor-pointer" onClick={() => setIsEditing(true)}>
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconFolder className="h-4 w-4" />
</div>
<div className="flex-1">
<h4 className="font-medium">{context.name}</h4>
<div className="flex flex-wrap gap-1 mt-2">
{context.columns.map((column) => {
// Map Tailwind bg colors to actual hex values
const colorMap: Record<string, string> = {
'bg-neutral-400': '#a3a3a3',
'bg-red-500': '#ef4444',
'bg-orange-500': '#f97316',
'bg-yellow-500': '#eab308',
'bg-green-500': '#22c55e',
'bg-blue-500': '#3b82f6',
'bg-indigo-500': '#6366f1',
'bg-violet-500': '#8b5cf6',
'bg-cyan-500': '#06b6d4',
'bg-sky-500': '#0ea5e9',
};
return (
<Badge
key={column.id}
variant="outline"
className="text-xs"
style={{
borderLeftWidth: '3px',
borderLeftColor: colorMap[column.color] || '#a3a3a3',
}}
>
{column.title}
</Badge>
);
})}
</div>
</div>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={() => setIsEditing(true)}>
<IconEdit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onDelete}>
<IconTrash className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,49 @@
'use client';
import Link from 'next/link';
import { IconServer, IconChevronRight } from '@tabler/icons-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import type { Environment } from '@/lib/settings/types';
type EnvironmentCardProps = {
environment: Environment;
};
export function EnvironmentCard({ environment }: EnvironmentCardProps) {
const typeLabel = environment.type === 'local-docker' ? 'Local Docker' : 'Remote Docker';
const baseDockerLabel = environment.baseDocker === 'universal' ? 'Universal' : 'Golang';
return (
<Link href={`/settings/environment/${environment.id}`}>
<Card className="hover:bg-accent transition-colors cursor-pointer">
<CardContent className="py-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconServer className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="font-medium">{environment.name}</h4>
<Badge variant="secondary" className="text-xs">
{typeLabel}
</Badge>
<Badge variant="outline" className="text-xs">
{baseDockerLabel}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground mt-1">
<span>{environment.envVariables.length} variables</span>
<span>{environment.secrets.length} secrets</span>
<span>{(environment.installedAgents || []).length} agents</span>
</div>
</div>
</div>
<IconChevronRight className="h-5 w-5 text-muted-foreground" />
</div>
</CardContent>
</Card>
</Link>
);
}
@@ -0,0 +1,73 @@
'use client';
import { IconPlus, IconX } from '@tabler/icons-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import type { KeyValue } from '@/lib/settings/types';
type KeyValueInputProps = {
items: KeyValue[];
onChange: (items: KeyValue[]) => void;
keyPlaceholder?: string;
valuePlaceholder?: string;
addButtonLabel?: string;
masked?: boolean;
};
export function KeyValueInput({
items,
onChange,
keyPlaceholder = 'Key',
valuePlaceholder = 'Value',
addButtonLabel = 'Add Item',
masked = false,
}: KeyValueInputProps) {
const handleAdd = () => {
onChange([...items, { id: crypto.randomUUID(), key: '', value: '' }]);
};
const handleRemove = (id: string) => {
onChange(items.filter((item) => item.id !== id));
};
const handleChange = (id: string, field: 'key' | 'value', value: string) => {
onChange(
items.map((item) =>
item.id === id ? { ...item, [field]: value } : item
)
);
};
return (
<div className="space-y-2">
{items.map((item) => (
<div key={item.id} className="flex gap-2">
<Input
placeholder={keyPlaceholder}
value={item.key}
onChange={(e) => handleChange(item.id, 'key', e.target.value)}
className="flex-1"
/>
<Input
type={masked ? 'password' : 'text'}
placeholder={valuePlaceholder}
value={item.value}
onChange={(e) => handleChange(item.id, 'value', e.target.value)}
className="flex-1"
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemove(item.id)}
>
<IconX className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" size="sm" onClick={handleAdd} className="w-full">
<IconPlus className="h-4 w-4 mr-2" />
{addButtonLabel}
</Button>
</div>
);
}
@@ -0,0 +1,249 @@
'use client';
import { useState } from 'react';
import { IconGitBranch, IconTrash, IconEdit, IconCheck, IconX, IconPlus } from '@tabler/icons-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import type { Repository, CustomScript } from '@/lib/settings/types';
type RepositoryCardProps = {
repository: Repository;
onUpdate: (repo: Repository) => void;
onDelete: () => void;
};
export function RepositoryCard({ repository, onUpdate, onDelete }: RepositoryCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [editedRepo, setEditedRepo] = useState<Repository>(repository);
const handleSave = () => {
onUpdate(editedRepo);
setIsEditing(false);
};
const handleCancel = () => {
setEditedRepo(repository);
setIsEditing(false);
};
const handleFolderPick = () => {
const input = document.createElement('input');
input.type = 'file';
input.webkitdirectory = true;
input.onchange = (e: any) => {
const files = e.target.files;
if (files && files[0]) {
const path = files[0].webkitRelativePath.split('/')[0];
setEditedRepo({ ...editedRepo, path: `/${path}` });
}
};
input.click();
};
const handleAddCustomScript = () => {
setEditedRepo({
...editedRepo,
customScripts: [
...editedRepo.customScripts,
{ id: crypto.randomUUID(), name: '', command: '#!/bin/bash\n' },
],
});
};
const handleRemoveCustomScript = (id: string) => {
setEditedRepo({
...editedRepo,
customScripts: editedRepo.customScripts.filter((s) => s.id !== id),
});
};
const handleUpdateCustomScript = (id: string, field: 'name' | 'command', value: string) => {
setEditedRepo({
...editedRepo,
customScripts: editedRepo.customScripts.map((s) =>
s.id === id ? { ...s, [field]: value } : s
),
});
};
if (isEditing) {
return (
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="space-y-2">
<Label>Repository Name</Label>
<Input
value={editedRepo.name}
onChange={(e) => setEditedRepo({ ...editedRepo, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Directory Path</Label>
<div className="flex gap-2">
<Input
value={editedRepo.path}
onChange={(e) => setEditedRepo({ ...editedRepo, path: e.target.value })}
placeholder="/path/to/repository"
/>
<Button type="button" variant="outline" onClick={handleFolderPick}>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Setup Script</Label>
<Textarea
value={editedRepo.setupScript}
onChange={(e) => setEditedRepo({ ...editedRepo, setupScript: e.target.value })}
placeholder="#!/bin/bash&#10;npm install"
rows={3}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<Label>Cleanup Script</Label>
<Textarea
value={editedRepo.cleanupScript}
onChange={(e) => setEditedRepo({ ...editedRepo, cleanupScript: e.target.value })}
placeholder="#!/bin/bash&#10;rm -rf node_modules"
rows={3}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Custom Scripts</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddCustomScript}
>
<IconPlus className="h-4 w-4 mr-1" />
Add Script
</Button>
</div>
<div className="space-y-3">
{editedRepo.customScripts.map((script) => (
<div key={script.id} className="border rounded-md p-3 space-y-2">
<div className="flex gap-2">
<Input
placeholder="Script name"
value={script.name}
onChange={(e) =>
handleUpdateCustomScript(script.id, 'name', e.target.value)
}
className="flex-1"
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemoveCustomScript(script.id)}
>
<IconX className="h-4 w-4" />
</Button>
</div>
<Textarea
placeholder="#!/bin/bash&#10;npm run dev"
value={script.command}
onChange={(e) =>
handleUpdateCustomScript(script.id, 'command', e.target.value)
}
rows={3}
className="font-mono text-sm"
/>
</div>
))}
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleCancel}>
<IconX className="h-4 w-4 mr-2" />
Cancel
</Button>
<Button onClick={handleSave}>
<IconCheck className="h-4 w-4 mr-2" />
Save
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="hover:bg-accent transition-colors cursor-pointer" onClick={() => setIsEditing(true)}>
<CardContent className="py-4">
<div className="space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3 flex-1">
<div className="p-2 bg-muted rounded-md">
<IconGitBranch className="h-4 w-4" />
</div>
<div className="flex-1">
<h4 className="font-medium">{repository.name}</h4>
<p className="text-sm text-muted-foreground">{repository.path}</p>
{repository.customScripts.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{repository.customScripts.map((script) => (
<Badge key={script.id} variant="secondary" className="text-xs">
{script.name}
</Badge>
))}
</div>
)}
</div>
</div>
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" onClick={() => setIsEditing(true)}>
<IconEdit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onDelete}>
<IconTrash className="h-4 w-4 text-destructive" />
</Button>
</div>
</div>
{isExpanded && (
<div className="pl-11 space-y-2 text-sm">
<div>
<p className="font-medium">Setup:</p>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
{repository.setupScript}
</pre>
</div>
<div>
<p className="font-medium">Cleanup:</p>
<pre className="bg-muted p-2 rounded text-xs overflow-x-auto">
{repository.cleanupScript}
</pre>
</div>
</div>
)}
{(repository.setupScript || repository.cleanupScript) && (
<button
onClick={() => setIsExpanded(!isExpanded)}
className="pl-11 text-xs text-muted-foreground hover:text-foreground"
>
{isExpanded ? 'Hide' : 'Show'} scripts
</button>
)}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,212 @@
'use client';
import * as React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import {
IconSettings,
IconFolder,
IconServer,
IconRobot,
IconChevronRight,
IconPlug,
IconPuzzle,
} from '@tabler/icons-react';
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarMenuSub,
SidebarMenuSubItem,
SidebarMenuSubButton,
SidebarMenuAction,
SidebarHeader,
} from '@/components/ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
export function SettingsAppSidebar() {
const pathname = usePathname();
const [workspaces] = React.useState(SETTINGS_DATA.workspaces);
const [environments] = React.useState(SETTINGS_DATA.environments);
const [agents] = React.useState(SETTINGS_DATA.agents);
return (
<Sidebar variant="inset">
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link href="/">
<div className="bg-primary text-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg font-bold text-sm">
K
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">KanDev.ai</span>
<span className="truncate text-xs">Settings</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Settings</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{/* General */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === '/settings/general'}>
<Link href="/settings/general">
<IconSettings className="h-4 w-4" />
<span>General</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Workspaces */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Workspaces">
<Link href="/settings/workspace">
<IconFolder className="h-4 w-4" />
<span>Workspaces</span>
</Link>
</SidebarMenuButton>
{workspaces.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{workspaces.map((workspace) => (
<SidebarMenuSubItem key={workspace.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/workspace/${workspace.id}`}
>
<Link href={`/settings/workspace/${workspace.id}`}>
<span>{workspace.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Environments */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Environments">
<Link href="/settings/environments">
<IconServer className="h-4 w-4" />
<span>Environments</span>
</Link>
</SidebarMenuButton>
{environments.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{environments.map((env) => (
<SidebarMenuSubItem key={env.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/environment/${env.id}`}
>
<Link href={`/settings/environment/${env.id}`}>
<span>{env.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Agents */}
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="Agents">
<Link href="/settings/agents">
<IconRobot className="h-4 w-4" />
<span>Agents</span>
</Link>
</SidebarMenuButton>
{agents.length > 0 && (
<>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{agents.map((agent) => (
<SidebarMenuSubItem key={agent.id}>
<SidebarMenuSubButton
asChild
isActive={pathname === `/settings/agent/${agent.id}`}
>
<Link href={`/settings/agent/${agent.id}`}>
<span>{agent.name}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</>
)}
</SidebarMenuItem>
</Collapsible>
{/* Integrations */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === '/settings/integrations'}>
<Link href="/settings/integrations">
<IconPlug className="h-4 w-4" />
<span>Integrations</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{/* Plugins */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === '/settings/plugins'}>
<Link href="/settings/plugins">
<IconPuzzle className="h-4 w-4" />
<span>Plugins</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}
@@ -0,0 +1,24 @@
'use client';
import { Sheet, SheetContent } from '@/components/ui/sheet';
import { SettingsSidebar } from './settings-sidebar';
type SettingsMobileNavProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
};
export function SettingsMobileNav({ open, onOpenChange }: SettingsMobileNavProps) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-64 p-0">
<div className="pt-6">
<SettingsSidebar
className="border-0"
onNavigate={() => onOpenChange(false)}
/>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,29 @@
import { ReactNode } from 'react';
type SettingsSectionProps = {
icon?: ReactNode;
title: string;
description?: string;
action?: ReactNode;
children: ReactNode;
};
export function SettingsSection({ icon, title, description, action, children }: SettingsSectionProps) {
return (
<section className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
{icon}
{title}
</h3>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
{action && <div>{action}</div>}
</div>
{children}
</section>
);
}
@@ -0,0 +1,66 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { IconSettings, IconFolder, IconServer, IconRobot } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
const NAV_ITEMS = [
{
href: '/settings/general',
label: 'General',
icon: IconSettings,
},
{
href: '/settings/workspace',
label: 'Workspace',
icon: IconFolder,
},
{
href: '/settings/environments',
label: 'Environments',
icon: IconServer,
},
{
href: '/settings/agents',
label: 'Agents',
icon: IconRobot,
},
];
type SettingsSidebarProps = {
className?: string;
onNavigate?: () => void;
};
export function SettingsSidebar({ className, onNavigate }: SettingsSidebarProps) {
const pathname = usePathname();
return (
<nav className={cn('w-64 border-r border-border bg-card flex-col p-4', className)}>
<div className="space-y-1">
{NAV_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
'flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground font-medium'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
);
})}
</div>
</nav>
);
}
+2 -2
View File
@@ -7,8 +7,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
defaultTheme="system"
enableSystem={true}
>
{children}
</NextThemesProvider>
+123
View File
@@ -0,0 +1,123 @@
import * as React from "react"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
import { IconChevronRight, IconDots } from "@tabler/icons-react"
function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
aria-label="breadcrumb"
data-slot="breadcrumb"
className={cn(className)}
{...props}
/>
)
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground gap-1.5 text-sm sm:gap-2.5 flex flex-wrap items-center break-words",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("gap-1.5 inline-flex items-center", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? (
<IconChevronRight
/>
)}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn(
"size-5 [&>svg]:size-4 flex items-center justify-center",
className
)}
{...props}
>
<IconDots
/>
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
+59
View File
@@ -0,0 +1,59 @@
"use client"
import * as React from "react"
import { Slider as SliderPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className="bg-muted rounded-full data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5 bg-muted relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full"
>
<SliderPrimitive.Range
data-slot="slider-range"
className="bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full"
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 size-4 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"data-checked:bg-primary data-unchecked:bg-input focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 dark:data-unchecked:bg-input/80 shrink-0 rounded-full border border-transparent shadow-xs focus-visible:ring-[3px] aria-invalid:ring-[3px] data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] peer group/switch relative inline-flex items-center transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="bg-background dark:data-unchecked:bg-foreground dark:data-checked:bg-primary-foreground rounded-full group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 pointer-events-none block ring-0 transition-transform"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+134
View File
@@ -0,0 +1,134 @@
import type { SettingsData } from './types';
export const SETTINGS_DATA: SettingsData = {
general: {
theme: 'dark',
editor: 'vscode',
customEditorCommand: '',
notifications: {
taskUpdates: true,
agentCompletion: true,
errors: true,
},
},
workspaces: [
{
id: '1',
name: 'KanDev Project',
repositories: [
{
id: '1',
name: 'bismarck',
path: '/Users/username/projects/bismarck',
setupScript: '#!/bin/bash\nnpm install && npm run build',
cleanupScript: '#!/bin/bash\nrm -rf node_modules dist',
customScripts: [
{ id: 's1', name: 'dev', command: '#!/bin/bash\nnpm run dev' },
{ id: 's2', name: 'test', command: '#!/bin/bash\nnpm test' },
],
},
{
id: '2',
name: 'backend-api',
path: '/Users/username/projects/backend-api',
setupScript: '#!/bin/bash\ngo mod download',
cleanupScript: '#!/bin/bash\ngo clean',
customScripts: [
{ id: 's3', name: 'run', command: '#!/bin/bash\ngo run main.go' },
],
},
],
contexts: [
{
id: '1',
name: 'Architect',
columns: [
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
{ id: 'high-level-design', title: 'High Level Design', color: 'bg-cyan-500' },
{ id: 'low-level-design', title: 'Low Level Design', color: 'bg-violet-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
{
id: '2',
name: 'Dev',
columns: [
{ id: 'todo', title: 'To Do', color: 'bg-neutral-400' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
{
id: '3',
name: 'Team',
columns: [
{ id: 'backlog', title: 'Backlog', color: 'bg-neutral-400' },
{ id: 'solution-design', title: 'Solution Design', color: 'bg-sky-500' },
{ id: 'ready-for-dev', title: 'Ready for Dev', color: 'bg-indigo-500' },
{ id: 'in-progress', title: 'In Progress', color: 'bg-blue-500' },
{ id: 'review', title: 'Review', color: 'bg-yellow-500' },
{ id: 'done', title: 'Done', color: 'bg-green-500' },
],
},
],
},
],
environments: [
{
id: '1',
name: 'Development',
type: 'local-docker',
baseDocker: 'universal',
envVariables: [
{ id: 'e1', key: 'NODE_ENV', value: 'development' },
{ id: 'e2', key: 'PORT', value: '3000' },
],
secrets: [
{ id: 'se1', key: 'DATABASE_PASSWORD', value: '••••••••' },
{ id: 'se2', key: 'API_SECRET', value: '••••••••' },
],
setupScript: '#!/bin/bash\napt-get update\napt-get install -y git curl',
installedAgents: ['claude-code', 'codex'],
},
{
id: '2',
name: 'Production',
type: 'remote-docker',
baseDocker: 'golang',
envVariables: [
{ id: 'e3', key: 'NODE_ENV', value: 'production' },
],
secrets: [],
setupScript: '',
installedAgents: ['auggie'],
},
],
agents: [
{
id: '1',
agent: 'claude-code',
name: 'Default Profile',
model: 'claude-sonnet-4.5',
autoApprove: false,
temperature: 0.7,
},
{
id: '2',
agent: 'codex',
name: 'Fast Development',
model: 'codex-v2',
autoApprove: true,
temperature: 0.5,
},
{
id: '3',
agent: 'auggie',
name: 'Code Review',
model: 'auggie-v1',
autoApprove: false,
temperature: 0.3,
},
],
};
+87
View File
@@ -0,0 +1,87 @@
export type Theme = 'system' | 'light' | 'dark';
export type Editor = 'vscode' | 'cursor' | 'zed' | 'vim' | 'custom';
export type Notifications = {
taskUpdates: boolean;
agentCompletion: boolean;
errors: boolean;
};
export type GeneralSettings = {
theme: Theme;
editor: Editor;
customEditorCommand?: string;
notifications: Notifications;
};
export type CustomScript = {
id: string;
name: string;
command: string;
};
export type Repository = {
id: string;
name: string;
path: string;
setupScript: string;
cleanupScript: string;
customScripts: CustomScript[];
};
export type Column = {
id: string;
title: string;
color: string;
};
export type Context = {
id: string;
name: string;
columns: Column[];
};
export type Workspace = {
id: string;
name: string;
repositories: Repository[];
contexts: Context[];
};
export type KeyValue = {
id: string;
key: string;
value: string;
};
export type EnvironmentType = 'local-docker' | 'remote-docker';
export type BaseDocker = 'universal' | 'golang';
export type Environment = {
id: string;
name: string;
type: EnvironmentType;
baseDocker: BaseDocker;
envVariables: KeyValue[];
secrets: KeyValue[];
setupScript: string;
installedAgents: AgentType[];
};
export type AgentType = 'claude-code' | 'codex' | 'auggie';
export type AgentProfile = {
id: string;
agent: AgentType;
name: string;
model: string;
autoApprove: boolean;
temperature: number;
};
export type SettingsData = {
general: GeneralSettings;
workspaces: Workspace[];
environments: Environment[];
agents: AgentProfile[];
};