chore: fmt frontend
This commit is contained in:
25
frontend/.eslintrc.json
Normal file
25
frontend/.eslintrc.json
Normal 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"
|
||||
}
|
||||
}
|
||||
8
frontend/.prettierrc.json
Normal file
8
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user