chore: fmt frontend

This commit is contained in:
couscous
2025-06-25 09:36:07 +01:00
parent 4bd9f51b98
commit 3f5f7a011b
52 changed files with 1977 additions and 1614 deletions

25
frontend/.eslintrc.json Normal file
View File

@@ -0,0 +1,25 @@
{
"root": true,
"env": { "browser": true, "es2020": true },
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"prettier"
],
"ignorePatterns": ["dist", ".eslintrc.json"],
"parser": "@typescript-eslint/parser",
"plugins": ["react-refresh", "@typescript-eslint"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"rules": {
"react-refresh/only-export-components": [
"warn",
{ "allowConstantExport": true }
],
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn"
}
}

View File

@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": true,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false
}

View File

@@ -1,15 +1,20 @@
import { useState, useEffect } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { Navbar } from "@/components/layout/navbar";
import { Projects } from "@/pages/projects";
import { ProjectTasks } from "@/pages/project-tasks";
import { TaskAttemptComparePage } from "@/pages/task-attempt-compare";
import { Settings } from "@/pages/Settings";
import { DisclaimerDialog } from "@/components/DisclaimerDialog";
import { OnboardingDialog } from "@/components/OnboardingDialog";
import { ConfigProvider, useConfig } from "@/components/config-provider";
import { ThemeProvider } from "@/components/theme-provider";
import type { Config, ApiResponse, ExecutorConfig, EditorType } from "shared/types";
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { Navbar } from '@/components/layout/navbar';
import { Projects } from '@/pages/projects';
import { ProjectTasks } from '@/pages/project-tasks';
import { TaskAttemptComparePage } from '@/pages/task-attempt-compare';
import { Settings } from '@/pages/Settings';
import { DisclaimerDialog } from '@/components/DisclaimerDialog';
import { OnboardingDialog } from '@/components/OnboardingDialog';
import { ConfigProvider, useConfig } from '@/components/config-provider';
import { ThemeProvider } from '@/components/theme-provider';
import type {
Config,
ApiResponse,
ExecutorConfig,
EditorType,
} from 'shared/types';
function AppContent() {
const { config, updateConfig, loading } = useConfig();
@@ -30,12 +35,12 @@ function AppContent() {
if (!config) return;
updateConfig({ disclaimer_acknowledged: true });
try {
const response = await fetch("/api/config", {
method: "POST",
const response = await fetch('/api/config', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify({ ...config, disclaimer_acknowledged: true }),
});
@@ -47,11 +52,14 @@ function AppContent() {
setShowOnboarding(!config.onboarding_acknowledged);
}
} catch (err) {
console.error("Error saving config:", err);
console.error('Error saving config:', err);
}
};
const handleOnboardingComplete = async (onboardingConfig: { executor: ExecutorConfig; editor: { editor_type: EditorType; custom_command: string | null } }) => {
const handleOnboardingComplete = async (onboardingConfig: {
executor: ExecutorConfig;
editor: { editor_type: EditorType; custom_command: string | null };
}) => {
if (!config) return;
const updatedConfig = {
@@ -62,12 +70,12 @@ function AppContent() {
};
updateConfig(updatedConfig);
try {
const response = await fetch("/api/config", {
method: "POST",
const response = await fetch('/api/config', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify(updatedConfig),
});
@@ -78,7 +86,7 @@ function AppContent() {
setShowOnboarding(false);
}
} catch (err) {
console.error("Error saving config:", err);
console.error('Error saving config:', err);
}
};
@@ -94,7 +102,7 @@ function AppContent() {
}
return (
<ThemeProvider initialTheme={config?.theme || "system"}>
<ThemeProvider initialTheme={config?.theme || 'system'}>
<div className="h-screen flex flex-col bg-background">
<DisclaimerDialog
open={showDisclaimer}
@@ -110,7 +118,10 @@ function AppContent() {
<Route path="/" element={<Projects />} />
<Route path="/projects" element={<Projects />} />
<Route path="/projects/:projectId" element={<Projects />} />
<Route path="/projects/:projectId/tasks" element={<ProjectTasks />} />
<Route
path="/projects/:projectId/tasks"
element={<ProjectTasks />}
/>
<Route
path="/projects/:projectId/tasks/:taskId"
element={<ProjectTasks />}

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -6,10 +6,10 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { AlertTriangle } from "lucide-react";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { AlertTriangle } from 'lucide-react';
interface DisclaimerDialogProps {
open: boolean;
@@ -39,7 +39,8 @@ export function DisclaimerDialog({ open, onAccept }: DisclaimerDialogProps) {
</p>
<div className="space-y-3">
<p>
<strong>Coding agents have full access to your computer</strong> and can execute any terminal commands, including:
<strong>Coding agents have full access to your computer</strong>{' '}
and can execute any terminal commands, including:
</p>
<ul className="list-disc list-inside space-y-1 ml-4">
<li>Installing, modifying, or deleting software</li>
@@ -48,13 +49,27 @@ export function DisclaimerDialog({ open, onAccept }: DisclaimerDialogProps) {
<li>Running system-level commands with your permissions</li>
</ul>
<p>
<strong>This software is experimental and may cause catastrophic damage</strong> to your system, data, or projects. By using this software, you acknowledge that:
<strong>
This software is experimental and may cause catastrophic
damage
</strong>{' '}
to your system, data, or projects. By using this software, you
acknowledge that:
</p>
<ul className="list-disc list-inside space-y-1 ml-4">
<li>You use this software entirely at your own risk</li>
<li>The developers are not responsible for any damage, data loss, or security issues</li>
<li>You should have proper backups of important data before using this software</li>
<li>You understand the potential consequences of granting unrestricted system access</li>
<li>
The developers are not responsible for any damage, data loss,
or security issues
</li>
<li>
You should have proper backups of important data before using
this software
</li>
<li>
You understand the potential consequences of granting
unrestricted system access
</li>
</ul>
</div>
</DialogDescription>
@@ -63,13 +78,17 @@ export function DisclaimerDialog({ open, onAccept }: DisclaimerDialogProps) {
<Checkbox
id="acknowledge"
checked={acknowledged}
onCheckedChange={(checked: boolean) => setAcknowledged(checked === true)}
onCheckedChange={(checked: boolean) =>
setAcknowledged(checked === true)
}
/>
<label
htmlFor="acknowledge"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
I understand and acknowledge the risks described above. I am aware that coding agents have full access to my computer and may cause catastrophic damage.
I understand and acknowledge the risks described above. I am aware
that coding agents have full access to my computer and may cause
catastrophic damage.
</label>
</div>
<DialogFooter>

View File

@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState } from 'react';
import {
Dialog,
DialogContent,
@@ -6,26 +6,26 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Sparkles, Code } from "lucide-react";
import type { EditorType, ExecutorConfig } from "shared/types";
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Sparkles, Code } from 'lucide-react';
import type { EditorType, ExecutorConfig } from 'shared/types';
import {
EXECUTOR_TYPES,
EDITOR_TYPES,
EXECUTOR_LABELS,
EDITOR_LABELS,
} from "shared/types";
} from 'shared/types';
interface OnboardingDialogProps {
open: boolean;
@@ -36,23 +36,23 @@ interface OnboardingDialogProps {
}
export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
const [executor, setExecutor] = useState<ExecutorConfig>({ type: "claude" });
const [editorType, setEditorType] = useState<EditorType>("vscode");
const [customCommand, setCustomCommand] = useState<string>("");
const [executor, setExecutor] = useState<ExecutorConfig>({ type: 'claude' });
const [editorType, setEditorType] = useState<EditorType>('vscode');
const [customCommand, setCustomCommand] = useState<string>('');
const handleComplete = () => {
onComplete({
executor,
editor: {
editor_type: editorType,
custom_command: editorType === "custom" ? customCommand || null : null,
custom_command: editorType === 'custom' ? customCommand || null : null,
},
});
};
const isValid =
editorType !== "custom" ||
(editorType === "custom" && customCommand.trim() !== "");
editorType !== 'custom' ||
(editorType === 'custom' && customCommand.trim() !== '');
return (
<Dialog open={open} onOpenChange={() => {}}>
@@ -81,7 +81,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
<Label htmlFor="executor">Default Executor</Label>
<Select
value={executor.type}
onValueChange={(value: "echo" | "claude" | "amp") =>
onValueChange={(value: 'echo' | 'claude' | 'amp') =>
setExecutor({ type: value })
}
>
@@ -97,10 +97,10 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
</SelectContent>
</Select>
<p className="text-sm text-muted-foreground">
{executor.type === "claude" && "Claude Code from Anthropic"}
{executor.type === "amp" && "From Sourcegraph"}
{executor.type === "echo" &&
"This is just for debugging vibe-kanban itself"}
{executor.type === 'claude' && 'Claude Code from Anthropic'}
{executor.type === 'amp' && 'From Sourcegraph'}
{executor.type === 'echo' &&
'This is just for debugging vibe-kanban itself'}
</p>
</div>
</CardContent>
@@ -137,7 +137,7 @@ export function OnboardingDialog({ open, onComplete }: OnboardingDialogProps) {
</p>
</div>
{editorType === "custom" && (
{editorType === 'custom' && (
<div className="space-y-2">
<Label htmlFor="custom-command">Custom Command</Label>
<Input

View File

@@ -1,5 +1,11 @@
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
import type { Config, ApiResponse } from "shared/types";
import {
createContext,
useContext,
useState,
useEffect,
ReactNode,
} from 'react';
import type { Config, ApiResponse } from 'shared/types';
interface ConfigContextType {
config: Config | null;
@@ -21,14 +27,14 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
useEffect(() => {
const loadConfig = async () => {
try {
const response = await fetch("/api/config");
const response = await fetch('/api/config');
const data: ApiResponse<Config> = await response.json();
if (data.success && data.data) {
setConfig(data.data);
}
} catch (err) {
console.error("Error loading config:", err);
console.error('Error loading config:', err);
} finally {
setLoading(false);
}
@@ -38,17 +44,17 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
}, []);
const updateConfig = (updates: Partial<Config>) => {
setConfig((prev) => prev ? { ...prev, ...updates } : null);
setConfig((prev) => (prev ? { ...prev, ...updates } : null));
};
const saveConfig = async (): Promise<boolean> => {
if (!config) return false;
try {
const response = await fetch("/api/config", {
method: "POST",
const response = await fetch('/api/config', {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify(config),
});
@@ -56,13 +62,15 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
const data: ApiResponse<Config> = await response.json();
return data.success;
} catch (err) {
console.error("Error saving config:", err);
console.error('Error saving config:', err);
return false;
}
};
return (
<ConfigContext.Provider value={{ config, updateConfig, saveConfig, loading }}>
<ConfigContext.Provider
value={{ config, updateConfig, saveConfig, loading }}
>
{children}
</ConfigContext.Provider>
);
@@ -71,7 +79,7 @@ export function ConfigProvider({ children }: ConfigProviderProps) {
export function useConfig() {
const context = useContext(ConfigContext);
if (context === undefined) {
throw new Error("useConfig must be used within a ConfigProvider");
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
}

View File

@@ -7,7 +7,7 @@ export function KeyboardShortcutsDemo() {
currentPath: '/demo',
hasOpenDialog: false,
closeDialog: () => {},
openCreateTask: () => {}
openCreateTask: () => {},
});
return (
@@ -18,7 +18,10 @@ export function KeyboardShortcutsDemo() {
<CardContent>
<div className="space-y-2">
{Object.values(shortcuts).map((shortcut) => (
<div key={shortcut.key} className="flex justify-between items-center">
<div
key={shortcut.key}
className="flex justify-between items-center"
>
<span className="text-sm">{shortcut.description}</span>
<kbd className="px-2 py-1 text-xs bg-muted rounded border">
{shortcut.key === 'KeyC' ? 'C' : shortcut.key}

View File

@@ -1,8 +1,8 @@
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { FolderOpen, Settings, HelpCircle } from "lucide-react";
import { Logo } from "@/components/logo";
import { SupportDialog } from "@/components/support-dialog";
import { Link, useLocation } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { FolderOpen, Settings, HelpCircle } from 'lucide-react';
import { Logo } from '@/components/logo';
import { SupportDialog } from '@/components/support-dialog';
export function Navbar() {
const location = useLocation();
@@ -17,7 +17,7 @@ export function Navbar() {
<Button
asChild
variant={
location.pathname === "/projects" ? "default" : "ghost"
location.pathname === '/projects' ? 'default' : 'ghost'
}
size="sm"
>
@@ -29,7 +29,7 @@ export function Navbar() {
<Button
asChild
variant={
location.pathname === "/settings" ? "default" : "ghost"
location.pathname === '/settings' ? 'default' : 'ghost'
}
size="sm"
>

View File

@@ -1,33 +1,33 @@
import { useTheme } from "@/components/theme-provider";
import { useEffect, useState } from "react";
import { useTheme } from '@/components/theme-provider';
import { useEffect, useState } from 'react';
export function Logo({ className = "" }: { className?: string }) {
export function Logo({ className = '' }: { className?: string }) {
const { theme } = useTheme();
const [isDark, setIsDark] = useState(false);
useEffect(() => {
const updateTheme = () => {
if (theme === "dark") {
if (theme === 'dark') {
setIsDark(true);
} else if (theme === "light") {
} else if (theme === 'light') {
setIsDark(false);
} else {
// System theme
setIsDark(window.matchMedia("(prefers-color-scheme: dark)").matches);
setIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
}
};
updateTheme();
// Listen for system theme changes when using system theme
if (theme === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaQuery.addEventListener("change", updateTheme);
return () => mediaQuery.removeEventListener("change", updateTheme);
if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateTheme);
return () => mediaQuery.removeEventListener('change', updateTheme);
}
}, [theme]);
const fillColor = isDark ? "#ffffff" : "#000000";
const fillColor = isDark ? '#ffffff' : '#000000';
return (
<svg

View File

@@ -1,70 +1,92 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { ProjectWithBranch, ApiResponse } from 'shared/types'
import { ProjectForm } from './project-form'
import { makeRequest } from '@/lib/api'
import { ArrowLeft, Edit, Trash2, Calendar, Clock, AlertCircle, Loader2, CheckSquare } from 'lucide-react'
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ProjectWithBranch, ApiResponse } from 'shared/types';
import { ProjectForm } from './project-form';
import { makeRequest } from '@/lib/api';
import {
ArrowLeft,
Edit,
Trash2,
Calendar,
Clock,
AlertCircle,
Loader2,
CheckSquare,
} from 'lucide-react';
interface ProjectDetailProps {
projectId: string
onBack: () => void
projectId: string;
onBack: () => void;
}
export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
const navigate = useNavigate()
const [project, setProject] = useState<ProjectWithBranch | null>(null)
const [loading, setLoading] = useState(false)
const [showEditForm, setShowEditForm] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate();
const [project, setProject] = useState<ProjectWithBranch | null>(null);
const [loading, setLoading] = useState(false);
const [showEditForm, setShowEditForm] = useState(false);
const [error, setError] = useState('');
const fetchProject = async () => {
setLoading(true)
setError('')
setLoading(true);
setError('');
try {
const response = await makeRequest(`/api/projects/${projectId}/with-branch`)
const data: ApiResponse<ProjectWithBranch> = await response.json()
const response = await makeRequest(
`/api/projects/${projectId}/with-branch`
);
const data: ApiResponse<ProjectWithBranch> = await response.json();
if (data.success && data.data) {
setProject(data.data)
setProject(data.data);
} else {
setError('Project not found')
setError('Project not found');
}
} catch (error) {
console.error('Failed to fetch project:', error)
setError('Failed to load project')
console.error('Failed to fetch project:', error);
setError('Failed to load project');
} finally {
setLoading(false)
setLoading(false);
}
}
};
const handleDelete = async () => {
if (!project) return
if (!confirm(`Are you sure you want to delete "${project.name}"? This action cannot be undone.`)) return
if (!project) return;
if (
!confirm(
`Are you sure you want to delete "${project.name}"? This action cannot be undone.`
)
)
return;
try {
const response = await makeRequest(`/api/projects/${projectId}`, {
method: 'DELETE',
})
});
if (response.ok) {
onBack()
onBack();
}
} catch (error) {
console.error('Failed to delete project:', error)
setError('Failed to delete project')
console.error('Failed to delete project:', error);
setError('Failed to delete project');
}
}
};
const handleEditSuccess = () => {
setShowEditForm(false)
fetchProject()
}
setShowEditForm(false);
fetchProject();
};
useEffect(() => {
fetchProject()
}, [projectId])
fetchProject();
}, [projectId]);
if (loading) {
return (
@@ -72,7 +94,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading project...
</div>
)
);
}
if (error || !project) {
@@ -89,7 +111,8 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</div>
<h3 className="mt-4 text-lg font-semibold">Project not found</h3>
<p className="mt-2 text-sm text-muted-foreground">
{error || 'The project you\'re looking for doesn\'t exist or has been deleted.'}
{error ||
"The project you're looking for doesn't exist or has been deleted."}
</p>
<Button className="mt-4" onClick={onBack}>
Back to Projects
@@ -97,7 +120,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</CardContent>
</Card>
</div>
)
);
}
return (
@@ -117,7 +140,9 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</span>
)}
</div>
<p className="text-sm text-muted-foreground">Project details and settings</p>
<p className="text-sm text-muted-foreground">
Project details and settings
</p>
</div>
</div>
<div className="flex gap-2">
@@ -129,8 +154,8 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
<Edit className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="outline"
<Button
variant="outline"
onClick={handleDelete}
className="text-destructive hover:text-destructive-foreground hover:bg-destructive/10"
>
@@ -143,9 +168,7 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{error}
</AlertDescription>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
@@ -159,21 +182,26 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Status</span>
<span className="text-sm font-medium text-muted-foreground">
Status
</span>
<Badge variant="secondary">Active</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center text-sm">
<Calendar className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Created:</span>
<span className="ml-2">{new Date(project.created_at).toLocaleDateString()}</span>
<span className="ml-2">
{new Date(project.created_at).toLocaleDateString()}
</span>
</div>
<div className="flex items-center text-sm">
<Clock className="mr-2 h-4 w-4 text-muted-foreground" />
<span className="text-muted-foreground">Last Updated:</span>
<span className="ml-2">{new Date(project.updated_at).toLocaleDateString()}</span>
<span className="ml-2">
{new Date(project.updated_at).toLocaleDateString()}
</span>
</div>
</div>
</CardContent>
</Card>
@@ -187,19 +215,25 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
</CardHeader>
<CardContent className="space-y-3">
<div>
<h4 className="text-sm font-medium text-muted-foreground">Project ID</h4>
<h4 className="text-sm font-medium text-muted-foreground">
Project ID
</h4>
<code className="mt-1 block text-xs bg-muted p-2 rounded font-mono">
{project.id}
</code>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">Created At</h4>
<h4 className="text-sm font-medium text-muted-foreground">
Created At
</h4>
<p className="mt-1 text-sm">
{new Date(project.created_at).toLocaleString()}
</p>
</div>
<div>
<h4 className="text-sm font-medium text-muted-foreground">Last Modified</h4>
<h4 className="text-sm font-medium text-muted-foreground">
Last Modified
</h4>
<p className="mt-1 text-sm">
{new Date(project.updated_at).toLocaleString()}
</p>
@@ -215,5 +249,5 @@ export function ProjectDetail({ projectId, onBack }: ProjectDetailProps) {
project={project}
/>
</div>
)
);
}

View File

@@ -1,8 +1,8 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Dialog,
DialogContent,
@@ -10,11 +10,11 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FolderPicker } from "@/components/ui/folder-picker";
import { Project, CreateProject, UpdateProject } from "shared/types";
import { AlertCircle, Folder } from "lucide-react";
import { makeRequest } from "@/lib/api";
} from '@/components/ui/dialog';
import { FolderPicker } from '@/components/ui/folder-picker';
import { Project, CreateProject, UpdateProject } from 'shared/types';
import { AlertCircle, Folder } from 'lucide-react';
import { makeRequest } from '@/lib/api';
interface ProjectFormProps {
open: boolean;
@@ -29,31 +29,31 @@ export function ProjectForm({
onSuccess,
project,
}: ProjectFormProps) {
const [name, setName] = useState(project?.name || "");
const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || "");
const [setupScript, setSetupScript] = useState(project?.setup_script ?? "");
const [devScript, setDevScript] = useState(project?.dev_script ?? "");
const [name, setName] = useState(project?.name || '');
const [gitRepoPath, setGitRepoPath] = useState(project?.git_repo_path || '');
const [setupScript, setSetupScript] = useState(project?.setup_script ?? '');
const [devScript, setDevScript] = useState(project?.dev_script ?? '');
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [error, setError] = useState('');
const [showFolderPicker, setShowFolderPicker] = useState(false);
const [repoMode, setRepoMode] = useState<"existing" | "new">("existing");
const [parentPath, setParentPath] = useState("");
const [folderName, setFolderName] = useState("");
const [repoMode, setRepoMode] = useState<'existing' | 'new'>('existing');
const [parentPath, setParentPath] = useState('');
const [folderName, setFolderName] = useState('');
const isEditing = !!project;
// Update form fields when project prop changes
useEffect(() => {
if (project) {
setName(project.name || "");
setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? "");
setDevScript(project.dev_script ?? "");
setName(project.name || '');
setGitRepoPath(project.git_repo_path || '');
setSetupScript(project.setup_script ?? '');
setDevScript(project.dev_script ?? '');
} else {
setName("");
setGitRepoPath("");
setSetupScript("");
setDevScript("");
setName('');
setGitRepoPath('');
setSetupScript('');
setDevScript('');
}
}, [project]);
@@ -64,11 +64,11 @@ export function ProjectForm({
// Only auto-populate name for new projects
if (!isEditing && path) {
// Extract the last part of the path (directory name)
const dirName = path.split("/").filter(Boolean).pop() || "";
const dirName = path.split('/').filter(Boolean).pop() || '';
if (dirName) {
// Clean up the directory name for a better project name
const cleanName = dirName
.replace(/[-_]/g, " ") // Replace hyphens and underscores with spaces
.replace(/[-_]/g, ' ') // Replace hyphens and underscores with spaces
.replace(/\b\w/g, (l) => l.toUpperCase()); // Capitalize first letter of each word
setName(cleanName);
}
@@ -77,15 +77,15 @@ export function ProjectForm({
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setError('');
setLoading(true);
try {
let finalGitRepoPath = gitRepoPath;
// For new repo mode, construct the full path
if (!isEditing && repoMode === "new") {
finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, "/");
if (!isEditing && repoMode === 'new') {
finalGitRepoPath = `${parentPath}/${folderName}`.replace(/\/+/g, '/');
}
if (isEditing) {
@@ -95,53 +95,50 @@ export function ProjectForm({
setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
};
const response = await makeRequest(
`/api/projects/${project.id}`,
{
method: "PUT",
body: JSON.stringify(updateData),
}
);
const response = await makeRequest(`/api/projects/${project.id}`, {
method: 'PUT',
body: JSON.stringify(updateData),
});
if (!response.ok) {
throw new Error("Failed to update project");
throw new Error('Failed to update project');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || "Failed to update project");
throw new Error(data.message || 'Failed to update project');
}
} else {
const createData: CreateProject = {
name,
git_repo_path: finalGitRepoPath,
use_existing_repo: repoMode === "existing",
use_existing_repo: repoMode === 'existing',
setup_script: setupScript.trim() || null,
dev_script: devScript.trim() || null,
};
const response = await makeRequest("/api/projects", {
method: "POST",
const response = await makeRequest('/api/projects', {
method: 'POST',
body: JSON.stringify(createData),
});
if (!response.ok) {
throw new Error("Failed to create project");
throw new Error('Failed to create project');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.message || "Failed to create project");
throw new Error(data.message || 'Failed to create project');
}
}
onSuccess();
setName("");
setGitRepoPath("");
setSetupScript("");
setParentPath("");
setFolderName("");
setName('');
setGitRepoPath('');
setSetupScript('');
setParentPath('');
setFolderName('');
} catch (error) {
setError(error instanceof Error ? error.message : "An error occurred");
setError(error instanceof Error ? error.message : 'An error occurred');
} finally {
setLoading(false);
}
@@ -149,19 +146,19 @@ export function ProjectForm({
const handleClose = () => {
if (project) {
setName(project.name || "");
setGitRepoPath(project.git_repo_path || "");
setSetupScript(project.setup_script ?? "");
setDevScript(project.dev_script ?? "");
setName(project.name || '');
setGitRepoPath(project.git_repo_path || '');
setSetupScript(project.setup_script ?? '');
setDevScript(project.dev_script ?? '');
} else {
setName("");
setGitRepoPath("");
setSetupScript("");
setDevScript("");
setName('');
setGitRepoPath('');
setSetupScript('');
setDevScript('');
}
setParentPath("");
setFolderName("");
setError("");
setParentPath('');
setFolderName('');
setError('');
onClose();
};
@@ -170,12 +167,12 @@ export function ProjectForm({
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{isEditing ? "Edit Project" : "Create New Project"}
{isEditing ? 'Edit Project' : 'Create New Project'}
</DialogTitle>
<DialogDescription>
{isEditing
? "Make changes to your project here. Click save when you're done."
: "Choose whether to use an existing git repository or create a new one."}
: 'Choose whether to use an existing git repository or create a new one.'}
</DialogDescription>
</DialogHeader>
@@ -189,9 +186,9 @@ export function ProjectForm({
type="radio"
name="repoMode"
value="existing"
checked={repoMode === "existing"}
checked={repoMode === 'existing'}
onChange={(e) =>
setRepoMode(e.target.value as "existing" | "new")
setRepoMode(e.target.value as 'existing' | 'new')
}
className="text-primary"
/>
@@ -202,9 +199,9 @@ export function ProjectForm({
type="radio"
name="repoMode"
value="new"
checked={repoMode === "new"}
checked={repoMode === 'new'}
onChange={(e) =>
setRepoMode(e.target.value as "existing" | "new")
setRepoMode(e.target.value as 'existing' | 'new')
}
className="text-primary"
/>
@@ -214,7 +211,7 @@ export function ProjectForm({
</div>
)}
{repoMode === "existing" || isEditing ? (
{repoMode === 'existing' || isEditing ? (
<div className="space-y-2">
<Label htmlFor="git-repo-path">Git Repository Path</Label>
<div className="flex space-x-2">
@@ -279,7 +276,7 @@ export function ProjectForm({
if (e.target.value) {
setName(
e.target.value
.replace(/[-_]/g, " ")
.replace(/[-_]/g, ' ')
.replace(/\b\w/g, (l) => l.toUpperCase())
);
}
@@ -318,8 +315,9 @@ export function ProjectForm({
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-sm text-muted-foreground">
This script will run after creating the worktree and before the executor starts.
Use it for setup tasks like installing dependencies or preparing the environment.
This script will run after creating the worktree and before the
executor starts. Use it for setup tasks like installing
dependencies or preparing the environment.
</p>
</div>
@@ -334,8 +332,9 @@ export function ProjectForm({
className="w-full px-3 py-2 border border-input bg-background text-foreground rounded-md resize-vertical focus:outline-none focus:ring-2 focus:ring-ring"
/>
<p className="text-sm text-muted-foreground">
This script can be run from task attempts to start a development server.
Use it to quickly start your project's dev server for testing changes.
This script can be run from task attempts to start a development
server. Use it to quickly start your project's dev server for
testing changes.
</p>
</div>
@@ -360,16 +359,16 @@ export function ProjectForm({
disabled={
loading ||
!name.trim() ||
(repoMode === "existing" || isEditing
(repoMode === 'existing' || isEditing
? !gitRepoPath.trim()
: !parentPath.trim() || !folderName.trim())
}
>
{loading
? "Saving..."
? 'Saving...'
: isEditing
? "Save Changes"
: "Create Project"}
? 'Save Changes'
: 'Create Project'}
</Button>
</DialogFooter>
</form>
@@ -379,23 +378,23 @@ export function ProjectForm({
open={showFolderPicker}
onClose={() => setShowFolderPicker(false)}
onSelect={(path) => {
if (repoMode === "existing" || isEditing) {
if (repoMode === 'existing' || isEditing) {
handleGitRepoPathChange(path);
} else {
setParentPath(path);
}
setShowFolderPicker(false);
}}
value={repoMode === "existing" || isEditing ? gitRepoPath : parentPath}
value={repoMode === 'existing' || isEditing ? gitRepoPath : parentPath}
title={
repoMode === "existing" || isEditing
? "Select Git Repository"
: "Select Parent Directory"
repoMode === 'existing' || isEditing
? 'Select Git Repository'
: 'Select Parent Directory'
}
description={
repoMode === "existing" || isEditing
? "Choose an existing git repository"
: "Choose where to create the new repository"
repoMode === 'existing' || isEditing
? 'Choose an existing git repository'
: 'Choose where to create the new repository'
}
/>
</Dialog>

View File

@@ -1,18 +1,18 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Project, ApiResponse } from "shared/types";
import { ProjectForm } from "./project-form";
import { makeRequest } from "@/lib/api";
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Project, ApiResponse } from 'shared/types';
import { ProjectForm } from './project-form';
import { makeRequest } from '@/lib/api';
import {
Plus,
Edit,
@@ -22,13 +22,13 @@ import {
Loader2,
MoreHorizontal,
ExternalLink,
} from "lucide-react";
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
export function ProjectList() {
const navigate = useNavigate();
@@ -36,22 +36,22 @@ export function ProjectList() {
const [loading, setLoading] = useState(false);
const [showForm, setShowForm] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [error, setError] = useState("");
const [error, setError] = useState('');
const fetchProjects = async () => {
setLoading(true);
setError("");
setError('');
try {
const response = await makeRequest("/api/projects");
const response = await makeRequest('/api/projects');
const data: ApiResponse<Project[]> = await response.json();
if (data.success && data.data) {
setProjects(data.data);
} else {
setError("Failed to load projects");
setError('Failed to load projects');
}
} catch (error) {
console.error("Failed to fetch projects:", error);
setError("Failed to connect to server");
console.error('Failed to fetch projects:', error);
setError('Failed to connect to server');
} finally {
setLoading(false);
}
@@ -67,14 +67,14 @@ export function ProjectList() {
try {
const response = await makeRequest(`/api/projects/${id}`, {
method: "DELETE",
method: 'DELETE',
});
if (response.ok) {
fetchProjects();
}
} catch (error) {
console.error("Failed to delete project:", error);
setError("Failed to delete project");
console.error('Failed to delete project:', error);
setError('Failed to delete project');
}
};

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'
import { ProjectList } from './project-list'
import { ProjectDetail } from './project-detail'
import { useState } from 'react';
import { ProjectList } from './project-list';
import { ProjectDetail } from './project-detail';
export function ProjectsPage() {
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null)
const [selectedProjectId, setSelectedProjectId] = useState<string | null>(
null
);
if (selectedProjectId) {
return (
@@ -11,8 +13,8 @@ export function ProjectsPage() {
projectId={selectedProjectId}
onBack={() => setSelectedProjectId(null)}
/>
)
);
}
return <ProjectList />
return <ProjectList />;
}

View File

@@ -1,13 +1,13 @@
import { useState, ReactNode } from "react";
import { HelpCircle, Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useState, ReactNode } from 'react';
import { HelpCircle, Mail } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
} from '@/components/ui/dialog';
interface SupportDialogProps {
children: ReactNode;
@@ -17,7 +17,7 @@ export function SupportDialog({ children }: SupportDialogProps) {
const [isOpen, setIsOpen] = useState(false);
const handleEmailClick = () => {
window.location.href = "mailto:louis@bloop.ai";
window.location.href = 'mailto:louis@bloop.ai';
};
return (
@@ -36,7 +36,7 @@ export function SupportDialog({ children }: SupportDialogProps) {
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Email me at{" "}
Email me at{' '}
<strong className="text-foreground">louis@bloop.ai</strong> with
any questions and I'll respond ASAP.
</p>

View File

@@ -1,7 +1,7 @@
import { useState, useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Brain,
@@ -12,7 +12,7 @@ import {
Zap,
AlertTriangle,
FileText,
} from "lucide-react";
} from 'lucide-react';
interface JSONLLine {
type: string;
@@ -21,9 +21,9 @@ interface JSONLLine {
messages?: [
number,
{
role: "user" | "assistant";
role: 'user' | 'assistant';
content: Array<{
type: "text" | "thinking" | "tool_use" | "tool_result";
type: 'text' | 'thinking' | 'tool_use' | 'tool_result';
text?: string;
thinking?: string;
id?: string;
@@ -43,10 +43,10 @@ interface JSONLLine {
type: string;
stopReason?: string;
};
}
},
][];
toolResults?: Array<{
type: "tool_use" | "tool_result";
type: 'tool_use' | 'tool_result';
id?: string;
name?: string;
input?: any;
@@ -66,17 +66,19 @@ interface JSONLLine {
command?: string;
// Claude format
message?: {
role: "user" | "assistant" | "system";
content: Array<{
type: "text" | "tool_use" | "tool_result";
text?: string;
id?: string;
name?: string;
input?: any;
tool_use_id?: string;
content?: any;
is_error?: boolean;
}> | string;
role: 'user' | 'assistant' | 'system';
content:
| Array<{
type: 'text' | 'tool_use' | 'tool_result';
text?: string;
id?: string;
name?: string;
input?: any;
tool_use_id?: string;
content?: any;
is_error?: boolean;
}>
| string;
};
// Tool rejection message (string format)
rejectionMessage?: string;
@@ -99,57 +101,58 @@ interface ConversationViewerProps {
// Validation functions
const isValidMessage = (data: any): boolean => {
return (
typeof data.role === "string" &&
typeof data.role === 'string' &&
Array.isArray(data.content) &&
data.content.every(
(item: any) =>
typeof item.type === "string" &&
(item.type !== "text" || typeof item.text === "string") &&
(item.type !== "thinking" || typeof item.thinking === "string") &&
(item.type !== "tool_use" || typeof item.name === "string") &&
(item.type !== "tool_result" ||
typeof item.type === 'string' &&
(item.type !== 'text' || typeof item.text === 'string') &&
(item.type !== 'thinking' || typeof item.thinking === 'string') &&
(item.type !== 'tool_use' || typeof item.name === 'string') &&
(item.type !== 'tool_result' ||
!item.run ||
typeof item.run.status === "string")
typeof item.run.status === 'string')
)
);
};
const isValidClaudeMessage = (data: any): boolean => {
return (
typeof data.role === "string" &&
(typeof data.content === "string" ||
(Array.isArray(data.content) &&
data.content.every(
(item: any) =>
typeof item.type === "string" &&
(item.type !== "text" || typeof item.text === "string") &&
(item.type !== "tool_use" || typeof item.name === "string") &&
(item.type !== "tool_result" || typeof item.content !== "undefined")
)))
typeof data.role === 'string' &&
(typeof data.content === 'string' ||
(Array.isArray(data.content) &&
data.content.every(
(item: any) =>
typeof item.type === 'string' &&
(item.type !== 'text' || typeof item.text === 'string') &&
(item.type !== 'tool_use' || typeof item.name === 'string') &&
(item.type !== 'tool_result' || typeof item.content !== 'undefined')
)))
);
};
const isValidTokenUsage = (data: any): boolean => {
return (
data &&
typeof data.used === "number" &&
typeof data.maxAvailable === "number"
typeof data.used === 'number' &&
typeof data.maxAvailable === 'number'
);
};
const isValidClaudeUsage = (data: any): boolean => {
return (
data &&
typeof data.input_tokens === "number" &&
typeof data.output_tokens === "number"
typeof data.input_tokens === 'number' &&
typeof data.output_tokens === 'number'
);
};
const isValidToolRejection = (data: any): boolean => {
return (
typeof data.tool === "string" &&
typeof data.command === "string" &&
(typeof data.message === "string" || typeof data.rejectionMessage === "string")
typeof data.tool === 'string' &&
typeof data.command === 'string' &&
(typeof data.message === 'string' ||
typeof data.rejectionMessage === 'string')
);
};
@@ -160,7 +163,7 @@ const isValidMessagesLine = (line: any): boolean => {
(msg: any) =>
Array.isArray(msg) &&
msg.length >= 2 &&
typeof msg[0] === "number" &&
typeof msg[0] === 'number' &&
isValidMessage(msg[1])
)
);
@@ -175,7 +178,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
const parsedLines = useMemo(() => {
try {
return jsonlOutput
.split("\n")
.split('\n')
.filter((line) => line.trim())
.map((line, index) => {
try {
@@ -187,10 +190,10 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
} as JSONLLine & { _lineIndex: number; _rawLine: string };
} catch {
return {
type: "parse-error",
type: 'parse-error',
_lineIndex: index,
_rawLine: line,
error: "Failed to parse JSON",
error: 'Failed to parse JSON',
} as JSONLLine & {
_lineIndex: number;
_rawLine: string;
@@ -205,8 +208,8 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
const conversation = useMemo(() => {
const items: Array<{
type: "message" | "tool-rejection" | "parse-error" | "unknown";
role?: "user" | "assistant";
type: 'message' | 'tool-rejection' | 'parse-error' | 'unknown';
role?: 'user' | 'assistant';
content?: Array<{
type: string;
text?: string;
@@ -236,22 +239,22 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
for (const line of parsedLines) {
try {
if (line.type === "parse-error") {
if (line.type === 'parse-error') {
items.push({
type: "parse-error",
type: 'parse-error',
error: line.error,
rawLine: line._rawLine,
lineIndex: line._lineIndex,
});
} else if (
line.type === "messages" &&
line.type === 'messages' &&
isValidMessagesLine(line) &&
line.messages
) {
// Amp format
for (const [messageIndex, message] of line.messages) {
items.push({
type: "message",
type: 'message',
role: message.role,
content: message.content,
timestamp: message.meta?.sentAt,
@@ -260,34 +263,39 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
});
}
} else if (
(line.type === "user" || line.type === "assistant" || line.type === "system") &&
(line.type === 'user' ||
line.type === 'assistant' ||
line.type === 'system') &&
line.message &&
isValidClaudeMessage(line.message)
) {
// Claude format
const content = typeof line.message.content === "string"
? [{ type: "text", text: line.message.content }]
: line.message.content;
const content =
typeof line.message.content === 'string'
? [{ type: 'text', text: line.message.content }]
: line.message.content;
items.push({
type: "message",
role: line.message.role === "system" ? "assistant" : line.message.role,
type: 'message',
role:
line.message.role === 'system' ? 'assistant' : line.message.role,
content: content,
lineIndex: line._lineIndex,
});
} else if (
line.type === "result" &&
line.type === 'result' &&
line.usage &&
isValidClaudeUsage(line.usage)
) {
// Claude usage info
tokenUsages.push({
used: line.usage.input_tokens + line.usage.output_tokens,
maxAvailable: line.usage.input_tokens + line.usage.output_tokens + 100000, // Approximate
maxAvailable:
line.usage.input_tokens + line.usage.output_tokens + 100000, // Approximate
lineIndex: line._lineIndex,
});
} else if (
line.type === "token-usage" &&
line.type === 'token-usage' &&
line.tokenUsage &&
isValidTokenUsage(line.tokenUsage)
) {
@@ -297,26 +305,29 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
maxAvailable: line.tokenUsage.maxAvailable,
lineIndex: line._lineIndex,
});
} else if (line.type === "state" && typeof line.state === "string") {
} else if (line.type === 'state' && typeof line.state === 'string') {
states.push({
state: line.state,
lineIndex: line._lineIndex,
});
} else if (
line.type === "tool-rejected" &&
line.type === 'tool-rejected' &&
isValidToolRejection(line)
) {
items.push({
type: "tool-rejection",
type: 'tool-rejection',
tool: line.tool,
command: line.command,
message: typeof line.message === "string" ? line.message : line.rejectionMessage || "Tool rejected",
message:
typeof line.message === 'string'
? line.message
: line.rejectionMessage || 'Tool rejected',
lineIndex: line._lineIndex,
});
} else {
// Unknown line type or invalid structure - add as unknown for fallback rendering
items.push({
type: "unknown",
type: 'unknown',
rawLine: line._rawLine,
lineIndex: line._lineIndex,
});
@@ -324,7 +335,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
} catch (error) {
// If anything goes wrong processing a line, treat it as unknown
items.push({
type: "unknown",
type: 'unknown',
rawLine: line._rawLine,
lineIndex: line._lineIndex,
});
@@ -333,7 +344,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
// Sort by messageIndex for messages, then by lineIndex for everything else
items.sort((a, b) => {
if (a.type === "message" && b.type === "message") {
if (a.type === 'message' && b.type === 'message') {
return (a.messageIndex || 0) - (b.messageIndex || 0);
}
return (a.lineIndex || 0) - (b.lineIndex || 0);
@@ -361,7 +372,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
if (input === null || input === undefined) {
return String(input);
}
if (typeof input === "object") {
if (typeof input === 'object') {
// Try to stringify, but handle circular references and complex objects
return JSON.stringify(input);
}
@@ -373,16 +384,16 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
};
const safeRenderString = (value: any): string => {
if (typeof value === "string") {
if (typeof value === 'string') {
return value;
}
if (value === null || value === undefined) {
return String(value);
}
if (typeof value === "object") {
if (typeof value === 'object') {
try {
// Use the same safe JSON.stringify logic as formatToolInput
return "(RAW)" + JSON.stringify(value);
return '(RAW)' + JSON.stringify(value);
} catch (error) {
return `[Object - serialization failed: ${String(value).substring(
0,
@@ -395,15 +406,15 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
const getToolStatusColor = (status: string) => {
switch (status) {
case "done":
return "bg-green-500";
case "rejected-by-user":
case "blocked-on-user":
return "bg-yellow-500";
case "error":
return "bg-red-500";
case 'done':
return 'bg-green-500';
case 'rejected-by-user':
case 'blocked-on-user':
return 'bg-yellow-500';
case 'error':
return 'bg-red-500';
default:
return "bg-blue-500";
return 'bg-blue-500';
}
};
@@ -434,7 +445,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
{latestTokenUsage && (
<Badge variant="outline" className="text-xs">
<Zap className="h-3 w-3 mr-1" />
{latestTokenUsage.used.toLocaleString()} /{" "}
{latestTokenUsage.used.toLocaleString()} /{' '}
{latestTokenUsage.maxAvailable.toLocaleString()} tokens
</Badge>
)}
@@ -469,7 +480,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
Step {index + 1}
</span>
<span>
{usage.used.toLocaleString()} /{" "}
{usage.used.toLocaleString()} /{' '}
{usage.maxAvailable.toLocaleString()}
</span>
</div>
@@ -482,7 +493,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
{/* Conversation items (messages and tool rejections) */}
<div className="space-y-3">
{conversation.items.map((item, index) => {
if (item.type === "parse-error") {
if (item.type === 'parse-error') {
return (
<Card
key={`error-${index}`}
@@ -508,11 +519,11 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
);
}
if (item.type === "unknown") {
if (item.type === 'unknown') {
let prettyJson = item.rawLine;
try {
prettyJson = JSON.stringify(
JSON.parse(item.rawLine || "{}"),
JSON.parse(item.rawLine || '{}'),
null,
2
);
@@ -540,7 +551,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
);
}
if (item.type === "tool-rejection") {
if (item.type === 'tool-rejection') {
return (
<Card
key={`rejection-${index}`}
@@ -579,20 +590,20 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
);
}
if (item.type === "message") {
if (item.type === 'message') {
const messageId = `message-${index}`;
const isExpanded = expandedMessages.has(messageId);
const hasThinking = item.content?.some(
(c: any) => c.type === "thinking"
(c: any) => c.type === 'thinking'
);
return (
<Card
key={messageId}
className={`${
item.role === "user"
? "bg-blue-100/50 dark:bg-blue-900/20 border ml-12"
: "bg-muted/50 border mr-12"
item.role === 'user'
? 'bg-blue-100/50 dark:bg-blue-900/20 border ml-12'
: 'bg-muted/50 border mr-12'
}`}
>
<CardContent className="p-4">
@@ -630,7 +641,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
<div className="space-y-2">
{item.content?.map((content: any, contentIndex: number) => {
if (content.type === "text") {
if (content.type === 'text') {
return (
<div
key={contentIndex}
@@ -643,7 +654,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
);
}
if (content.type === "thinking" && isExpanded) {
if (content.type === 'thinking' && isExpanded) {
return (
<div key={contentIndex} className="mt-3">
<div className="flex items-center gap-2 mb-2">
@@ -658,7 +669,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
);
}
if (content.type === "tool_use") {
if (content.type === 'tool_use') {
return (
<div key={contentIndex} className="mt-3">
<div className="flex items-center gap-2 mb-2">
@@ -676,7 +687,7 @@ export function ConversationViewer({ jsonlOutput }: ConversationViewerProps) {
);
}
if (content.type === "tool_result") {
if (content.type === 'tool_result') {
return (
<div key={contentIndex} className="mt-3">
<div className="flex items-center gap-2 mb-2">

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -7,15 +7,15 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { EditorType } from "shared/types";
} from '@/components/ui/select';
import type { EditorType } from 'shared/types';
interface EditorSelectionDialogProps {
isOpen: boolean;
@@ -23,36 +23,40 @@ interface EditorSelectionDialogProps {
onSelectEditor: (editorType: EditorType) => void;
}
const editorOptions: { value: EditorType; label: string; description: string }[] = [
const editorOptions: {
value: EditorType;
label: string;
description: string;
}[] = [
{
value: "vscode",
label: "Visual Studio Code",
value: 'vscode',
label: 'Visual Studio Code',
description: "Microsoft's popular code editor",
},
{
value: "cursor",
label: "Cursor",
description: "AI-powered code editor",
value: 'cursor',
label: 'Cursor',
description: 'AI-powered code editor',
},
{
value: "windsurf",
label: "Windsurf",
description: "Modern code editor",
value: 'windsurf',
label: 'Windsurf',
description: 'Modern code editor',
},
{
value: "intellij",
label: "IntelliJ IDEA",
description: "JetBrains IDE",
value: 'intellij',
label: 'IntelliJ IDEA',
description: 'JetBrains IDE',
},
{
value: "zed",
label: "Zed",
description: "High-performance code editor",
value: 'zed',
label: 'Zed',
description: 'High-performance code editor',
},
{
value: "custom",
label: "Custom Editor",
description: "Use your configured custom editor",
value: 'custom',
label: 'Custom Editor',
description: 'Use your configured custom editor',
},
];
@@ -61,7 +65,7 @@ export function EditorSelectionDialog({
onClose,
onSelectEditor,
}: EditorSelectionDialogProps) {
const [selectedEditor, setSelectedEditor] = useState<EditorType>("vscode");
const [selectedEditor, setSelectedEditor] = useState<EditorType>('vscode');
const handleConfirm = () => {
onSelectEditor(selectedEditor);

View File

@@ -1,10 +1,10 @@
import { useState, useMemo, useEffect } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FileText, MessageSquare } from "lucide-react";
import { ConversationViewer } from "./ConversationViewer";
import type { ExecutionProcess, ExecutionProcessStatus } from "shared/types";
import { useState, useMemo, useEffect } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { FileText, MessageSquare } from 'lucide-react';
import { ConversationViewer } from './ConversationViewer';
import type { ExecutionProcess, ExecutionProcessStatus } from 'shared/types';
interface ExecutionOutputViewerProps {
executionProcess: ExecutionProcess;
@@ -15,16 +15,16 @@ const getExecutionProcessStatusDisplay = (
status: ExecutionProcessStatus
): { label: string; color: string } => {
switch (status) {
case "running":
return { label: "Running", color: "bg-blue-500" };
case "completed":
return { label: "Completed", color: "bg-green-500" };
case "failed":
return { label: "Failed", color: "bg-red-500" };
case "killed":
return { label: "Stopped", color: "bg-gray-500" };
case 'running':
return { label: 'Running', color: 'bg-blue-500' };
case 'completed':
return { label: 'Completed', color: 'bg-green-500' };
case 'failed':
return { label: 'Failed', color: 'bg-red-500' };
case 'killed':
return { label: 'Stopped', color: 'bg-gray-500' };
default:
return { label: "Unknown", color: "bg-gray-400" };
return { label: 'Unknown', color: 'bg-gray-400' };
}
};
@@ -32,10 +32,10 @@ export function ExecutionOutputViewer({
executionProcess,
executor,
}: ExecutionOutputViewerProps) {
const [viewMode, setViewMode] = useState<"conversation" | "raw">("raw");
const [viewMode, setViewMode] = useState<'conversation' | 'raw'>('raw');
const isAmpExecutor = executor === "amp";
const isClaudeExecutor = executor === "claude";
const isAmpExecutor = executor === 'amp';
const isClaudeExecutor = executor === 'claude';
const hasStdout = !!executionProcess.stdout;
const hasStderr = !!executionProcess.stderr;
@@ -47,7 +47,7 @@ export function ExecutionOutputViewer({
try {
const lines = executionProcess.stdout
.split("\n")
.split('\n')
.filter((line) => line.trim());
if (lines.length === 0) return { isValidJsonl: false, jsonlFormat: null };
@@ -71,10 +71,15 @@ export function ExecutionOutputViewer({
for (const line of testLines) {
try {
const parsed = JSON.parse(line);
if (parsed.type === "messages" || parsed.type === "token-usage") {
if (parsed.type === 'messages' || parsed.type === 'token-usage') {
hasAmpFormat = true;
}
if (parsed.type === "user" || parsed.type === "assistant" || parsed.type === "system" || parsed.type === "result") {
if (
parsed.type === 'user' ||
parsed.type === 'assistant' ||
parsed.type === 'system' ||
parsed.type === 'result'
) {
hasClaudeFormat = true;
}
} catch {
@@ -84,7 +89,11 @@ export function ExecutionOutputViewer({
return {
isValidJsonl: true,
jsonlFormat: hasAmpFormat ? "amp" : hasClaudeFormat ? "claude" : "unknown"
jsonlFormat: hasAmpFormat
? 'amp'
: hasClaudeFormat
? 'claude'
: 'unknown',
};
} catch {
return { isValidJsonl: false, jsonlFormat: null };
@@ -94,7 +103,7 @@ export function ExecutionOutputViewer({
// Set initial view mode based on JSONL detection
useEffect(() => {
if (isValidJsonl) {
setViewMode("conversation");
setViewMode('conversation');
}
}, [isValidJsonl]);
@@ -110,7 +119,9 @@ export function ExecutionOutputViewer({
);
}
const statusDisplay = getExecutionProcessStatusDisplay(executionProcess.status);
const statusDisplay = getExecutionProcessStatusDisplay(
executionProcess.status
);
return (
<Card className="">
@@ -120,11 +131,17 @@ export function ExecutionOutputViewer({
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs capitalize">
{executionProcess.process_type.replace(/([A-Z])/g, ' $1').toLowerCase()}
{executionProcess.process_type
.replace(/([A-Z])/g, ' $1')
.toLowerCase()}
</Badge>
<div className="flex items-center gap-1">
<div className={`h-2 w-2 rounded-full ${statusDisplay.color}`} />
<span className="text-xs text-muted-foreground">{statusDisplay.label}</span>
<div
className={`h-2 w-2 rounded-full ${statusDisplay.color}`}
/>
<span className="text-xs text-muted-foreground">
{statusDisplay.label}
</span>
</div>
{executor && (
<Badge variant="secondary" className="text-xs">
@@ -146,18 +163,18 @@ export function ExecutionOutputViewer({
</div>
<div className="flex items-center gap-1">
<Button
variant={viewMode === "conversation" ? "default" : "ghost"}
variant={viewMode === 'conversation' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode("conversation")}
onClick={() => setViewMode('conversation')}
className="h-7 px-2 text-xs"
>
<MessageSquare className="h-3 w-3 mr-1" />
Conversation
</Button>
<Button
variant={viewMode === "raw" ? "default" : "ghost"}
variant={viewMode === 'raw' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode("raw")}
onClick={() => setViewMode('raw')}
className="h-7 px-2 text-xs"
>
<FileText className="h-3 w-3 mr-1" />
@@ -170,9 +187,9 @@ export function ExecutionOutputViewer({
{/* Output content */}
{hasStdout && (
<div>
{isValidJsonl && viewMode === "conversation" ? (
{isValidJsonl && viewMode === 'conversation' ? (
<ConversationViewer
jsonlOutput={executionProcess.stdout || ""}
jsonlOutput={executionProcess.stdout || ''}
/>
) : (
<div>

View File

@@ -1,26 +1,39 @@
import { Button } from '@/components/ui/button'
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { KanbanCard } from '@/components/ui/shadcn-io/kanban'
import { MoreHorizontal, Trash2, Edit, Loader2, CheckCircle } from 'lucide-react'
import type { TaskWithAttemptStatus } from 'shared/types'
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { KanbanCard } from '@/components/ui/shadcn-io/kanban';
import {
MoreHorizontal,
Trash2,
Edit,
Loader2,
CheckCircle,
} from 'lucide-react';
import type { TaskWithAttemptStatus } from 'shared/types';
type Task = TaskWithAttemptStatus
type Task = TaskWithAttemptStatus;
interface TaskCardProps {
task: Task
index: number
status: string
onEdit: (task: Task) => void
onDelete: (taskId: string) => void
onViewDetails: (task: Task) => void
task: Task;
index: number;
status: string;
onEdit: (task: Task) => void;
onDelete: (taskId: string) => void;
onViewDetails: (task: Task) => void;
}
export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails }: TaskCardProps) {
export function TaskCard({
task,
index,
status,
onEdit,
onDelete,
onViewDetails,
}: TaskCardProps) {
return (
<KanbanCard
key={task.id}
@@ -33,9 +46,7 @@ export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="flex-1 pr-2">
<h4 className="font-medium text-sm break-words">
{task.title}
</h4>
<h4 className="font-medium text-sm break-words">{task.title}</h4>
</div>
<div className="flex items-center space-x-1">
{/* In Progress Spinner */}
@@ -47,16 +58,16 @@ export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails
<CheckCircle className="h-3 w-3 text-green-500" />
)}
{/* Actions Menu */}
<div
<div
onPointerDown={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-muted"
>
<MoreHorizontal className="h-3 w-3" />
@@ -67,7 +78,7 @@ export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails
<Edit className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
<DropdownMenuItem
onClick={() => onDelete(task.id)}
className="text-destructive"
>
@@ -82,13 +93,13 @@ export function TaskCard({ task, index, status, onEdit, onDelete, onViewDetails
{task.description && (
<div>
<p className="text-xs text-muted-foreground break-words">
{task.description.length > 130
? `${task.description.substring(0, 130)}...`
{task.description.length > 130
? `${task.description.substring(0, 130)}...`
: task.description}
</p>
</div>
)}
</div>
</KanbanCard>
)
);
}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
import { Link } from "react-router-dom";
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
X,
History,
@@ -16,34 +16,34 @@ import {
GitCompare,
ExternalLink,
Code,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Label } from "@/components/ui/label";
import { Chip } from "@/components/ui/chip";
import { FileSearchTextarea } from "@/components/ui/file-search-textarea";
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Label } from '@/components/ui/label';
import { Chip } from '@/components/ui/chip';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { ExecutionOutputViewer } from "./ExecutionOutputViewer";
import { EditorSelectionDialog } from "./EditorSelectionDialog";
} from '@/components/ui/tooltip';
import { ExecutionOutputViewer } from './ExecutionOutputViewer';
import { EditorSelectionDialog } from './EditorSelectionDialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
} from '@/components/ui/dropdown-menu';
import { makeRequest } from "@/lib/api";
import { makeRequest } from '@/lib/api';
import {
getTaskPanelClasses,
getBackdropClasses,
} from "@/lib/responsive-config";
import { useConfig } from "@/components/config-provider";
} from '@/lib/responsive-config';
import { useConfig } from '@/components/config-provider';
import type {
TaskStatus,
TaskAttempt,
@@ -56,7 +56,7 @@ import type {
ExecutionProcessSummary,
EditorType,
Project,
} from "shared/types";
} from 'shared/types';
interface TaskDetailsPanelProps {
task: TaskWithAttemptStatus | null;
@@ -70,27 +70,27 @@ interface TaskDetailsPanelProps {
}
const statusLabels: Record<TaskStatus, string> = {
todo: "To Do",
inprogress: "In Progress",
inreview: "In Review",
done: "Done",
cancelled: "Cancelled",
todo: 'To Do',
inprogress: 'In Progress',
inreview: 'In Review',
done: 'Done',
cancelled: 'Cancelled',
};
const getTaskStatusDotColor = (status: TaskStatus): string => {
switch (status) {
case "todo":
return "bg-gray-400";
case "inprogress":
return "bg-blue-500";
case "inreview":
return "bg-yellow-500";
case "done":
return "bg-green-500";
case "cancelled":
return "bg-red-500";
case 'todo':
return 'bg-gray-400';
case 'inprogress':
return 'bg-blue-500';
case 'inreview':
return 'bg-yellow-500';
case 'done':
return 'bg-green-500';
case 'cancelled':
return 'bg-red-500';
default:
return "bg-gray-400";
return 'bg-gray-400';
}
};
@@ -98,40 +98,40 @@ const getAttemptStatusDisplay = (
status: TaskAttemptStatus
): { label: string; dotColor: string } => {
switch (status) {
case "setuprunning":
case 'setuprunning':
return {
label: "Setup Running",
dotColor: "bg-blue-500",
label: 'Setup Running',
dotColor: 'bg-blue-500',
};
case "setupcomplete":
case 'setupcomplete':
return {
label: "Setup Complete",
dotColor: "bg-green-500",
label: 'Setup Complete',
dotColor: 'bg-green-500',
};
case "setupfailed":
case 'setupfailed':
return {
label: "Setup Failed",
dotColor: "bg-red-500",
label: 'Setup Failed',
dotColor: 'bg-red-500',
};
case "executorrunning":
case 'executorrunning':
return {
label: "Executor Running",
dotColor: "bg-blue-500",
label: 'Executor Running',
dotColor: 'bg-blue-500',
};
case "executorcomplete":
case 'executorcomplete':
return {
label: "Executor Complete",
dotColor: "bg-green-500",
label: 'Executor Complete',
dotColor: 'bg-green-500',
};
case "executorfailed":
case 'executorfailed':
return {
label: "Executor Failed",
dotColor: "bg-red-500",
label: 'Executor Failed',
dotColor: 'bg-red-500',
};
default:
return {
label: "Unknown",
dotColor: "bg-gray-400",
label: 'Unknown',
dotColor: 'bg-gray-400',
};
}
};
@@ -162,13 +162,13 @@ export function TaskDetailsPanel({
});
const [loading, setLoading] = useState(false);
const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false);
const [selectedExecutor, setSelectedExecutor] = useState<string>("claude");
const [selectedExecutor, setSelectedExecutor] = useState<string>('claude');
const [isStopping, setIsStopping] = useState(false);
const [expandedOutputs, setExpandedOutputs] = useState<Set<string>>(
new Set()
);
const [showEditorDialog, setShowEditorDialog] = useState(false);
const [followUpMessage, setFollowUpMessage] = useState("");
const [followUpMessage, setFollowUpMessage] = useState('');
const [isSendingFollowUp, setIsSendingFollowUp] = useState(false);
const [followUpError, setFollowUpError] = useState<string | null>(null);
const [isStartingDevServer, setIsStartingDevServer] = useState(false);
@@ -185,7 +185,7 @@ export function TaskDetailsPanel({
const runningDevServer = useMemo(() => {
return attemptData.processes.find(
(process) =>
process.process_type === "devserver" && process.status === "running"
process.process_type === 'devserver' && process.status === 'running'
);
}, [attemptData.processes]);
@@ -194,22 +194,22 @@ export function TaskDetailsPanel({
if (!isOpen || isDialogOpen) return; // Don't handle ESC if dialog is open
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose();
}
};
document.addEventListener("keydown", handleKeyDown, true); // Use capture phase
return () => document.removeEventListener("keydown", handleKeyDown, true);
document.addEventListener('keydown', handleKeyDown, true); // Use capture phase
return () => document.removeEventListener('keydown', handleKeyDown, true);
}, [isOpen, onClose, isDialogOpen]);
// Available executors
const availableExecutors = [
{ id: "echo", name: "Echo" },
{ id: "claude", name: "Claude" },
{ id: "amp", name: "Amp" },
{ id: 'echo', name: 'Echo' },
{ id: 'claude', name: 'Claude' },
{ id: 'amp', name: 'Amp' },
];
// Check if any execution process is currently running
@@ -236,8 +236,8 @@ export function TaskDetailsPanel({
// Check if any execution process has a running status as its latest activity
return Array.from(latestActivitiesByProcess.values()).some(
(activity) =>
activity.status === "setuprunning" ||
activity.status === "executorrunning"
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
);
}, [selectedAttempt, attemptData.activities, isStopping]);
@@ -254,7 +254,7 @@ export function TaskDetailsPanel({
// Need at least one completed coding agent execution
const codingAgentActivities = attemptData.activities.filter(
(activity) => activity.status === "executorcomplete"
(activity) => activity.status === 'executorcomplete'
);
return codingAgentActivities.length > 0;
@@ -293,7 +293,7 @@ export function TaskDetailsPanel({
}
}
} catch (err) {
console.error("Failed to fetch dev server details:", err);
console.error('Failed to fetch dev server details:', err);
}
};
@@ -319,14 +319,14 @@ export function TaskDetailsPanel({
// Memoize processed dev server logs to prevent stuttering
const processedDevServerLogs = useMemo(() => {
if (!devServerDetails) return "No output yet...";
const stdout = devServerDetails.stdout || "";
const stderr = devServerDetails.stderr || "";
const allOutput = stdout + (stderr ? "\n" + stderr : "");
const lines = allOutput.split("\n").filter((line) => line.trim());
if (!devServerDetails) return 'No output yet...';
const stdout = devServerDetails.stdout || '';
const stderr = devServerDetails.stderr || '';
const allOutput = stdout + (stderr ? '\n' + stderr : '');
const lines = allOutput.split('\n').filter((line) => line.trim());
const lastLines = lines.slice(-10);
return lastLines.length > 0 ? lastLines.join("\n") : "No output yet...";
return lastLines.length > 0 ? lastLines.join('\n') : 'No output yet...';
}, [devServerDetails?.stdout, devServerDetails?.stderr]);
// Set default executor from config
@@ -400,7 +400,7 @@ export function TaskDetailsPanel({
}
}
} catch (err) {
console.error("Failed to fetch task attempts:", err);
console.error('Failed to fetch task attempts:', err);
} finally {
setLoading(false);
}
@@ -437,8 +437,8 @@ export function TaskDetailsPanel({
// Find running activities that need detailed execution info
const runningActivities = activitiesResult.data.filter(
(activity) =>
activity.status === "setuprunning" ||
activity.status === "executorrunning"
activity.status === 'setuprunning' ||
activity.status === 'executorrunning'
);
// Fetch detailed execution info for running processes
@@ -473,7 +473,7 @@ export function TaskDetailsPanel({
}
}
} catch (err) {
console.error("Failed to fetch attempt data:", err);
console.error('Failed to fetch attempt data:', err);
}
};
@@ -492,19 +492,19 @@ export function TaskDetailsPanel({
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/open-editor`,
{
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify(editorType ? { editor_type: editorType } : null),
}
);
if (!response.ok) {
throw new Error("Failed to open editor");
throw new Error('Failed to open editor');
}
} catch (err) {
console.error("Failed to open editor:", err);
console.error('Failed to open editor:', err);
// Show editor selection dialog if editor failed to open
if (!editorType) {
setShowEditorDialog(true);
@@ -521,27 +521,27 @@ export function TaskDetailsPanel({
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/start-dev-server`,
{
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error("Failed to start dev server");
throw new Error('Failed to start dev server');
}
const data: ApiResponse<null> = await response.json();
if (!data.success) {
throw new Error(data.message || "Failed to start dev server");
throw new Error(data.message || 'Failed to start dev server');
}
// Refresh activities to show the new dev server process
fetchAttemptData(selectedAttempt.id);
} catch (err) {
console.error("Failed to start dev server:", err);
console.error('Failed to start dev server:', err);
} finally {
setIsStartingDevServer(false);
}
@@ -556,21 +556,21 @@ export function TaskDetailsPanel({
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/execution-processes/${runningDevServer.id}/stop`,
{
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
}
);
if (!response.ok) {
throw new Error("Failed to stop dev server");
throw new Error('Failed to stop dev server');
}
// Refresh activities to show the stopped dev server
fetchAttemptData(selectedAttempt.id);
} catch (err) {
console.error("Failed to stop dev server:", err);
console.error('Failed to stop dev server:', err);
} finally {
setIsStartingDevServer(false);
}
@@ -583,9 +583,9 @@ export function TaskDetailsPanel({
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts`,
{
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify({
executor: executor || selectedExecutor,
@@ -598,7 +598,7 @@ export function TaskDetailsPanel({
fetchTaskAttempts();
}
} catch (err) {
console.error("Failed to create new attempt:", err);
console.error('Failed to create new attempt:', err);
}
};
@@ -610,9 +610,9 @@ export function TaskDetailsPanel({
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/stop`,
{
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
}
);
@@ -626,7 +626,7 @@ export function TaskDetailsPanel({
}, 1000);
}
} catch (err) {
console.error("Failed to stop executions:", err);
console.error('Failed to stop executions:', err);
} finally {
setIsStopping(false);
}
@@ -653,9 +653,9 @@ export function TaskDetailsPanel({
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${task.id}/attempts/${selectedAttempt.id}/follow-up`,
{
method: "POST",
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: followUpMessage.trim(),
@@ -665,7 +665,7 @@ export function TaskDetailsPanel({
if (response.ok) {
// Clear the message
setFollowUpMessage("");
setFollowUpMessage('');
// Refresh activities to show the new follow-up execution
fetchAttemptData(selectedAttempt.id);
} else {
@@ -679,7 +679,7 @@ export function TaskDetailsPanel({
} catch (err) {
setFollowUpError(
`Failed to send follow-up: ${
err instanceof Error ? err.message : "Unknown error"
err instanceof Error ? err.message : 'Unknown error'
}`
);
} finally {
@@ -754,7 +754,11 @@ export function TaskDetailsPanel({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={onClose}>
<Button
variant="ghost"
size="icon"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</TooltipTrigger>
@@ -775,8 +779,8 @@ export function TaskDetailsPanel({
className={`text-sm whitespace-pre-wrap ${
!isDescriptionExpanded &&
task.description.length > 200
? "line-clamp-6"
: ""
? 'line-clamp-6'
: ''
}`}
>
{task.description}
@@ -822,14 +826,18 @@ export function TaskDetailsPanel({
<>
<div className="text-sm">
<span className="font-medium">
{new Date(selectedAttempt.created_at).toLocaleDateString()}{" "}
{new Date(selectedAttempt.created_at).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
{new Date(
selectedAttempt.created_at
).toLocaleDateString()}{' '}
{new Date(
selectedAttempt.created_at
).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</span>
<span className="text-muted-foreground ml-2">
({selectedAttempt.executor || "executor"})
({selectedAttempt.executor || 'executor'})
</span>
</div>
<div className="h-4 w-px bg-border" />
@@ -865,24 +873,26 @@ export function TaskDetailsPanel({
{taskAttempts.map((attempt) => (
<DropdownMenuItem
key={attempt.id}
onClick={() => handleAttemptChange(attempt.id)}
onClick={() =>
handleAttemptChange(attempt.id)
}
className={
selectedAttempt?.id === attempt.id
? "bg-accent"
: ""
? 'bg-accent'
: ''
}
>
<div className="flex flex-col w-full">
<span className="font-medium text-sm">
{new Date(
attempt.created_at
).toLocaleDateString()}{" "}
).toLocaleDateString()}{' '}
{new Date(
attempt.created_at
).toLocaleTimeString()}
</span>
<span className="text-xs text-muted-foreground">
{attempt.executor || "executor"}
{attempt.executor || 'executor'}
</span>
</div>
</DropdownMenuItem>
@@ -890,7 +900,7 @@ export function TaskDetailsPanel({
</DropdownMenuContent>
</DropdownMenu>
)}
{(isAttemptRunning || isStopping) ? (
{isAttemptRunning || isStopping ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -902,11 +912,15 @@ export function TaskDetailsPanel({
className="text-red-600 hover:text-red-700 hover:bg-red-50 disabled:opacity-50"
>
<StopCircle className="h-4 w-4 mr-2" />
{isStopping ? "Stopping..." : "Stop Attempt"}
{isStopping ? 'Stopping...' : 'Stop Attempt'}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{isStopping ? "Stopping execution..." : "Stop execution"}</p>
<p>
{isStopping
? 'Stopping execution...'
: 'Stop execution'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -921,11 +935,17 @@ export function TaskDetailsPanel({
onClick={() => createNewAttempt()}
className="rounded-r-none border-r-0"
>
{selectedAttempt ? "New Attempt" : "Start Attempt"}
{selectedAttempt
? 'New Attempt'
: 'Start Attempt'}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{selectedAttempt ? "Create new attempt with current executor" : "Start new attempt with current executor"}</p>
<p>
{selectedAttempt
? 'Create new attempt with current executor'
: 'Start new attempt with current executor'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -952,16 +972,18 @@ export function TaskDetailsPanel({
{availableExecutors.map((executor) => (
<DropdownMenuItem
key={executor.id}
onClick={() => setSelectedExecutor(executor.id)}
onClick={() =>
setSelectedExecutor(executor.id)
}
className={
selectedExecutor === executor.id
? "bg-accent"
: ""
? 'bg-accent'
: ''
}
>
{executor.name}
{config?.executor.type === executor.id &&
" (Default)"}
' (Default)'}
</DropdownMenuItem>
))}
</DropdownMenuContent>
@@ -973,7 +995,7 @@ export function TaskDetailsPanel({
{selectedAttempt && (
<>
<div className="h-4 w-px bg-border" />
{/* Dev Server Control Group */}
<div className="flex items-center gap-1">
<TooltipProvider>
@@ -981,14 +1003,22 @@ export function TaskDetailsPanel({
<TooltipTrigger asChild>
<span
className={
!project?.dev_script ? "cursor-not-allowed" : ""
!project?.dev_script
? 'cursor-not-allowed'
: ''
}
onMouseEnter={() =>
setIsHoveringDevServer(true)
}
onMouseLeave={() =>
setIsHoveringDevServer(false)
}
onMouseEnter={() => setIsHoveringDevServer(true)}
onMouseLeave={() => setIsHoveringDevServer(false)}
>
<Button
variant={
runningDevServer ? "destructive" : "outline"
runningDevServer
? 'destructive'
: 'outline'
}
size="sm"
onClick={
@@ -997,7 +1027,8 @@ export function TaskDetailsPanel({
: startDevServer
}
disabled={
isStartingDevServer || !project?.dev_script
isStartingDevServer ||
!project?.dev_script
}
>
{runningDevServer ? (
@@ -1009,7 +1040,9 @@ export function TaskDetailsPanel({
</span>
</TooltipTrigger>
<TooltipContent
className={runningDevServer ? "max-w-2xl p-4" : ""}
className={
runningDevServer ? 'max-w-2xl p-4' : ''
}
side="top"
align="center"
avoidCollisions={true}
@@ -1025,7 +1058,7 @@ export function TaskDetailsPanel({
Dev Server Logs (Last 10 lines):
</p>
<pre className="text-xs bg-muted p-2 rounded max-h-64 overflow-y-auto whitespace-pre-wrap">
{processedDevServerLogs}
{processedDevServerLogs}
</pre>
</div>
) : runningDevServer ? (
@@ -1122,9 +1155,9 @@ export function TaskDetailsPanel({
{new Date(
selectedAttempt.created_at
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
</div>
@@ -1155,33 +1188,34 @@ export function TaskDetailsPanel({
{new Date(
activity.created_at
).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</div>
</div>
{/* Show prompt for coding agent executions */}
{activity.prompt && activity.status === "executorrunning" && (
<div className="mt-2 mb-4">
<div className="p-3 bg-blue-50 dark:bg-blue-950/30 rounded-md border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2 mb-2">
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5" />
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
Prompt
</span>
</div>
<pre className="text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
{activity.prompt}
</pre>
</div>
</div>
)}
{activity.prompt &&
activity.status === 'executorrunning' && (
<div className="mt-2 mb-4">
<div className="p-3 bg-blue-50 dark:bg-blue-950/30 rounded-md border border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2 mb-2">
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5" />
<span className="text-sm font-medium text-blue-900 dark:text-blue-100">
Prompt
</span>
</div>
<pre className="text-sm text-blue-800 dark:text-blue-200 whitespace-pre-wrap break-words">
{activity.prompt}
</pre>
</div>
</div>
)}
{/* Show stdio output for running processes */}
{(activity.status === "setuprunning" ||
activity.status === "executorrunning") &&
{/* Show stdio output for running processes */}
{(activity.status === 'setuprunning' ||
activity.status === 'executorrunning') &&
attemptData.runningProcessDetails[
activity.execution_process_id
] && (
@@ -1191,8 +1225,8 @@ export function TaskDetailsPanel({
expandedOutputs.has(
activity.execution_process_id
)
? ""
: "max-h-64 overflow-hidden flex flex-col justify-end"
? ''
: 'max-h-64 overflow-hidden flex flex-col justify-end'
}`}
>
<ExecutionOutputViewer
@@ -1265,7 +1299,7 @@ export function TaskDetailsPanel({
if (followUpError) setFollowUpError(null);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
if (
canSendFollowUp &&
@@ -1305,9 +1339,9 @@ export function TaskDetailsPanel({
<p className="text-xs text-muted-foreground">
{!canSendFollowUp
? isAttemptRunning
? "Wait for current execution to complete before asking follow-up questions"
: "Complete at least one coding agent execution to enable follow-up questions"
: "Continue the conversation with the most recent executor session"}
? 'Wait for current execution to complete before asking follow-up questions'
: 'Complete at least one coding agent execution to enable follow-up questions'
: 'Continue the conversation with the most recent executor session'}
</p>
</div>
</div>

View File

@@ -1,165 +1,190 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { FileSearchTextarea } from '@/components/ui/file-search-textarea'
import { Label } from '@/components/ui/label'
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { FileSearchTextarea } from '@/components/ui/file-search-textarea';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { useConfig } from '@/components/config-provider'
import type { TaskStatus, ExecutorConfig } from 'shared/types'
SelectValue,
} from '@/components/ui/select';
import { useConfig } from '@/components/config-provider';
import type { TaskStatus, ExecutorConfig } from 'shared/types';
interface Task {
id: string
project_id: string
title: string
description: string | null
status: TaskStatus
created_at: string
updated_at: string
id: string;
project_id: string;
title: string;
description: string | null;
status: TaskStatus;
created_at: string;
updated_at: string;
}
interface TaskFormDialogProps {
isOpen: boolean
onOpenChange: (open: boolean) => void
task?: Task | null // Optional for create mode
projectId?: string // For file search functionality
onCreateTask?: (title: string, description: string) => Promise<void>
onCreateAndStartTask?: (title: string, description: string, executor?: ExecutorConfig) => Promise<void>
onUpdateTask?: (title: string, description: string, status: TaskStatus) => Promise<void>
isOpen: boolean;
onOpenChange: (open: boolean) => void;
task?: Task | null; // Optional for create mode
projectId?: string; // For file search functionality
onCreateTask?: (title: string, description: string) => Promise<void>;
onCreateAndStartTask?: (
title: string,
description: string,
executor?: ExecutorConfig
) => Promise<void>;
onUpdateTask?: (
title: string,
description: string,
status: TaskStatus
) => Promise<void>;
}
export function TaskFormDialog({
isOpen,
onOpenChange,
task,
export function TaskFormDialog({
isOpen,
onOpenChange,
task,
projectId,
onCreateTask,
onCreateTask,
onCreateAndStartTask,
onUpdateTask
onUpdateTask,
}: TaskFormDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [status, setStatus] = useState<TaskStatus>('todo')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmittingAndStart, setIsSubmittingAndStart] = useState(false)
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [status, setStatus] = useState<TaskStatus>('todo');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmittingAndStart, setIsSubmittingAndStart] = useState(false);
const { config } = useConfig()
const isEditMode = Boolean(task)
const { config } = useConfig();
const isEditMode = Boolean(task);
useEffect(() => {
if (task) {
// Edit mode - populate with existing task data
setTitle(task.title)
setDescription(task.description || '')
setStatus(task.status)
setTitle(task.title);
setDescription(task.description || '');
setStatus(task.status);
} else {
// Create mode - reset to defaults
setTitle('')
setDescription('')
setStatus('todo')
setTitle('');
setDescription('');
setStatus('todo');
}
}, [task, isOpen])
}, [task, isOpen]);
const handleSubmit = async () => {
if (!title.trim()) return
setIsSubmitting(true)
if (!title.trim()) return;
setIsSubmitting(true);
try {
if (isEditMode && onUpdateTask) {
await onUpdateTask(title, description, status)
await onUpdateTask(title, description, status);
} else if (!isEditMode && onCreateTask) {
await onCreateTask(title, description)
await onCreateTask(title, description);
}
// Reset form on successful creation
if (!isEditMode) {
setTitle('')
setDescription('')
setStatus('todo')
setTitle('');
setDescription('');
setStatus('todo');
}
onOpenChange(false)
onOpenChange(false);
} finally {
setIsSubmitting(false)
setIsSubmitting(false);
}
}
};
const handleCreateAndStart = async () => {
if (!title.trim()) return
setIsSubmittingAndStart(true)
if (!title.trim()) return;
setIsSubmittingAndStart(true);
try {
if (!isEditMode && onCreateAndStartTask) {
await onCreateAndStartTask(title, description, config?.executor)
await onCreateAndStartTask(title, description, config?.executor);
}
// Reset form on successful creation
setTitle('')
setDescription('')
setStatus('todo')
onOpenChange(false)
setTitle('');
setDescription('');
setStatus('todo');
onOpenChange(false);
} finally {
setIsSubmittingAndStart(false)
setIsSubmittingAndStart(false);
}
}
};
const handleCancel = () => {
// Reset form state when canceling
if (task) {
setTitle(task.title)
setDescription(task.description || '')
setStatus(task.status)
setTitle(task.title);
setDescription(task.description || '');
setStatus(task.status);
} else {
setTitle('')
setDescription('')
setStatus('todo')
setTitle('');
setDescription('');
setStatus('todo');
}
onOpenChange(false)
}
onOpenChange(false);
};
// Handle keyboard shortcuts
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// ESC to close dialog (prevent it from reaching TaskDetailsPanel)
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
handleCancel()
return
event.preventDefault();
event.stopPropagation();
handleCancel();
return;
}
// Command/Ctrl + Enter to Create & Start (only in create mode)
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
if (!isEditMode && onCreateAndStartTask && title.trim() && !isSubmitting && !isSubmittingAndStart) {
event.preventDefault()
handleCreateAndStart()
if (
!isEditMode &&
onCreateAndStartTask &&
title.trim() &&
!isSubmitting &&
!isSubmittingAndStart
) {
event.preventDefault();
handleCreateAndStart();
}
}
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown, true) // Use capture phase to get priority
return () => document.removeEventListener('keydown', handleKeyDown, true)
document.addEventListener('keydown', handleKeyDown, true); // Use capture phase to get priority
return () => document.removeEventListener('keydown', handleKeyDown, true);
}
}, [isOpen, isEditMode, onCreateAndStartTask, title, isSubmitting, isSubmittingAndStart, handleCreateAndStart, handleCancel])
}, [
isOpen,
isEditMode,
onCreateAndStartTask,
title,
isSubmitting,
isSubmittingAndStart,
handleCreateAndStart,
handleCancel,
]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isEditMode ? 'Edit Task' : 'Create New Task'}</DialogTitle>
<DialogTitle>
{isEditMode ? 'Edit Task' : 'Create New Task'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
@@ -172,7 +197,7 @@ export function TaskFormDialog({
disabled={isSubmitting || isSubmittingAndStart}
/>
</div>
<div>
<Label htmlFor="task-description">Description</Label>
<FileSearchTextarea
@@ -184,7 +209,7 @@ export function TaskFormDialog({
projectId={projectId}
/>
</div>
{isEditMode && (
<div>
<Label htmlFor="task-status">Status</Label>
@@ -206,7 +231,7 @@ export function TaskFormDialog({
</Select>
</div>
)}
<div className="flex justify-end space-x-2">
<Button
variant="outline"
@@ -216,7 +241,7 @@ export function TaskFormDialog({
Cancel
</Button>
{isEditMode ? (
<Button
<Button
onClick={handleSubmit}
disabled={isSubmitting || !title.trim()}
>
@@ -224,19 +249,25 @@ export function TaskFormDialog({
</Button>
) : (
<>
<Button
<Button
variant="outline"
onClick={handleSubmit}
disabled={isSubmitting || isSubmittingAndStart || !title.trim()}
disabled={
isSubmitting || isSubmittingAndStart || !title.trim()
}
>
{isSubmitting ? 'Creating...' : 'Create Task'}
</Button>
{onCreateAndStartTask && (
<Button
<Button
onClick={handleCreateAndStart}
disabled={isSubmitting || isSubmittingAndStart || !title.trim()}
disabled={
isSubmitting || isSubmittingAndStart || !title.trim()
}
>
{isSubmittingAndStart ? 'Creating & Starting...' : 'Create & Start'}
{isSubmittingAndStart
? 'Creating & Starting...'
: 'Create & Start'}
</Button>
)}
</>
@@ -245,5 +276,5 @@ export function TaskFormDialog({
</div>
</DialogContent>
</Dialog>
)
);
}

View File

@@ -3,61 +3,73 @@ import {
KanbanBoard,
KanbanHeader,
KanbanCards,
type DragEndEvent
} from '@/components/ui/shadcn-io/kanban'
import { TaskCard } from './TaskCard'
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types'
type DragEndEvent,
} from '@/components/ui/shadcn-io/kanban';
import { TaskCard } from './TaskCard';
import type { TaskStatus, TaskWithAttemptStatus } from 'shared/types';
type Task = TaskWithAttemptStatus
type Task = TaskWithAttemptStatus;
interface TaskKanbanBoardProps {
tasks: Task[]
onDragEnd: (event: DragEndEvent) => void
onEditTask: (task: Task) => void
onDeleteTask: (taskId: string) => void
onViewTaskDetails: (task: Task) => void
tasks: Task[];
onDragEnd: (event: DragEndEvent) => void;
onEditTask: (task: Task) => void;
onDeleteTask: (taskId: string) => void;
onViewTaskDetails: (task: Task) => void;
}
const allTaskStatuses: TaskStatus[] = ['todo', 'inprogress', 'inreview', 'done', 'cancelled']
const allTaskStatuses: TaskStatus[] = [
'todo',
'inprogress',
'inreview',
'done',
'cancelled',
];
const statusLabels: Record<TaskStatus, string> = {
todo: 'To Do',
inprogress: 'In Progress',
inreview: 'In Review',
done: 'Done',
cancelled: 'Cancelled'
}
cancelled: 'Cancelled',
};
const statusBoardColors: Record<TaskStatus, string> = {
todo: 'hsl(var(--neutral))',
inprogress: 'hsl(var(--info))',
inreview: 'hsl(var(--warning))',
done: 'hsl(var(--success))',
cancelled: 'hsl(var(--destructive))'
}
cancelled: 'hsl(var(--destructive))',
};
export function TaskKanbanBoard({ tasks, onDragEnd, onEditTask, onDeleteTask, onViewTaskDetails }: TaskKanbanBoardProps) {
export function TaskKanbanBoard({
tasks,
onDragEnd,
onEditTask,
onDeleteTask,
onViewTaskDetails,
}: TaskKanbanBoardProps) {
const groupTasksByStatus = () => {
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>
const groups: Record<TaskStatus, Task[]> = {} as Record<TaskStatus, Task[]>;
// Initialize groups for all possible statuses
allTaskStatuses.forEach(status => {
groups[status] = []
})
tasks.forEach(task => {
allTaskStatuses.forEach((status) => {
groups[status] = [];
});
tasks.forEach((task) => {
// Convert old capitalized status to lowercase if needed
const normalizedStatus = task.status.toLowerCase() as TaskStatus
const normalizedStatus = task.status.toLowerCase() as TaskStatus;
if (groups[normalizedStatus]) {
groups[normalizedStatus].push(task)
groups[normalizedStatus].push(task);
} else {
// Default to todo if status doesn't match any expected value
groups['todo'].push(task)
groups['todo'].push(task);
}
})
return groups
}
});
return groups;
};
return (
<KanbanProvider onDragEnd={onDragEnd}>
@@ -83,5 +95,5 @@ export function TaskKanbanBoard({ tasks, onDragEnd, onEditTask, onDeleteTask, on
</KanbanBoard>
))}
</KanbanProvider>
)
);
}

View File

@@ -1,3 +1,3 @@
export { TaskFormDialog } from './TaskFormDialog'
export { TaskCard } from './TaskCard'
export { TaskKanbanBoard } from './TaskKanbanBoard'
export { TaskFormDialog } from './TaskFormDialog';
export { TaskCard } from './TaskCard';
export { TaskKanbanBoard } from './TaskKanbanBoard';

View File

@@ -1,5 +1,5 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import type { ThemeMode } from "shared/types";
import React, { createContext, useContext, useEffect, useState } from 'react';
import type { ThemeMode } from 'shared/types';
type ThemeProviderProps = {
children: React.ReactNode;
@@ -12,13 +12,17 @@ type ThemeProviderState = {
};
const initialState: ThemeProviderState = {
theme: "system",
theme: 'system',
setTheme: () => null,
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({ children, initialTheme = "system", ...props }: ThemeProviderProps) {
export function ThemeProvider({
children,
initialTheme = 'system',
...props
}: ThemeProviderProps) {
const [theme, setThemeState] = useState<ThemeMode>(initialTheme);
// Update theme when initialTheme changes
@@ -29,13 +33,13 @@ export function ThemeProvider({ children, initialTheme = "system", ...props }: T
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
root.classList.remove('light', 'dark');
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
.matches
? "dark"
: "light";
? 'dark'
: 'light';
root.classList.add(systemTheme);
return;
@@ -64,7 +68,7 @@ export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
throw new Error('useTheme must be used within a ThemeProvider');
return context;
};
};

View File

@@ -1,12 +1,12 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useTheme } from "@/components/theme-provider";
} from '@/components/ui/dropdown-menu';
import { useTheme } from '@/components/theme-provider';
export function ThemeToggle() {
const { setTheme } = useTheme();
@@ -21,16 +21,16 @@ export function ThemeToggle() {
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<DropdownMenuItem onClick={() => setTheme('light')}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<DropdownMenuItem onClick={() => setTheme('dark')}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<DropdownMenuItem onClick={() => setTheme('system')}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
}

View File

@@ -1,23 +1,23 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: "bg-background text-foreground",
default: 'bg-background text-foreground',
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
const Alert = React.forwardRef<
HTMLDivElement,
@@ -29,8 +29,8 @@ const Alert = React.forwardRef<
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
@@ -38,11 +38,11 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
));
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
@@ -50,10 +50,10 @@ const AlertDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
className={cn('text-sm [&_p]:leading-relaxed', className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription }
export { Alert, AlertTitle, AlertDescription };

View File

@@ -1,27 +1,27 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: "default",
variant: 'default',
},
}
)
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
@@ -30,7 +30,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };

View File

@@ -1,56 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: 'default',
size: 'default',
},
}
)
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
);
}
)
Button.displayName = "Button"
);
Button.displayName = 'Button';
export { Button, buttonVariants }
export { Button, buttonVariants };

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Card = React.forwardRef<
HTMLDivElement,
@@ -9,13 +9,13 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
/>
))
Card.displayName = "Card"
));
Card.displayName = 'Card';
const CardHeader = React.forwardRef<
HTMLDivElement,
@@ -23,11 +23,11 @@ const CardHeader = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
));
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<
HTMLDivElement,
@@ -36,13 +36,13 @@ const CardTitle = React.forwardRef<
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
));
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<
HTMLDivElement,
@@ -50,19 +50,19 @@ const CardDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
));
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
));
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<
HTMLDivElement,
@@ -70,10 +70,17 @@ const CardFooter = React.forwardRef<
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
));
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
import * as React from 'react';
import { Check } from 'lucide-react';
import { cn } from '@/lib/utils';
interface CheckboxProps {
id?: string;
@@ -11,7 +11,10 @@ interface CheckboxProps {
}
const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
({ className, checked = false, onCheckedChange, disabled, ...props }, ref) => {
(
{ className, checked = false, onCheckedChange, disabled, ...props },
ref
) => {
return (
<button
type="button"
@@ -19,8 +22,8 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
aria-checked={checked}
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
checked && "bg-primary text-primary-foreground",
'peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
checked && 'bg-primary text-primary-foreground',
className
)}
disabled={disabled}
@@ -33,9 +36,9 @@ const Checkbox = React.forwardRef<HTMLButtonElement, CheckboxProps>(
</div>
)}
</button>
)
);
}
)
Checkbox.displayName = "Checkbox"
);
Checkbox.displayName = 'Checkbox';
export { Checkbox }
export { Checkbox };

View File

@@ -1,4 +1,4 @@
import { cn } from "@/lib/utils";
import { cn } from '@/lib/utils';
export interface ChipProps {
children: React.ReactNode;
@@ -6,17 +6,19 @@ export interface ChipProps {
className?: string;
}
export function Chip({ children, dotColor = "bg-gray-400", className }: ChipProps) {
export function Chip({
children,
dotColor = 'bg-gray-400',
className,
}: ChipProps) {
return (
<span
className={cn(
"inline-flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium bg-muted text-muted-foreground",
'inline-flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium bg-muted text-muted-foreground',
className
)}
>
<span
className={cn("w-2 h-2 rounded-full", dotColor)}
/>
<span className={cn('w-2 h-2 rounded-full', dotColor)} />
{children}
</span>
);

View File

@@ -1,14 +1,14 @@
import * as React from "react"
import { X } from "lucide-react"
import * as React from 'react';
import { X } from 'lucide-react';
import { cn } from "@/lib/utils"
import { useDialogKeyboardShortcuts } from "@/lib/keyboard-shortcuts"
import { cn } from '@/lib/utils';
import { useDialogKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
const Dialog = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & {
open?: boolean
onOpenChange?: (open: boolean) => void
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
>(({ className, open, onOpenChange, children, ...props }, ref) => {
// Add keyboard shortcut support for closing dialog with Esc
@@ -18,7 +18,7 @@ const Dialog = React.forwardRef<
}
});
if (!open) return null
if (!open) return null;
return (
<div className="fixed inset-0 z-[9999] flex items-start justify-center p-4 overflow-y-auto">
@@ -29,7 +29,7 @@ const Dialog = React.forwardRef<
<div
ref={ref}
className={cn(
"relative z-[9999] grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg my-8",
'relative z-[9999] grid w-full max-w-lg gap-4 border bg-background p-6 shadow-lg duration-200 sm:rounded-lg my-8',
className
)}
{...props}
@@ -44,9 +44,9 @@ const Dialog = React.forwardRef<
{children}
</div>
</div>
)
})
Dialog.displayName = "Dialog"
);
});
Dialog.displayName = 'Dialog';
const DialogHeader = ({
className,
@@ -54,13 +54,13 @@ const DialogHeader = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
);
DialogHeader.displayName = 'DialogHeader';
const DialogTitle = React.forwardRef<
HTMLParagraphElement,
@@ -69,13 +69,13 @@ const DialogTitle = React.forwardRef<
<h3
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
))
DialogTitle.displayName = "DialogTitle"
));
DialogTitle.displayName = 'DialogTitle';
const DialogDescription = React.forwardRef<
HTMLParagraphElement,
@@ -83,19 +83,19 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = "DialogDescription"
));
DialogDescription.displayName = 'DialogDescription';
const DialogContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("grid gap-4", className)} {...props} />
))
DialogContent.displayName = "DialogContent"
<div ref={ref} className={cn('grid gap-4', className)} {...props} />
));
DialogContent.displayName = 'DialogContent';
const DialogFooter = ({
className,
@@ -103,13 +103,13 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogFooter.displayName = 'DialogFooter';
export {
Dialog,
@@ -118,4 +118,4 @@ export {
DialogFooter,
DialogHeader,
DialogTitle,
}
};

View File

@@ -1,32 +1,32 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from 'react';
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { Check, ChevronRight, Circle } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
'flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
@@ -34,9 +34,9 @@ const DropdownMenuSubTrigger = React.forwardRef<
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -45,14 +45,14 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
className
)}
{...props}
/>
))
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -63,32 +63,32 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
'z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]',
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
'relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@@ -97,7 +97,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
checked={checked}
@@ -110,9 +110,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -121,7 +121,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
@@ -133,26 +133,26 @@ const DropdownMenuRadioItem = React.forwardRef<
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
'px-2 py-1.5 text-sm font-semibold',
inset && 'pl-8',
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@@ -160,11 +160,11 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@@ -172,12 +172,12 @@ const DropdownMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
className={cn('ml-auto text-xs tracking-widest opacity-60', className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
);
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
export {
DropdownMenu,
@@ -195,4 +195,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
};

View File

@@ -1,28 +1,28 @@
import { useState, useRef, useEffect, KeyboardEvent } from 'react'
import { createPortal } from 'react-dom'
import { Textarea } from '@/components/ui/textarea'
import { makeRequest } from '@/lib/api'
import { useState, useRef, useEffect, KeyboardEvent } from 'react';
import { createPortal } from 'react-dom';
import { Textarea } from '@/components/ui/textarea';
import { makeRequest } from '@/lib/api';
interface FileSearchResult {
path: string
name: string
path: string;
name: string;
}
interface ApiResponse<T> {
success: boolean
data: T | null
message: string | null
success: boolean;
data: T | null;
message: string | null;
}
interface FileSearchTextareaProps {
value: string
onChange: (value: string) => void
placeholder?: string
rows?: number
disabled?: boolean
className?: string
projectId?: string
onKeyDown?: (e: React.KeyboardEvent) => void
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
disabled?: boolean;
className?: string;
projectId?: string;
onKeyDown?: (e: React.KeyboardEvent) => void;
}
export function FileSearchTextarea({
@@ -33,81 +33,81 @@ export function FileSearchTextarea({
disabled = false,
className,
projectId,
onKeyDown
onKeyDown,
}: FileSearchTextareaProps) {
const [searchQuery, setSearchQuery] = useState('')
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<FileSearchResult[]>([]);
const [showDropdown, setShowDropdown] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [atSymbolPosition, setAtSymbolPosition] = useState(-1)
const [isLoading, setIsLoading] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [atSymbolPosition, setAtSymbolPosition] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// Search for files when query changes
useEffect(() => {
if (!searchQuery || !projectId || searchQuery.length < 1) {
setSearchResults([])
setShowDropdown(false)
return
setSearchResults([]);
setShowDropdown(false);
return;
}
const searchFiles = async () => {
setIsLoading(true)
setIsLoading(true);
try {
const response = await makeRequest(
`/api/projects/${projectId}/search?q=${encodeURIComponent(searchQuery)}`
)
);
if (response.ok) {
const result: ApiResponse<FileSearchResult[]> = await response.json()
const result: ApiResponse<FileSearchResult[]> = await response.json();
if (result.success && result.data) {
setSearchResults(result.data)
setShowDropdown(true)
setSelectedIndex(-1)
setSearchResults(result.data);
setShowDropdown(true);
setSelectedIndex(-1);
}
}
} catch (error) {
console.error('Failed to search files:', error)
console.error('Failed to search files:', error);
} finally {
setIsLoading(false)
setIsLoading(false);
}
}
};
const debounceTimer = setTimeout(searchFiles, 300)
return () => clearTimeout(debounceTimer)
}, [searchQuery, projectId])
const debounceTimer = setTimeout(searchFiles, 300);
return () => clearTimeout(debounceTimer);
}, [searchQuery, projectId]);
// Handle text changes and detect @ symbol
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value
const newCursorPosition = e.target.selectionStart || 0
onChange(newValue)
const newValue = e.target.value;
const newCursorPosition = e.target.selectionStart || 0;
onChange(newValue);
// Check if @ was just typed
const textBeforeCursor = newValue.slice(0, newCursorPosition)
const lastAtIndex = textBeforeCursor.lastIndexOf('@')
const textBeforeCursor = newValue.slice(0, newCursorPosition);
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
if (lastAtIndex !== -1) {
// Check if there's no space after the @ (still typing the search query)
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1)
const hasSpace = textAfterAt.includes(' ') || textAfterAt.includes('\n')
const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
const hasSpace = textAfterAt.includes(' ') || textAfterAt.includes('\n');
if (!hasSpace) {
setAtSymbolPosition(lastAtIndex)
setSearchQuery(textAfterAt)
return
setAtSymbolPosition(lastAtIndex);
setSearchQuery(textAfterAt);
return;
}
}
// If no valid @ context, hide dropdown
setShowDropdown(false)
setSearchQuery('')
setAtSymbolPosition(-1)
}
setShowDropdown(false);
setSearchQuery('');
setAtSymbolPosition(-1);
};
// Handle keyboard navigation
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
@@ -115,136 +115,154 @@ export function FileSearchTextarea({
if (showDropdown && searchResults.length > 0) {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev =>
e.preventDefault();
setSelectedIndex((prev) =>
prev < searchResults.length - 1 ? prev + 1 : 0
)
return
);
return;
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev =>
e.preventDefault();
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : searchResults.length - 1
)
return
);
return;
case 'Enter':
if (selectedIndex >= 0) {
e.preventDefault()
selectFile(searchResults[selectedIndex])
return
e.preventDefault();
selectFile(searchResults[selectedIndex]);
return;
}
break
break;
case 'Escape':
e.preventDefault()
setShowDropdown(false)
setSearchQuery('')
setAtSymbolPosition(-1)
return
e.preventDefault();
setShowDropdown(false);
setSearchQuery('');
setAtSymbolPosition(-1);
return;
}
}
// Call the passed onKeyDown handler
onKeyDown?.(e)
}
onKeyDown?.(e);
};
// Select a file and insert it into the text
const selectFile = (file: FileSearchResult) => {
if (atSymbolPosition === -1) return
const beforeAt = value.slice(0, atSymbolPosition)
const afterQuery = value.slice(atSymbolPosition + 1 + searchQuery.length)
const newValue = beforeAt + file.path + afterQuery
onChange(newValue)
setShowDropdown(false)
setSearchQuery('')
setAtSymbolPosition(-1)
if (atSymbolPosition === -1) return;
const beforeAt = value.slice(0, atSymbolPosition);
const afterQuery = value.slice(atSymbolPosition + 1 + searchQuery.length);
const newValue = beforeAt + file.path + afterQuery;
onChange(newValue);
setShowDropdown(false);
setSearchQuery('');
setAtSymbolPosition(-1);
// Focus back to textarea
setTimeout(() => {
if (textareaRef.current) {
const newCursorPos = atSymbolPosition + file.path.length
textareaRef.current.focus()
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos)
const newCursorPos = atSymbolPosition + file.path.length;
textareaRef.current.focus();
textareaRef.current.setSelectionRange(newCursorPos, newCursorPos);
}
}, 0)
}
}, 0);
};
// Calculate dropdown position relative to viewport
const getDropdownPosition = () => {
if (!textareaRef.current || atSymbolPosition === -1) return { top: 0, left: 0, maxHeight: 240 }
const textareaRect = textareaRef.current.getBoundingClientRect()
const textBeforeAt = value.slice(0, atSymbolPosition)
const lines = textBeforeAt.split('\n')
const currentLine = lines.length - 1
const charInLine = lines[lines.length - 1].length
if (!textareaRef.current || atSymbolPosition === -1)
return { top: 0, left: 0, maxHeight: 240 };
const textareaRect = textareaRef.current.getBoundingClientRect();
const textBeforeAt = value.slice(0, atSymbolPosition);
const lines = textBeforeAt.split('\n');
const currentLine = lines.length - 1;
const charInLine = lines[lines.length - 1].length;
// More accurate calculation using computed styles
const computedStyle = window.getComputedStyle(textareaRef.current)
const lineHeight = parseInt(computedStyle.lineHeight) || 20
const fontSize = parseInt(computedStyle.fontSize) || 14
const charWidth = fontSize * 0.6 // Approximate character width
const paddingLeft = parseInt(computedStyle.paddingLeft) || 12
const paddingTop = parseInt(computedStyle.paddingTop) || 8
const computedStyle = window.getComputedStyle(textareaRef.current);
const lineHeight = parseInt(computedStyle.lineHeight) || 20;
const fontSize = parseInt(computedStyle.fontSize) || 14;
const charWidth = fontSize * 0.6; // Approximate character width
const paddingLeft = parseInt(computedStyle.paddingLeft) || 12;
const paddingTop = parseInt(computedStyle.paddingTop) || 8;
// Position relative to textarea
const relativeTop = paddingTop + (currentLine * lineHeight) + lineHeight
const relativeLeft = paddingLeft + (charWidth * charInLine)
const relativeTop = paddingTop + currentLine * lineHeight + lineHeight;
const relativeLeft = paddingLeft + charWidth * charInLine;
// Convert to viewport coordinates
const viewportTop = textareaRect.top + relativeTop
const viewportLeft = textareaRect.left + relativeLeft
const viewportTop = textareaRect.top + relativeTop;
const viewportLeft = textareaRect.left + relativeLeft;
// Dropdown dimensions
const dropdownWidth = 256 // min-w-64 = 256px
const minDropdownHeight = 120
const maxDropdownHeight = 400 // Increased to show more results without scrolling
let finalTop = viewportTop
let finalLeft = viewportLeft
let maxHeight = maxDropdownHeight
const dropdownWidth = 256; // min-w-64 = 256px
const minDropdownHeight = 120;
const maxDropdownHeight = 400; // Increased to show more results without scrolling
let finalTop = viewportTop;
let finalLeft = viewportLeft;
let maxHeight = maxDropdownHeight;
// Prevent going off the right edge
if (viewportLeft + dropdownWidth > window.innerWidth - 16) {
finalLeft = window.innerWidth - dropdownWidth - 16
finalLeft = window.innerWidth - dropdownWidth - 16;
}
// Prevent going off the left edge
if (finalLeft < 16) {
finalLeft = 16
finalLeft = 16;
}
// Smart positioning: avoid clipping by positioning above when needed
const availableSpaceBelow = window.innerHeight - viewportTop - 32
const availableSpaceAbove = textareaRect.top + (currentLine * lineHeight) - 32
const availableSpaceBelow = window.innerHeight - viewportTop - 32;
const availableSpaceAbove =
textareaRect.top + currentLine * lineHeight - 32;
// Check if dropdown would be clipped at bottom - if so, try positioning above
const wouldBeClippedBelow = availableSpaceBelow < maxDropdownHeight
const hasEnoughSpaceAbove = availableSpaceAbove >= maxDropdownHeight
const wouldBeClippedBelow = availableSpaceBelow < maxDropdownHeight;
const hasEnoughSpaceAbove = availableSpaceAbove >= maxDropdownHeight;
if (wouldBeClippedBelow && hasEnoughSpaceAbove) {
// Position above the cursor line with full height
finalTop = textareaRect.top + paddingTop + (currentLine * lineHeight) - maxDropdownHeight - 8
maxHeight = maxDropdownHeight
} else if (wouldBeClippedBelow && availableSpaceAbove > availableSpaceBelow) {
finalTop =
textareaRect.top +
paddingTop +
currentLine * lineHeight -
maxDropdownHeight -
8;
maxHeight = maxDropdownHeight;
} else if (
wouldBeClippedBelow &&
availableSpaceAbove > availableSpaceBelow
) {
// Position above but with reduced height if not enough space
finalTop = textareaRect.top + paddingTop + (currentLine * lineHeight) - availableSpaceAbove - 8
maxHeight = Math.max(availableSpaceAbove, minDropdownHeight)
finalTop =
textareaRect.top +
paddingTop +
currentLine * lineHeight -
availableSpaceAbove -
8;
maxHeight = Math.max(availableSpaceAbove, minDropdownHeight);
} else {
// Position below the cursor line
maxHeight = Math.min(maxDropdownHeight, Math.max(availableSpaceBelow, minDropdownHeight))
maxHeight = Math.min(
maxDropdownHeight,
Math.max(availableSpaceBelow, minDropdownHeight)
);
}
// Ensure minimum height
if (maxHeight < minDropdownHeight) {
maxHeight = minDropdownHeight
finalTop = Math.max(16, window.innerHeight - minDropdownHeight - 16)
maxHeight = minDropdownHeight;
finalTop = Math.max(16, window.innerHeight - minDropdownHeight - 16);
}
return { top: finalTop, left: finalLeft, maxHeight }
}
const dropdownPosition = getDropdownPosition()
return { top: finalTop, left: finalLeft, maxHeight };
};
const dropdownPosition = getDropdownPosition();
return (
<div className="relative">
@@ -258,43 +276,50 @@ export function FileSearchTextarea({
disabled={disabled}
className={className}
/>
{showDropdown && createPortal(
<div
ref={dropdownRef}
className="fixed bg-background border border-border rounded-md shadow-lg overflow-y-auto min-w-64"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
maxHeight: dropdownPosition.maxHeight,
zIndex: 10000, // Higher than dialog z-[9999]
}}
>
{isLoading ? (
<div className="p-2 text-sm text-muted-foreground">Searching...</div>
) : searchResults.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground">No files found</div>
) : (
<div className="py-1">
{searchResults.map((file, index) => (
<div
key={file.path}
className={`px-3 py-2 cursor-pointer text-sm ${
index === selectedIndex
? 'bg-blue-50 text-blue-900'
: 'hover:bg-muted'
}`}
onClick={() => selectFile(file)}
>
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground truncate">{file.path}</div>
</div>
))}
</div>
)}
</div>,
document.body
)}
{showDropdown &&
createPortal(
<div
ref={dropdownRef}
className="fixed bg-background border border-border rounded-md shadow-lg overflow-y-auto min-w-64"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left,
maxHeight: dropdownPosition.maxHeight,
zIndex: 10000, // Higher than dialog z-[9999]
}}
>
{isLoading ? (
<div className="p-2 text-sm text-muted-foreground">
Searching...
</div>
) : searchResults.length === 0 ? (
<div className="p-2 text-sm text-muted-foreground">
No files found
</div>
) : (
<div className="py-1">
{searchResults.map((file, index) => (
<div
key={file.path}
className={`px-3 py-2 cursor-pointer text-sm ${
index === selectedIndex
? 'bg-blue-50 text-blue-900'
: 'hover:bg-muted'
}`}
onClick={() => selectFile(file)}
>
<div className="font-medium truncate">{file.name}</div>
<div className="text-xs text-muted-foreground truncate">
{file.path}
</div>
</div>
))}
</div>
)}
</div>,
document.body
)}
</div>
)
);
}

View File

@@ -1,123 +1,138 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Folder, FolderOpen, File, AlertCircle, Home, ChevronUp, Search } from 'lucide-react'
import { makeRequest } from '@/lib/api'
import { DirectoryEntry } from 'shared/types'
import React, { useState, useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Folder,
FolderOpen,
File,
AlertCircle,
Home,
ChevronUp,
Search,
} from 'lucide-react';
import { makeRequest } from '@/lib/api';
import { DirectoryEntry } from 'shared/types';
interface FolderPickerProps {
open: boolean
onClose: () => void
onSelect: (path: string) => void
value?: string
title?: string
description?: string
open: boolean;
onClose: () => void;
onSelect: (path: string) => void;
value?: string;
title?: string;
description?: string;
}
export function FolderPicker({
open,
onClose,
onSelect,
value = '',
export function FolderPicker({
open,
onClose,
onSelect,
value = '',
title = 'Select Folder',
description = 'Choose a folder for your project'
description = 'Choose a folder for your project',
}: FolderPickerProps) {
const [currentPath, setCurrentPath] = useState<string>('')
const [entries, setEntries] = useState<DirectoryEntry[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [manualPath, setManualPath] = useState(value)
const [searchTerm, setSearchTerm] = useState('')
const [currentPath, setCurrentPath] = useState<string>('');
const [entries, setEntries] = useState<DirectoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [manualPath, setManualPath] = useState(value);
const [searchTerm, setSearchTerm] = useState('');
const filteredEntries = useMemo(() => {
if (!searchTerm.trim()) return entries
return entries.filter(entry =>
if (!searchTerm.trim()) return entries;
return entries.filter((entry) =>
entry.name.toLowerCase().includes(searchTerm.toLowerCase())
)
}, [entries, searchTerm])
);
}, [entries, searchTerm]);
useEffect(() => {
if (open) {
setManualPath(value)
loadDirectory()
setManualPath(value);
loadDirectory();
}
}, [open, value])
}, [open, value]);
const loadDirectory = async (path?: string) => {
setLoading(true)
setError('')
setLoading(true);
setError('');
try {
const queryParam = path ? `?path=${encodeURIComponent(path)}` : ''
const response = await makeRequest(`/api/filesystem/list${queryParam}`)
const queryParam = path ? `?path=${encodeURIComponent(path)}` : '';
const response = await makeRequest(`/api/filesystem/list${queryParam}`);
if (!response.ok) {
throw new Error('Failed to load directory')
throw new Error('Failed to load directory');
}
const data = await response.json()
const data = await response.json();
if (data.success) {
setEntries(data.data || [])
const newPath = path || data.message || ''
setCurrentPath(newPath)
setEntries(data.data || []);
const newPath = path || data.message || '';
setCurrentPath(newPath);
// Update manual path if we have a specific path (not for initial home directory load)
if (path) {
setManualPath(newPath)
setManualPath(newPath);
}
} else {
setError(data.message || 'Failed to load directory')
setError(data.message || 'Failed to load directory');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load directory')
setError(err instanceof Error ? err.message : 'Failed to load directory');
} finally {
setLoading(false)
setLoading(false);
}
}
};
const handleFolderClick = (entry: DirectoryEntry) => {
if (entry.is_directory) {
loadDirectory(entry.path)
setManualPath(entry.path) // Auto-populate the manual path field
loadDirectory(entry.path);
setManualPath(entry.path); // Auto-populate the manual path field
}
}
};
const handleParentDirectory = () => {
const parentPath = currentPath.split('/').slice(0, -1).join('/')
const newPath = parentPath || '/'
loadDirectory(newPath)
setManualPath(newPath)
}
const parentPath = currentPath.split('/').slice(0, -1).join('/');
const newPath = parentPath || '/';
loadDirectory(newPath);
setManualPath(newPath);
};
const handleHomeDirectory = () => {
loadDirectory()
loadDirectory();
// Don't set manual path here since home directory path varies by system
}
};
const handleManualPathChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setManualPath(e.target.value)
}
setManualPath(e.target.value);
};
const handleManualPathSubmit = () => {
loadDirectory(manualPath)
}
loadDirectory(manualPath);
};
const handleSelectCurrent = () => {
onSelect(manualPath || currentPath)
onClose()
}
onSelect(manualPath || currentPath);
onClose();
};
const handleSelectManual = () => {
onSelect(manualPath)
onClose()
}
onSelect(manualPath);
onClose();
};
const handleClose = () => {
setError('')
onClose()
}
setError('');
onClose();
};
return (
<Dialog open={open} onOpenChange={handleClose}>
@@ -126,13 +141,13 @@ export function FolderPicker({
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col space-y-4 overflow-hidden">
{/* Legend */}
<div className="text-xs text-muted-foreground border-b pb-2">
Click folder names to navigate Use action buttons to select
</div>
{/* Manual path input */}
<div className="space-y-2">
<div className="text-sm font-medium">Enter path manually:</div>
@@ -143,7 +158,7 @@ export function FolderPicker({
placeholder="/path/to/your/project"
className="flex-1 min-w-0"
/>
<Button
<Button
onClick={handleManualPathSubmit}
variant="outline"
size="sm"
@@ -224,7 +239,9 @@ export function FolderPicker({
className={`flex items-center space-x-2 p-2 rounded cursor-pointer hover:bg-accent ${
!entry.is_directory ? 'opacity-50 cursor-not-allowed' : ''
}`}
onClick={() => entry.is_directory && handleFolderClick(entry)}
onClick={() =>
entry.is_directory && handleFolderClick(entry)
}
title={entry.name} // Show full name on hover
>
{entry.is_directory ? (
@@ -236,7 +253,9 @@ export function FolderPicker({
) : (
<File className="h-4 w-4 text-gray-400 flex-shrink-0" />
)}
<span className="text-sm flex-1 truncate min-w-0">{entry.name}</span>
<span className="text-sm flex-1 truncate min-w-0">
{entry.name}
</span>
{entry.is_git_repo && (
<span className="text-xs text-green-600 bg-green-100 px-2 py-1 rounded flex-shrink-0">
git repo
@@ -250,21 +269,14 @@ export function FolderPicker({
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={handleClose}
>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSelectManual}
disabled={!manualPath.trim()}
>
<Button onClick={handleSelectManual} disabled={!manualPath.trim()}>
Select Path
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
);
}

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
@@ -11,15 +11,15 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}
{...props}
/>
)
);
}
)
Input.displayName = "Input"
);
Input.displayName = 'Input';
export { Input }
export { Input };

View File

@@ -1,12 +1,12 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import * as React from 'react';
import * as LabelPrimitive from '@radix-ui/react-label';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
@@ -18,7 +18,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label }
export { Label };

View File

@@ -1,16 +1,16 @@
"use client"
'use client';
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import * as React from 'react';
import * as SelectPrimitive from '@radix-ui/react-select';
import { Check, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Select = SelectPrimitive.Root
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
className
)}
{...props}
@@ -29,8 +29,8 @@ const SelectTrigger = React.forwardRef<
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
@@ -39,15 +39,15 @@ const SelectScrollUpButton = React.forwardRef<
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
@@ -56,28 +56,28 @@ const SelectScrollDownButton = React.forwardRef<
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
'flex cursor-default items-center justify-center py-1',
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
'relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className
)}
position={position}
@@ -86,9 +86,9 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
)}
>
{children}
@@ -96,8 +96,8 @@ const SelectContent = React.forwardRef<
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
@@ -105,11 +105,11 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
@@ -118,7 +118,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
@@ -131,8 +131,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
@@ -140,11 +140,11 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
className={cn('-mx-1 my-1 h-px bg-muted', className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
@@ -157,4 +157,4 @@ export {
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
};

View File

@@ -1,16 +1,16 @@
"use client"
'use client';
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import * as React from 'react';
import * as SeparatorPrimitive from '@radix-ui/react-separator';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
{ className, orientation = 'horizontal', decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
@@ -18,14 +18,14 @@ const Separator = React.forwardRef<
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator }
export { Separator };

View File

@@ -1,7 +1,7 @@
"use client";
'use client';
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import {
DndContext,
PointerSensor,
@@ -10,11 +10,11 @@ import {
useDroppable,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent } from "@dnd-kit/core";
import type { ReactNode } from "react";
} from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core';
import type { ReactNode } from 'react';
export type { DragEndEvent } from "@dnd-kit/core";
export type { DragEndEvent } from '@dnd-kit/core';
export type Status = {
id: string;
@@ -31,7 +31,7 @@ export type Feature = {
};
export type KanbanBoardProps = {
id: Status["id"];
id: Status['id'];
children: ReactNode;
className?: string;
};
@@ -42,8 +42,8 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
return (
<div
className={cn(
"flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all",
isOver ? "outline-primary" : "outline-transparent",
'flex h-full min-h-40 flex-col gap-2 rounded-md border bg-secondary p-2 text-xs shadow-sm outline outline-2 transition-all',
isOver ? 'outline-primary' : 'outline-transparent',
className
)}
ref={setNodeRef}
@@ -53,7 +53,7 @@ export const KanbanBoard = ({ id, children, className }: KanbanBoardProps) => {
);
};
export type KanbanCardProps = Pick<Feature, "id" | "name"> & {
export type KanbanCardProps = Pick<Feature, 'id' | 'name'> & {
index: number;
parent: string;
children?: ReactNode;
@@ -79,14 +79,14 @@ export const KanbanCard = ({
return (
<Card
className={cn(
"rounded-md p-3 shadow-sm",
isDragging && "cursor-grabbing",
'rounded-md p-3 shadow-sm',
isDragging && 'cursor-grabbing',
className
)}
style={{
transform: transform
? `translateX(${transform.x}px) translateY(${transform.y}px)`
: "none",
: 'none',
}}
{...listeners}
{...attributes}
@@ -104,7 +104,7 @@ export type KanbanCardsProps = {
};
export const KanbanCards = ({ children, className }: KanbanCardsProps) => (
<div className={cn("flex flex-1 flex-col gap-2", className)}>{children}</div>
<div className={cn('flex flex-1 flex-col gap-2', className)}>{children}</div>
);
export type KanbanHeaderProps =
@@ -112,16 +112,16 @@ export type KanbanHeaderProps =
children: ReactNode;
}
| {
name: Status["name"];
color: Status["color"];
name: Status['name'];
color: Status['color'];
className?: string;
};
export const KanbanHeader = (props: KanbanHeaderProps) =>
"children" in props ? (
'children' in props ? (
props.children
) : (
<div className={cn("flex shrink-0 items-center gap-2", props.className)}>
<div className={cn('flex shrink-0 items-center gap-2', props.className)}>
<div
className="h-2 w-2 rounded-full"
style={{ backgroundColor: props.color }}
@@ -155,7 +155,7 @@ export const KanbanProvider = ({
>
<div
className={cn(
"grid w-full auto-cols-fr grid-flow-col gap-4",
'grid w-full auto-cols-fr grid-flow-col gap-4',
className
)}
>

View File

@@ -1,6 +1,6 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Table = React.forwardRef<
HTMLTableElement,
@@ -9,20 +9,20 @@ const Table = React.forwardRef<
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
));
Table.displayName = 'Table';
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
));
TableHeader.displayName = 'TableHeader';
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@@ -30,11 +30,11 @@ const TableBody = React.forwardRef<
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
));
TableBody.displayName = 'TableBody';
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@@ -43,13 +43,13 @@ const TableFooter = React.forwardRef<
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
));
TableFooter.displayName = 'TableFooter';
const TableRow = React.forwardRef<
HTMLTableRowElement,
@@ -58,13 +58,13 @@ const TableRow = React.forwardRef<
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
'border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted',
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
));
TableRow.displayName = 'TableRow';
const TableHead = React.forwardRef<
HTMLTableCellElement,
@@ -73,13 +73,13 @@ const TableHead = React.forwardRef<
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
));
TableHead.displayName = 'TableHead';
const TableCell = React.forwardRef<
HTMLTableCellElement,
@@ -87,11 +87,11 @@ const TableCell = React.forwardRef<
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
));
TableCell.displayName = 'TableCell';
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@@ -99,11 +99,11 @@ const TableCaption = React.forwardRef<
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
));
TableCaption.displayName = 'TableCaption';
export {
Table,
@@ -114,4 +114,4 @@ export {
TableRow,
TableCell,
TableCaption,
}
};

View File

@@ -1,22 +1,22 @@
import * as React from "react"
import * as React from 'react';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
React.ComponentProps<'textarea'>
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
);
});
Textarea.displayName = 'Textarea';
export { Textarea }
export { Textarea };

View File

@@ -1,13 +1,13 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';
const TooltipProvider = TooltipPrimitive.Provider
const TooltipProvider = TooltipPrimitive.Provider;
const Tooltip = TooltipPrimitive.Root
const Tooltip = TooltipPrimitive.Root;
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
@@ -17,12 +17,12 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
'z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]',
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -24,7 +24,7 @@
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
/* Status colors */
--success: 142.1 76.2% 36.3%;
--success-foreground: 138.5 76.5% 96.7%;
@@ -34,7 +34,7 @@
--info-foreground: 222.2 84% 4.9%;
--neutral: 210 40% 96%;
--neutral-foreground: 222.2 84% 4.9%;
/* Status indicator colors */
--status-init: 210 40% 96%;
--status-init-foreground: 222.2 84% 4.9%;
@@ -46,7 +46,7 @@
--status-failed-foreground: 210 40% 98%;
--status-paused: 32.2 95% 44.1%;
--status-paused-foreground: 26 83.3% 14.1%;
/* Console/terminal colors */
--console-background: 222.2 84% 4.9%;
--console-foreground: 210 40% 98%;
@@ -74,7 +74,7 @@
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
/* Status colors */
--success: 138.5 76.5% 47.7%;
--success-foreground: 138.5 76.5% 96.7%;
@@ -84,7 +84,7 @@
--info-foreground: 222.2 84% 4.9%;
--neutral: 217.2 32.6% 17.5%;
--neutral-foreground: 210 40% 98%;
/* Status indicator colors */
--status-init: 217.2 32.6% 17.5%;
--status-init-foreground: 210 40% 98%;
@@ -96,7 +96,7 @@
--status-failed-foreground: 210 40% 98%;
--status-paused: 32.2 95% 44.1%;
--status-paused-foreground: 26 83.3% 14.1%;
/* Console/terminal colors */
--console-background: 0 0% 0%;
--console-foreground: 138.5 76.5% 47.7%;

View File

@@ -1,11 +1,11 @@
export const makeRequest = async (url: string, options: RequestInit = {}) => {
const headers = {
'Content-Type': 'application/json',
...(options.headers || {})
}
...(options.headers || {}),
};
return fetch(url, {
...options,
headers
})
}
headers,
});
};

View File

@@ -20,8 +20,10 @@ export interface KeyboardShortcutContext {
}
// Centralized shortcut definitions
export const createKeyboardShortcuts = (context: KeyboardShortcutContext): Record<string, KeyboardShortcut> => ({
'Escape': {
export const createKeyboardShortcuts = (
context: KeyboardShortcutContext
): Record<string, KeyboardShortcut> => ({
Escape: {
key: 'Escape',
description: 'Go back or close dialog',
action: () => {
@@ -30,21 +32,31 @@ export const createKeyboardShortcuts = (context: KeyboardShortcutContext): Recor
context.closeDialog();
return;
}
// Otherwise, navigate back
if (context.navigate) {
const currentPath = context.currentPath || context.location?.pathname || '/';
const currentPath =
context.currentPath || context.location?.pathname || '/';
// Navigate back based on current path
if (currentPath.includes('/attempts/') && currentPath.includes('/compare')) {
if (
currentPath.includes('/attempts/') &&
currentPath.includes('/compare')
) {
// From compare page, go back to task details
const taskPath = currentPath.split('/attempts/')[0];
context.navigate(taskPath);
} else if (currentPath.includes('/tasks/') && !currentPath.endsWith('/tasks')) {
} else if (
currentPath.includes('/tasks/') &&
!currentPath.endsWith('/tasks')
) {
// From task details, go back to project tasks
const projectPath = currentPath.split('/tasks/')[0] + '/tasks';
context.navigate(projectPath);
} else if (currentPath.includes('/projects/') && currentPath.includes('/tasks')) {
} else if (
currentPath.includes('/projects/') &&
currentPath.includes('/tasks')
) {
// From project tasks, go back to projects
context.navigate('/projects');
} else if (currentPath !== '/' && currentPath !== '/projects') {
@@ -52,48 +64,55 @@ export const createKeyboardShortcuts = (context: KeyboardShortcutContext): Recor
context.navigate('/projects');
}
}
}
},
},
'KeyC': {
KeyC: {
key: 'c',
description: 'Create new task',
action: () => {
if (context.openCreateTask) {
context.openCreateTask();
}
}
}
},
},
});
// Hook to register global keyboard shortcuts
export function useKeyboardShortcuts(context: KeyboardShortcutContext) {
const shortcuts = createKeyboardShortcuts(context);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in input fields
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
// Don't trigger shortcuts when modifier keys are pressed (except for specific shortcuts)
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const shortcut = shortcuts[event.code] || shortcuts[event.key];
if (shortcut && !shortcut.disabled) {
event.preventDefault();
shortcut.action(context);
}
}, [shortcuts, context]);
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
// Don't trigger shortcuts when typing in input fields
const target = event.target as HTMLElement;
if (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
) {
return;
}
// Don't trigger shortcuts when modifier keys are pressed (except for specific shortcuts)
if (event.ctrlKey || event.metaKey || event.altKey) {
return;
}
const shortcut = shortcuts[event.code] || shortcuts[event.key];
if (shortcut && !shortcut.disabled) {
event.preventDefault();
shortcut.action(context);
}
},
[shortcuts, context]
);
useEffect(() => {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleKeyDown]);
return shortcuts;
}

View File

@@ -6,28 +6,28 @@
// The breakpoint at which we switch from overlay to side-by-side mode
// Change this value to adjust when the panel switches to side-by-side mode:
// 'sm' = 640px, 'md' = 768px, 'lg' = 1024px, 'xl' = 1280px, '2xl' = 1536px
export const PANEL_SIDE_BY_SIDE_BREAKPOINT = "xl" as const;
export const PANEL_SIDE_BY_SIDE_BREAKPOINT = 'xl' as const;
// Panel widths for different screen sizes (in overlay mode)
export const PANEL_WIDTHS = {
base: "w-full", // < 640px
sm: "sm:w-[560px]", // 640px+
md: "md:w-[600px]", // 768px+
lg: "lg:w-[650px]", // 1024px+ (smaller to start transitioning)
xl: "xl:w-[750px]", // 1280px+
"2xl": "2xl:w-[800px]", // 1536px+ (side-by-side mode)
base: 'w-full', // < 640px
sm: 'sm:w-[560px]', // 640px+
md: 'md:w-[600px]', // 768px+
lg: 'lg:w-[650px]', // 1024px+ (smaller to start transitioning)
xl: 'xl:w-[750px]', // 1280px+
'2xl': '2xl:w-[800px]', // 1536px+ (side-by-side mode)
} as const;
// Generate classes for TaskDetailsPanel
export const getTaskPanelClasses = () => {
const overlayClasses = [
"fixed inset-y-0 right-0 z-50",
'fixed inset-y-0 right-0 z-50',
PANEL_WIDTHS.base,
PANEL_WIDTHS.sm,
PANEL_WIDTHS.md,
PANEL_WIDTHS.lg,
PANEL_WIDTHS.xl,
].join(" ");
].join(' ');
const sideBySideClasses = [
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:relative`,
@@ -35,7 +35,7 @@ export const getTaskPanelClasses = () => {
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:z-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:w-[800px]`,
].join(" ");
].join(' ');
return `${overlayClasses} ${sideBySideClasses} bg-background border-l shadow-lg overflow-hidden`;
};
@@ -47,16 +47,16 @@ export const getBackdropClasses = () => {
// Generate classes for main container (enable flex layout in side-by-side mode)
export const getMainContainerClasses = (isPanelOpen: boolean) => {
if (!isPanelOpen) return "w-full";
if (!isPanelOpen) return 'w-full';
return `w-full ${PANEL_SIDE_BY_SIDE_BREAKPOINT}:flex ${PANEL_SIDE_BY_SIDE_BREAKPOINT}:h-full`;
};
// Generate classes for kanban section
export const getKanbanSectionClasses = (isPanelOpen: boolean) => {
if (!isPanelOpen) return "w-full";
if (!isPanelOpen) return 'w-full';
const overlayClasses = "w-full opacity-50 pointer-events-none";
const overlayClasses = 'w-full opacity-50 pointer-events-none';
const sideBySideClasses = [
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:flex-1`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:min-w-0`,
@@ -64,7 +64,7 @@ export const getKanbanSectionClasses = (isPanelOpen: boolean) => {
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:overflow-y-auto`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:opacity-100`,
`${PANEL_SIDE_BY_SIDE_BREAKPOINT}:pointer-events-auto`,
].join(" ");
].join(' ');
return `${overlayClasses} ${sideBySideClasses}`;
};

View File

@@ -1,6 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
return twMerge(clsx(inputs));
}

View File

@@ -1,10 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { ClickToComponent } from "click-to-react-component";
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
import './index.css';
import { ClickToComponent } from 'click-to-react-component';
ReactDOM.createRoot(document.getElementById("root")!).render(
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ClickToComponent />
<App />

View File

@@ -1,16 +1,35 @@
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Loader2, Volume2 } from "lucide-react";
import type { ThemeMode, EditorType, SoundFile } from "shared/types";
import { EXECUTOR_TYPES, EDITOR_TYPES, EXECUTOR_LABELS, EDITOR_LABELS, SOUND_FILES, SOUND_LABELS } from "shared/types";
import { useTheme } from "@/components/theme-provider";
import { useConfig } from "@/components/config-provider";
import { useState } from 'react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Loader2, Volume2 } from 'lucide-react';
import type { ThemeMode, EditorType, SoundFile } from 'shared/types';
import {
EXECUTOR_TYPES,
EDITOR_TYPES,
EXECUTOR_LABELS,
EDITOR_LABELS,
SOUND_FILES,
SOUND_LABELS,
} from 'shared/types';
import { useTheme } from '@/components/theme-provider';
import { useConfig } from '@/components/config-provider';
export function Settings() {
const { config, updateConfig, saveConfig, loading } = useConfig();
@@ -24,32 +43,32 @@ export function Settings() {
try {
await audio.play();
} catch (err) {
console.error("Failed to play sound:", err);
console.error('Failed to play sound:', err);
}
};
const handleSave = async () => {
if (!config) return;
setSaving(true);
setError(null);
setSuccess(false);
try {
const success = await saveConfig();
if (success) {
setSuccess(true);
// Update theme provider to reflect the saved theme
setTheme(config.theme);
setTimeout(() => setSuccess(false), 3000);
} else {
setError("Failed to save configuration");
setError('Failed to save configuration');
}
} catch (err) {
setError("Failed to save configuration");
console.error("Error saving config:", err);
setError('Failed to save configuration');
console.error('Error saving config:', err);
} finally {
setSaving(false);
}
@@ -57,13 +76,13 @@ export function Settings() {
const resetDisclaimer = async () => {
if (!config) return;
updateConfig({ disclaimer_acknowledged: false });
};
const resetOnboarding = async () => {
if (!config) return;
updateConfig({ onboarding_acknowledged: false });
};
@@ -82,9 +101,7 @@ export function Settings() {
return (
<div className="container mx-auto px-4 py-8">
<Alert variant="destructive">
<AlertDescription>
Failed to load settings. {error}
</AlertDescription>
<AlertDescription>Failed to load settings. {error}</AlertDescription>
</Alert>
</div>
);
@@ -108,7 +125,9 @@ export function Settings() {
{success && (
<Alert className="border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200">
<AlertDescription className="font-medium"> Settings saved successfully!</AlertDescription>
<AlertDescription className="font-medium">
Settings saved successfully!
</AlertDescription>
</Alert>
)}
@@ -125,7 +144,9 @@ export function Settings() {
<Label htmlFor="theme">Theme</Label>
<Select
value={config.theme}
onValueChange={(value: ThemeMode) => updateConfig({ theme: value })}
onValueChange={(value: ThemeMode) =>
updateConfig({ theme: value })
}
>
<SelectTrigger id="theme">
<SelectValue placeholder="Select theme" />
@@ -155,7 +176,9 @@ export function Settings() {
<Label htmlFor="executor">Default Executor</Label>
<Select
value={config.executor.type}
onValueChange={(value: "echo" | "claude" | "amp") => updateConfig({ executor: { type: value } })}
onValueChange={(value: 'echo' | 'claude' | 'amp') =>
updateConfig({ executor: { type: value } })
}
>
<SelectTrigger id="executor">
<SelectValue placeholder="Select executor" />
@@ -187,13 +210,18 @@ export function Settings() {
<Label htmlFor="editor">Preferred Editor</Label>
<Select
value={config.editor.editor_type}
onValueChange={(value: EditorType) => updateConfig({
editor: {
...config.editor,
editor_type: value,
custom_command: value === "custom" ? config.editor.custom_command : null
}
})}
onValueChange={(value: EditorType) =>
updateConfig({
editor: {
...config.editor,
editor_type: value,
custom_command:
value === 'custom'
? config.editor.custom_command
: null,
},
})
}
>
<SelectTrigger id="editor">
<SelectValue placeholder="Select editor" />
@@ -210,23 +238,26 @@ export function Settings() {
Choose your preferred code editor for opening task attempts.
</p>
</div>
{config.editor.editor_type === "custom" && (
{config.editor.editor_type === 'custom' && (
<div className="space-y-2">
<Label htmlFor="custom-command">Custom Command</Label>
<Input
id="custom-command"
placeholder="e.g., code, subl, vim"
value={config.editor.custom_command || ""}
onChange={(e) => updateConfig({
editor: {
...config.editor,
custom_command: e.target.value || null
}
})}
value={config.editor.custom_command || ''}
onChange={(e) =>
updateConfig({
editor: {
...config.editor,
custom_command: e.target.value || null,
},
})
}
/>
<p className="text-sm text-muted-foreground">
Enter the command to run your custom editor. Use spaces for arguments (e.g., "code --wait").
Enter the command to run your custom editor. Use spaces for
arguments (e.g., "code --wait").
</p>
</div>
)}
@@ -245,23 +276,29 @@ export function Settings() {
<Checkbox
id="sound-alerts"
checked={config.sound_alerts}
onCheckedChange={(checked: boolean) => updateConfig({ sound_alerts: checked })}
onCheckedChange={(checked: boolean) =>
updateConfig({ sound_alerts: checked })
}
/>
<div className="space-y-0.5">
<Label htmlFor="sound-alerts" className="cursor-pointer">Sound Alerts</Label>
<Label htmlFor="sound-alerts" className="cursor-pointer">
Sound Alerts
</Label>
<p className="text-sm text-muted-foreground">
Play a sound when task attempts finish running.
</p>
</div>
</div>
{config.sound_alerts && (
<div className="space-y-2 ml-6">
<Label htmlFor="sound-file">Sound</Label>
<div className="flex items-center gap-2">
<Select
value={config.sound_file}
onValueChange={(value: SoundFile) => updateConfig({ sound_file: value })}
onValueChange={(value: SoundFile) =>
updateConfig({ sound_file: value })
}
>
<SelectTrigger id="sound-file" className="flex-1">
<SelectValue placeholder="Select sound" />
@@ -284,7 +321,8 @@ export function Settings() {
</Button>
</div>
<p className="text-sm text-muted-foreground">
Choose the sound to play when tasks complete. Click the volume button to preview.
Choose the sound to play when tasks complete. Click the
volume button to preview.
</p>
</div>
)}
@@ -292,10 +330,17 @@ export function Settings() {
<Checkbox
id="push-notifications"
checked={config.push_notifications}
onCheckedChange={(checked: boolean) => updateConfig({ push_notifications: checked })}
onCheckedChange={(checked: boolean) =>
updateConfig({ push_notifications: checked })
}
/>
<div className="space-y-0.5">
<Label htmlFor="push-notifications" className="cursor-pointer">Push Notifications (macOS)</Label>
<Label
htmlFor="push-notifications"
className="cursor-pointer"
>
Push Notifications (macOS)
</Label>
<p className="text-sm text-muted-foreground">
Show system notifications when task attempts finish running.
</p>
@@ -317,9 +362,9 @@ export function Settings() {
<div>
<Label>Disclaimer Status</Label>
<p className="text-sm text-muted-foreground">
{config.disclaimer_acknowledged
? "You have acknowledged the safety disclaimer."
: "The safety disclaimer has not been acknowledged."}
{config.disclaimer_acknowledged
? 'You have acknowledged the safety disclaimer.'
: 'The safety disclaimer has not been acknowledged.'}
</p>
</div>
<Button
@@ -332,7 +377,8 @@ export function Settings() {
</Button>
</div>
<p className="text-xs text-muted-foreground">
Resetting the disclaimer will require you to acknowledge the safety warning again on next app start.
Resetting the disclaimer will require you to acknowledge the
safety warning again on next app start.
</p>
</div>
<div className="space-y-2">
@@ -340,9 +386,9 @@ export function Settings() {
<div>
<Label>Onboarding Status</Label>
<p className="text-sm text-muted-foreground">
{config.onboarding_acknowledged
? "You have completed the onboarding process."
: "The onboarding process has not been completed."}
{config.onboarding_acknowledged
? 'You have completed the onboarding process.'
: 'The onboarding process has not been completed.'}
</p>
</div>
<Button
@@ -355,7 +401,8 @@ export function Settings() {
</Button>
</div>
<p className="text-xs text-muted-foreground">
Resetting the onboarding will show the setup screen again on next app start.
Resetting the onboarding will show the setup screen again on
next app start.
</p>
</div>
</CardContent>
@@ -365,14 +412,18 @@ export function Settings() {
{/* Sticky save button */}
<div className="fixed bottom-0 left-0 right-0 bg-background/80 backdrop-blur-sm border-t p-4 z-10">
<div className="container mx-auto max-w-4xl flex justify-end">
<Button onClick={handleSave} disabled={saving || success} className={success ? "bg-green-600 hover:bg-green-700" : ""}>
<Button
onClick={handleSave}
disabled={saving || success}
className={success ? 'bg-green-600 hover:bg-green-700' : ''}
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{success && <span className="mr-2"></span>}
{success ? "Settings Saved!" : "Save Settings"}
{success ? 'Settings Saved!' : 'Save Settings'}
</Button>
</div>
</div>
{/* Spacer to prevent content from being hidden behind sticky button */}
<div className="h-20"></div>
</div>

View File

@@ -1,27 +1,27 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Plus, Settings } from "lucide-react";
import { makeRequest } from "@/lib/api";
import { TaskFormDialog } from "@/components/tasks/TaskFormDialog";
import { ProjectForm } from "@/components/projects/project-form";
import { useKeyboardShortcuts } from "@/lib/keyboard-shortcuts";
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Plus, Settings } from 'lucide-react';
import { makeRequest } from '@/lib/api';
import { TaskFormDialog } from '@/components/tasks/TaskFormDialog';
import { ProjectForm } from '@/components/projects/project-form';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
import {
getMainContainerClasses,
getKanbanSectionClasses,
} from "@/lib/responsive-config";
} from '@/lib/responsive-config';
import { TaskKanbanBoard } from "@/components/tasks/TaskKanbanBoard";
import { TaskDetailsPanel } from "@/components/tasks/TaskDetailsPanel";
import { TaskKanbanBoard } from '@/components/tasks/TaskKanbanBoard';
import { TaskDetailsPanel } from '@/components/tasks/TaskDetailsPanel';
import type {
TaskStatus,
TaskWithAttemptStatus,
ProjectWithBranch,
ExecutorConfig,
CreateTaskAndStart,
} from "shared/types";
import type { DragEndEvent } from "@/components/ui/shadcn-io/kanban";
} from 'shared/types';
import type { DragEndEvent } from '@/components/ui/shadcn-io/kanban';
type Task = TaskWithAttemptStatus;
@@ -92,7 +92,9 @@ export function ProjectTasks() {
const fetchProject = async () => {
try {
const response = await makeRequest(`/api/projects/${projectId}/with-branch`);
const response = await makeRequest(
`/api/projects/${projectId}/with-branch`
);
if (response.ok) {
const result: ApiResponse<ProjectWithBranch> = await response.json();
@@ -100,11 +102,11 @@ export function ProjectTasks() {
setProject(result.data);
}
} else if (response.status === 404) {
setError("Project not found");
navigate("/projects");
setError('Project not found');
navigate('/projects');
}
} catch (err) {
setError("Failed to load project");
setError('Failed to load project');
}
};
@@ -143,10 +145,10 @@ export function ProjectTasks() {
});
}
} else {
setError("Failed to load tasks");
setError('Failed to load tasks');
}
} catch (err) {
setError("Failed to load tasks");
setError('Failed to load tasks');
} finally {
if (!skipLoading) {
setLoading(false);
@@ -157,7 +159,7 @@ export function ProjectTasks() {
const handleCreateTask = async (title: string, description: string) => {
try {
const response = await makeRequest(`/api/projects/${projectId}/tasks`, {
method: "POST",
method: 'POST',
body: JSON.stringify({
project_id: projectId,
title,
@@ -168,10 +170,10 @@ export function ProjectTasks() {
if (response.ok) {
await fetchTasks();
} else {
setError("Failed to create task");
setError('Failed to create task');
}
} catch (err) {
setError("Failed to create task");
setError('Failed to create task');
}
};
@@ -191,7 +193,7 @@ export function ProjectTasks() {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/create-and-start`,
{
method: "POST",
method: 'POST',
body: JSON.stringify(payload),
}
);
@@ -199,10 +201,10 @@ export function ProjectTasks() {
if (response.ok) {
await fetchTasks();
} else {
setError("Failed to create and start task");
setError('Failed to create and start task');
}
} catch (err) {
setError("Failed to create and start task");
setError('Failed to create and start task');
}
};
@@ -217,7 +219,7 @@ export function ProjectTasks() {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${editingTask.id}`,
{
method: "PUT",
method: 'PUT',
body: JSON.stringify({
title,
description: description || null,
@@ -230,31 +232,31 @@ export function ProjectTasks() {
await fetchTasks();
setEditingTask(null);
} else {
setError("Failed to update task");
setError('Failed to update task');
}
} catch (err) {
setError("Failed to update task");
setError('Failed to update task');
}
};
const handleDeleteTask = async (taskId: string) => {
if (!confirm("Are you sure you want to delete this task?")) return;
if (!confirm('Are you sure you want to delete this task?')) return;
try {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: "DELETE",
method: 'DELETE',
}
);
if (response.ok) {
await fetchTasks();
} else {
setError("Failed to delete task");
setError('Failed to delete task');
}
} catch (err) {
setError("Failed to delete task");
setError('Failed to delete task');
}
};
@@ -288,7 +290,7 @@ export function ProjectTasks() {
if (!over || !active.data.current) return;
const taskId = active.id as string;
const newStatus = over.id as Task["status"];
const newStatus = over.id as Task['status'];
const task = tasks.find((t) => t.id === taskId);
if (!task || task.status === newStatus) return;
@@ -303,7 +305,7 @@ export function ProjectTasks() {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}`,
{
method: "PUT",
method: 'PUT',
body: JSON.stringify({
title: task.title,
description: task.description,
@@ -319,7 +321,7 @@ export function ProjectTasks() {
t.id === taskId ? { ...t, status: previousStatus } : t
)
);
setError("Failed to update task status");
setError('Failed to update task status');
}
} catch (err) {
// Revert the optimistic update if the API call failed
@@ -328,7 +330,7 @@ export function ProjectTasks() {
t.id === taskId ? { ...t, status: previousStatus } : t
)
);
setError("Failed to update task status");
setError('Failed to update task status');
}
};
@@ -348,7 +350,7 @@ export function ProjectTasks() {
<div className="px-8 my-12 flex flex-row">
<div className="w-full flex items-center gap-3">
<h1 className="text-2xl font-bold">{project?.name || "Project"}</h1>
<h1 className="text-2xl font-bold">{project?.name || 'Project'}</h1>
{project?.current_branch && (
<span className="text-sm text-muted-foreground bg-muted px-2 py-1 rounded-md">
{project.current_branch}

View File

@@ -1,15 +1,15 @@
import { useParams, useNavigate } from 'react-router-dom'
import { ProjectList } from '@/components/projects/project-list'
import { ProjectDetail } from '@/components/projects/project-detail'
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts'
import { useParams, useNavigate } from 'react-router-dom';
import { ProjectList } from '@/components/projects/project-list';
import { ProjectDetail } from '@/components/projects/project-detail';
import { useKeyboardShortcuts } from '@/lib/keyboard-shortcuts';
export function Projects() {
const { projectId } = useParams<{ projectId: string }>()
const navigate = useNavigate()
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const handleBack = () => {
navigate('/projects')
}
navigate('/projects');
};
// Setup keyboard shortcuts (only Esc for back navigation, no task creation here)
useKeyboardShortcuts({
@@ -17,17 +17,12 @@ export function Projects() {
currentPath: projectId ? `/projects/${projectId}` : '/projects',
hasOpenDialog: false,
closeDialog: () => {},
openCreateTask: () => {} // No-op for projects page
})
openCreateTask: () => {}, // No-op for projects page
});
if (projectId) {
return (
<ProjectDetail
projectId={projectId}
onBack={handleBack}
/>
)
return <ProjectDetail projectId={projectId} onBack={handleBack} />;
}
return <ProjectList />
return <ProjectList />;
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -9,7 +9,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
} from '@/components/ui/dialog';
import {
ArrowLeft,
FileText,
@@ -20,14 +20,14 @@ import {
Trash2,
Eye,
EyeOff,
} from "lucide-react";
import { makeRequest } from "@/lib/api";
} from 'lucide-react';
import { makeRequest } from '@/lib/api';
import type {
WorktreeDiff,
DiffChunkType,
DiffChunk,
BranchStatus,
} from "shared/types";
} from 'shared/types';
interface ApiResponse<T> {
success: boolean;
@@ -80,13 +80,13 @@ export function TaskAttemptComparePage() {
if (result.success && result.data) {
setDiff(result.data);
} else {
setError("Failed to load diff");
setError('Failed to load diff');
}
} else {
setError("Failed to load diff");
setError('Failed to load diff');
}
} catch (err) {
setError("Failed to load diff");
setError('Failed to load diff');
} finally {
setLoading(false);
}
@@ -106,13 +106,13 @@ export function TaskAttemptComparePage() {
if (result.success && result.data) {
setBranchStatus(result.data);
} else {
setError("Failed to load branch status");
setError('Failed to load branch status');
}
} else {
setError("Failed to load branch status");
setError('Failed to load branch status');
}
} catch (err) {
setError("Failed to load branch status");
setError('Failed to load branch status');
} finally {
setBranchStatusLoading(false);
}
@@ -142,7 +142,7 @@ export function TaskAttemptComparePage() {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/merge`,
{
method: "POST",
method: 'POST',
}
);
@@ -153,13 +153,13 @@ export function TaskAttemptComparePage() {
fetchDiff();
fetchBranchStatus();
} else {
setError("Failed to merge changes");
setError('Failed to merge changes');
}
} else {
setError("Failed to merge changes");
setError('Failed to merge changes');
}
} catch (err) {
setError("Failed to merge changes");
setError('Failed to merge changes');
} finally {
setMerging(false);
}
@@ -182,7 +182,7 @@ export function TaskAttemptComparePage() {
const response = await makeRequest(
`/api/projects/${projectId}/tasks/${taskId}/attempts/${attemptId}/rebase`,
{
method: "POST",
method: 'POST',
}
);
@@ -194,27 +194,27 @@ export function TaskAttemptComparePage() {
fetchDiff();
fetchBranchStatus();
} else {
setError(result.message || "Failed to rebase branch");
setError(result.message || 'Failed to rebase branch');
}
} else {
setError("Failed to rebase branch");
setError('Failed to rebase branch');
}
} catch (err) {
setError("Failed to rebase branch");
setError('Failed to rebase branch');
} finally {
setRebasing(false);
}
};
const getChunkClassName = (chunkType: DiffChunkType) => {
const baseClass = "font-mono text-sm whitespace-pre py-1 flex";
const baseClass = 'font-mono text-sm whitespace-pre py-1 flex';
switch (chunkType) {
case "Insert":
case 'Insert':
return `${baseClass} bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border-l-2 border-green-400 dark:border-green-500`;
case "Delete":
case 'Delete':
return `${baseClass} bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border-l-2 border-red-400 dark:border-red-500`;
case "Equal":
case 'Equal':
default:
return `${baseClass} text-muted-foreground`;
}
@@ -222,13 +222,13 @@ export function TaskAttemptComparePage() {
const getChunkPrefix = (chunkType: DiffChunkType) => {
switch (chunkType) {
case "Insert":
return "+";
case "Delete":
return "-";
case "Equal":
case 'Insert':
return '+';
case 'Delete':
return '-';
case 'Equal':
default:
return " ";
return ' ';
}
};
@@ -240,7 +240,7 @@ export function TaskAttemptComparePage() {
}
interface ProcessedSection {
type: "context" | "change" | "expanded";
type: 'context' | 'change' | 'expanded';
lines: ProcessedLine[];
expandKey?: string;
expandedAbove?: boolean;
@@ -255,9 +255,9 @@ export function TaskAttemptComparePage() {
// Convert chunks to lines with line numbers
chunks.forEach((chunk) => {
const chunkLines = chunk.content.split("\n");
const chunkLines = chunk.content.split('\n');
chunkLines.forEach((line, index) => {
if (index < chunkLines.length - 1 || line !== "") {
if (index < chunkLines.length - 1 || line !== '') {
// Skip empty last line from split
const processedLine: ProcessedLine = {
content: line,
@@ -266,15 +266,15 @@ export function TaskAttemptComparePage() {
// Set line numbers based on chunk type
switch (chunk.chunk_type) {
case "Equal":
case 'Equal':
processedLine.oldLineNumber = oldLineNumber++;
processedLine.newLineNumber = newLineNumber++;
break;
case "Delete":
case 'Delete':
processedLine.oldLineNumber = oldLineNumber++;
// No new line number for deletions
break;
case "Insert":
case 'Insert':
processedLine.newLineNumber = newLineNumber++;
// No old line number for insertions
break;
@@ -291,12 +291,12 @@ export function TaskAttemptComparePage() {
while (i < lines.length) {
const line = lines[i];
if (line.chunkType === "Equal") {
if (line.chunkType === 'Equal') {
// Look for the next change or end of file
let nextChangeIndex = i + 1;
while (
nextChangeIndex < lines.length &&
lines[nextChangeIndex].chunkType === "Equal"
lines[nextChangeIndex].chunkType === 'Equal'
) {
nextChangeIndex++;
}
@@ -305,7 +305,7 @@ export function TaskAttemptComparePage() {
const hasNextChange = nextChangeIndex < lines.length;
const hasPrevChange =
sections.length > 0 &&
sections[sections.length - 1].type === "change";
sections[sections.length - 1].type === 'change';
if (
contextLength <= CONTEXT_LINES * 2 ||
@@ -314,7 +314,7 @@ export function TaskAttemptComparePage() {
) {
// Show all context if it's short, no changes around it, or global toggle is on
sections.push({
type: "context",
type: 'context',
lines: lines.slice(i, nextChangeIndex),
});
} else {
@@ -322,7 +322,7 @@ export function TaskAttemptComparePage() {
if (hasPrevChange) {
// Add context after previous change
sections.push({
type: "context",
type: 'context',
lines: lines.slice(i, i + CONTEXT_LINES),
});
i += CONTEXT_LINES;
@@ -335,17 +335,18 @@ export function TaskAttemptComparePage() {
if (expandEnd > expandStart) {
const expandKey = `${fileIndex}-${expandStart}-${expandEnd}`;
const isExpanded = expandedSections.has(expandKey) || showAllUnchanged;
const isExpanded =
expandedSections.has(expandKey) || showAllUnchanged;
if (isExpanded) {
sections.push({
type: "expanded",
type: 'expanded',
lines: lines.slice(expandStart, expandEnd),
expandKey,
});
} else {
sections.push({
type: "context",
type: 'context',
lines: [],
expandKey,
});
@@ -354,7 +355,7 @@ export function TaskAttemptComparePage() {
// Add context before next change
sections.push({
type: "context",
type: 'context',
lines: lines.slice(
nextChangeIndex - CONTEXT_LINES,
nextChangeIndex
@@ -363,7 +364,7 @@ export function TaskAttemptComparePage() {
} else if (!hasPrevChange) {
// No changes around, just show first few lines
sections.push({
type: "context",
type: 'context',
lines: lines.slice(i, i + CONTEXT_LINES),
});
}
@@ -373,12 +374,12 @@ export function TaskAttemptComparePage() {
} else {
// Found a change, collect all consecutive changes
const changeStart = i;
while (i < lines.length && lines[i].chunkType !== "Equal") {
while (i < lines.length && lines[i].chunkType !== 'Equal') {
i++;
}
sections.push({
type: "change",
type: 'change',
lines: lines.slice(changeStart, i),
});
}
@@ -413,7 +414,7 @@ export function TaskAttemptComparePage() {
fileToDelete
)}`,
{
method: "POST",
method: 'POST',
}
);
@@ -423,13 +424,13 @@ export function TaskAttemptComparePage() {
// Refresh the diff to show updated state
fetchDiff();
} else {
setError(result.message || "Failed to delete file");
setError(result.message || 'Failed to delete file');
}
} else {
setError("Failed to delete file");
setError('Failed to delete file');
}
} catch (err) {
setError("Failed to delete file");
setError('Failed to delete file');
} finally {
setDeletingFiles((prev) => {
const newSet = new Set(prev);
@@ -491,15 +492,17 @@ export function TaskAttemptComparePage() {
{branchStatus.up_to_date ? (
<span className="text-green-600">Up to date</span>
) : branchStatus.is_behind === true ? (
<span className="text-orange-600">
{branchStatus.commits_behind} commit
{branchStatus.commits_behind !== 1 ? "s" : ""} behind {branchStatus.base_branch_name}
</span>
<span className="text-orange-600">
{branchStatus.commits_behind} commit
{branchStatus.commits_behind !== 1 ? 's' : ''} behind{' '}
{branchStatus.base_branch_name}
</span>
) : (
<span className="text-blue-600">
{branchStatus.commits_ahead} commit
{branchStatus.commits_ahead !== 1 ? "s" : ""} ahead of {branchStatus.base_branch_name}
</span>
<span className="text-blue-600">
{branchStatus.commits_ahead} commit
{branchStatus.commits_ahead !== 1 ? 's' : ''} ahead of{' '}
{branchStatus.base_branch_name}
</span>
)}
</div>
{branchStatus.has_uncommitted_changes && (
@@ -535,9 +538,11 @@ export function TaskAttemptComparePage() {
className="border-orange-300 text-orange-700 hover:bg-orange-50"
>
<RefreshCw
className={`mr-2 h-4 w-4 ${rebasing ? "animate-spin" : ""}`}
className={`mr-2 h-4 w-4 ${rebasing ? 'animate-spin' : ''}`}
/>
{rebasing ? "Rebasing..." : `Rebase onto ${branchStatus.base_branch_name}`}
{rebasing
? 'Rebasing...'
: `Rebase onto ${branchStatus.base_branch_name}`}
</Button>
)}
{!branchStatus?.merged && (
@@ -551,7 +556,7 @@ export function TaskAttemptComparePage() {
}
className="bg-green-600 hover:bg-green-700 disabled:bg-gray-400"
>
{merging ? "Merging..." : "Merge Changes"}
{merging ? 'Merging...' : 'Merge Changes'}
</Button>
)}
</div>
@@ -566,8 +571,8 @@ export function TaskAttemptComparePage() {
Diff: Base Commit vs. Current Worktree
</CardTitle>
<p className="text-sm text-muted-foreground">
Shows changes made in the task attempt worktree compared to the base
commit
Shows changes made in the task attempt worktree compared to the
base commit
</p>
</div>
<Button
@@ -621,8 +626,8 @@ export function TaskAttemptComparePage() {
<Trash2 className="h-4 w-4" />
<span className="text-xs">
{deletingFiles.has(file.path)
? "Deleting..."
: "Delete File"}
? 'Deleting...'
: 'Delete File'}
</span>
</Button>
</div>
@@ -630,37 +635,37 @@ export function TaskAttemptComparePage() {
{processFileChunks(file.chunks, fileIndex).map(
(section, sectionIndex) => {
if (
section.type === "context" &&
section.lines.length === 0 &&
section.expandKey &&
section.type === 'context' &&
section.lines.length === 0 &&
section.expandKey &&
!showAllUnchanged
) {
// Render expand button (only when global toggle is off)
const lineCount =
parseInt(section.expandKey.split("-")[2]) -
parseInt(section.expandKey.split("-")[1]);
return (
<div key={`expand-${section.expandKey}`}>
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleExpandSection(section.expandKey!)
}
className="w-full h-8 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none"
>
<ChevronDown className="h-3 w-3 mr-1" />
Show {lineCount} more lines
</Button>
</div>
// Render expand button (only when global toggle is off)
const lineCount =
parseInt(section.expandKey.split('-')[2]) -
parseInt(section.expandKey.split('-')[1]);
return (
<div key={`expand-${section.expandKey}`}>
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleExpandSection(section.expandKey!)
}
className="w-full h-8 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-950/50 border-t border-b border-gray-200 dark:border-gray-700 rounded-none"
>
<ChevronDown className="h-3 w-3 mr-1" />
Show {lineCount} more lines
</Button>
</div>
);
}
}
// Render lines (context, change, or expanded)
return (
<div key={`section-${sectionIndex}`}>
{section.type === "expanded" &&
section.expandKey &&
{section.type === 'expanded' &&
section.expandKey &&
!showAllUnchanged && (
<Button
variant="ghost"
@@ -681,10 +686,10 @@ export function TaskAttemptComparePage() {
>
<div className="flex-shrink-0 w-16 px-2 text-xs text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 select-none">
<span className="inline-block w-6 text-right">
{line.oldLineNumber || ""}
{line.oldLineNumber || ''}
</span>
<span className="inline-block w-6 text-right ml-1">
{line.newLineNumber || ""}
{line.newLineNumber || ''}
</span>
</div>
<div className="flex-1 px-3">
@@ -713,7 +718,7 @@ export function TaskAttemptComparePage() {
<DialogHeader>
<DialogTitle>Delete File</DialogTitle>
<DialogDescription>
Are you sure you want to delete the file{" "}
Are you sure you want to delete the file{' '}
<span className="font-mono font-medium">"{fileToDelete}"</span>?
</DialogDescription>
</DialogHeader>
@@ -732,35 +737,44 @@ export function TaskAttemptComparePage() {
<Button
variant="destructive"
onClick={handleConfirmDelete}
disabled={deletingFiles.has(fileToDelete || "")}
disabled={deletingFiles.has(fileToDelete || '')}
>
{deletingFiles.has(fileToDelete || "")
? "Deleting..."
: "Delete File"}
{deletingFiles.has(fileToDelete || '')
? 'Deleting...'
: 'Delete File'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Uncommitted Changes Warning Dialog */}
<Dialog open={showUncommittedWarning} onOpenChange={() => handleCancelMergeWithUncommitted()}>
<Dialog
open={showUncommittedWarning}
onOpenChange={() => handleCancelMergeWithUncommitted()}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Uncommitted Changes Detected</DialogTitle>
<DialogDescription>
There are uncommitted changes in the worktree that will be included in the merge.
There are uncommitted changes in the worktree that will be
included in the merge.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-3">
<p className="text-sm text-yellow-800">
<strong>Warning:</strong> The worktree contains uncommitted changes (modified, added, or deleted files)
that have not been committed to git. These changes will be permanently merged into the {branchStatus?.base_branch_name || 'base'} branch.
<strong>Warning:</strong> The worktree contains uncommitted
changes (modified, added, or deleted files) that have not been
committed to git. These changes will be permanently merged into
the {branchStatus?.base_branch_name || 'base'} branch.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelMergeWithUncommitted}>
<Button
variant="outline"
onClick={handleCancelMergeWithUncommitted}
>
Cancel
</Button>
<Button
@@ -768,7 +782,7 @@ export function TaskAttemptComparePage() {
disabled={merging}
className="bg-yellow-600 hover:bg-yellow-700"
>
{merging ? "Merging..." : "Merge Anyway"}
{merging ? 'Merging...' : 'Merge Anyway'}
</Button>
</DialogFooter>
</DialogContent>