feat: local executor and worktree + new task dialog (#211)
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { memo, useState } from "react"
|
||||
import { IconCheck, IconSelector } from "@tabler/icons-react"
|
||||
import { IconCheck, IconChevronDown } from "@tabler/icons-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@kandev/ui/button"
|
||||
@@ -37,6 +37,7 @@ interface ComboboxProps {
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
triggerClassName?: string
|
||||
showSearch?: boolean
|
||||
}
|
||||
|
||||
export const Combobox = memo(function Combobox({
|
||||
@@ -50,6 +51,7 @@ export const Combobox = memo(function Combobox({
|
||||
disabled = false,
|
||||
className,
|
||||
triggerClassName,
|
||||
showSearch = true,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -59,7 +61,7 @@ export const Combobox = memo(function Combobox({
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", !disabled && "cursor-pointer", triggerClassName)}
|
||||
@@ -72,17 +74,17 @@ export const Combobox = memo(function Combobox({
|
||||
<span className="truncate">{selectedOption?.label || placeholder}</span>
|
||||
)}
|
||||
</div>
|
||||
<IconSelector className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<IconChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className={cn("w-[var(--radix-popover-trigger-width)] max-w-none p-0", className)} align="start">
|
||||
<PopoverContent className={cn("w-[var(--radix-popover-trigger-width)] min-w-[300px] max-w-none p-0", className)} align="start">
|
||||
<Command>
|
||||
{dropdownLabel ? (
|
||||
<div className="text-muted-foreground px-2 py-1.5 text-xs border-b">
|
||||
{dropdownLabel}
|
||||
</div>
|
||||
) : null}
|
||||
<CommandInput placeholder={searchPlaceholder} className="h-9" />
|
||||
{showSearch && <CommandInput placeholder={searchPlaceholder} className="h-9" />}
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyMessage}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Switch } from '@kandev/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
import { ModelCombobox } from '@/components/settings/model-combobox';
|
||||
import type { ModelConfig, PermissionSetting, PassthroughConfig } from '@/lib/types/http';
|
||||
|
||||
|
||||
@@ -218,6 +218,32 @@ export function SettingsAppSidebar() {
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Executors */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Executors">
|
||||
<Link href="/settings/executors">
|
||||
<IconCpu className="h-4 w-4" />
|
||||
<span>Executors</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{executors.length > 0 && (
|
||||
<SidebarMenuSub className="ml-3 mt-1">
|
||||
{executors.map((executor: Executor) => (
|
||||
<SidebarMenuSubItem key={executor.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === `/settings/executor/${executor.id}`}
|
||||
>
|
||||
<Link href={`/settings/executor/${executor.id}`}>
|
||||
<span>{executor.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Environments */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Environments">
|
||||
@@ -243,34 +269,6 @@ export function SettingsAppSidebar() {
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
|
||||
{/* Executors */}
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild tooltip="Executors">
|
||||
<Link href="/settings/executors">
|
||||
<IconCpu className="h-4 w-4" />
|
||||
<span>Executors</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{executors.filter((executor: Executor) => executor.type !== 'remote_docker').length > 0 && (
|
||||
<SidebarMenuSub className="ml-3 mt-1">
|
||||
{executors
|
||||
.filter((executor: Executor) => executor.type !== 'remote_docker')
|
||||
.map((executor: Executor) => (
|
||||
<SidebarMenuSubItem key={executor.id}>
|
||||
<SidebarMenuSubButton
|
||||
asChild
|
||||
isActive={pathname === `/settings/executor/${executor.id}`}
|
||||
>
|
||||
<Link href={`/settings/executor/${executor.id}`}>
|
||||
<span>{executor.name}</span>
|
||||
</Link>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
))}
|
||||
</SidebarMenuSub>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState, FormEvent, memo, useMemo, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconSettings, IconLoader2 } from '@tabler/icons-react';
|
||||
import { IconLoader2, IconGitBranch } from '@tabler/icons-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,13 +16,6 @@ import { Label } from '@kandev/ui/label';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kandev/ui/select';
|
||||
import { Combobox } from './combobox';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@kandev/ui/tooltip';
|
||||
@@ -49,6 +42,7 @@ import { discoverRepositoriesAction, listLocalRepositoryBranchesAction } from '@
|
||||
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
|
||||
import { STORAGE_KEYS } from '@/lib/settings/constants';
|
||||
import { linkToSession } from '@/lib/links';
|
||||
import { getExecutorIcon } from '@/lib/executor-icons';
|
||||
|
||||
interface TaskCreateDialogProps {
|
||||
open: boolean;
|
||||
@@ -93,6 +87,7 @@ type RepositorySelectorProps = {
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
emptyMessage: string;
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
const RepositorySelector = memo(function RepositorySelector({
|
||||
@@ -103,6 +98,7 @@ const RepositorySelector = memo(function RepositorySelector({
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
emptyMessage,
|
||||
triggerClassName,
|
||||
}: RepositorySelectorProps) {
|
||||
return (
|
||||
<Combobox
|
||||
@@ -115,6 +111,7 @@ const RepositorySelector = memo(function RepositorySelector({
|
||||
disabled={disabled}
|
||||
dropdownLabel="Repository"
|
||||
className={disabled ? undefined : 'cursor-pointer'}
|
||||
triggerClassName={triggerClassName}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -192,9 +189,76 @@ const AgentSelector = memo(function AgentSelector({
|
||||
);
|
||||
});
|
||||
|
||||
type ExecutorSelectorProps = {
|
||||
options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
disabled: boolean;
|
||||
placeholder: string;
|
||||
triggerClassName?: string;
|
||||
};
|
||||
|
||||
const ExecutorSelector = memo(function ExecutorSelector({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
triggerClassName,
|
||||
}: ExecutorSelectorProps) {
|
||||
return (
|
||||
<Combobox
|
||||
options={options}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
placeholder={placeholder}
|
||||
emptyMessage="No executor found."
|
||||
disabled={disabled}
|
||||
dropdownLabel="Executor"
|
||||
className={disabled ? undefined : 'cursor-pointer'}
|
||||
triggerClassName={triggerClassName}
|
||||
showSearch={false}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type InlineTaskNameProps = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
const InlineTaskName = memo(function InlineTaskName({
|
||||
value,
|
||||
onChange,
|
||||
autoFocus,
|
||||
}: InlineTaskNameProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [autoFocus]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="task-name"
|
||||
size={Math.max(value.length, 9)}
|
||||
className="bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 rounded-md px-1.5 py-0.5 -mx-1.5 hover:bg-muted focus:bg-muted transition-colors"
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// Memoized text inputs to prevent re-rendering the entire dialog on every keystroke
|
||||
type TaskFormInputsProps = {
|
||||
isSessionMode: boolean;
|
||||
showTitleInput: boolean;
|
||||
initialTitle: string;
|
||||
initialDescription: string;
|
||||
onTitleChange: (hasContent: boolean) => void;
|
||||
@@ -206,6 +270,7 @@ type TaskFormInputsProps = {
|
||||
|
||||
const TaskFormInputs = memo(function TaskFormInputs({
|
||||
isSessionMode,
|
||||
showTitleInput,
|
||||
initialTitle,
|
||||
initialDescription,
|
||||
onTitleChange,
|
||||
@@ -265,10 +330,9 @@ const TaskFormInputs = memo(function TaskFormInputs({
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isSessionMode && (
|
||||
{showTitleInput && !isSessionMode && (
|
||||
<div>
|
||||
<Input
|
||||
autoFocus
|
||||
required
|
||||
placeholder="Enter task title..."
|
||||
value={title}
|
||||
@@ -277,29 +341,15 @@ const TaskFormInputs = memo(function TaskFormInputs({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isSessionMode && (
|
||||
<div>
|
||||
<Label htmlFor="task-title">Task</Label>
|
||||
<Input
|
||||
id="task-title"
|
||||
value={title}
|
||||
disabled
|
||||
placeholder="Task"
|
||||
className="bg-muted cursor-not-allowed mt-1.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
{isSessionMode && <Label htmlFor="prompt">Prompt</Label>}
|
||||
<Textarea
|
||||
id={isSessionMode ? 'prompt' : undefined}
|
||||
ref={textareaRef}
|
||||
placeholder={isSessionMode ? 'Describe what you want the agent to do...' : 'Write a prompt for the agent...'}
|
||||
value={description}
|
||||
onChange={handleDescriptionChange}
|
||||
onKeyDown={onKeyDown}
|
||||
rows={2}
|
||||
className={isSessionMode ? 'min-h-[120px] max-h-[240px] resize-none overflow-auto mt-1.5' : 'min-h-[96px] max-h-[240px] resize-y overflow-auto'}
|
||||
className={isSessionMode ? 'min-h-[120px] max-h-[240px] resize-none overflow-auto' : 'min-h-[96px] max-h-[240px] resize-y overflow-auto'}
|
||||
required={isSessionMode}
|
||||
/>
|
||||
</div>
|
||||
@@ -326,8 +376,10 @@ export function TaskCreateDialog({
|
||||
const router = useRouter();
|
||||
const isSessionMode = mode === 'session';
|
||||
const isEditMode = submitLabel !== 'Create';
|
||||
const isCreateMode = !isEditMode && !isSessionMode;
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
||||
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
||||
const [taskName, setTaskName] = useState('');
|
||||
// Track only whether inputs have content (not the actual values) to minimize re-renders
|
||||
const [hasTitle, setHasTitle] = useState(Boolean(initialValues?.title?.trim()));
|
||||
const [hasDescription, setHasDescription] = useState(Boolean(initialValues?.description?.trim()));
|
||||
@@ -340,7 +392,6 @@ export function TaskCreateDialog({
|
||||
const [agentProfileId, setAgentProfileId] = useState('');
|
||||
const [environmentId, setEnvironmentId] = useState('');
|
||||
const [executorId, setExecutorId] = useState('');
|
||||
const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
|
||||
const [discoveredRepositories, setDiscoveredRepositories] = useState<LocalRepository[]>([]);
|
||||
const [discoveredRepoPath, setDiscoveredRepoPath] = useState('');
|
||||
const [selectedLocalRepo, setSelectedLocalRepo] = useState<LocalRepository | null>(null);
|
||||
@@ -353,6 +404,21 @@ export function TaskCreateDialog({
|
||||
const executors = useAppStore((state) => state.executors.items);
|
||||
const environments = useAppStore((state) => state.environments.items);
|
||||
const settingsData = useAppStore((state) => state.settingsData);
|
||||
// Derive repository name from store for session mode header
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const kanbanTasks = useAppStore((state) => state.kanban.tasks);
|
||||
const reposByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const sessionRepoName = useMemo(() => {
|
||||
if (!isSessionMode) return undefined;
|
||||
const activeTask = activeTaskId ? kanbanTasks.find((t) => t.id === activeTaskId) : null;
|
||||
const repoId = activeTask?.repositoryId;
|
||||
if (!repoId) return undefined;
|
||||
for (const repos of Object.values(reposByWorkspace)) {
|
||||
const repo = repos.find((r) => r.id === repoId);
|
||||
if (repo) return repo.name;
|
||||
}
|
||||
return undefined;
|
||||
}, [isSessionMode, activeTaskId, kanbanTasks, reposByWorkspace]);
|
||||
const { toast } = useToast();
|
||||
useSettingsData(open);
|
||||
const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, open);
|
||||
@@ -361,12 +427,17 @@ export function TaskCreateDialog({
|
||||
Boolean(open && repositoryId)
|
||||
);
|
||||
const agentProfilesLoading = open && !settingsData.agentsLoaded;
|
||||
const environmentsLoading = open && !settingsData.environmentsLoaded;
|
||||
const executorsLoading = open && !settingsData.executorsLoaded;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setHasTitle(Boolean(initialValues?.title?.trim()));
|
||||
if (isCreateMode) {
|
||||
const name = initialValues?.title || '';
|
||||
setTaskName(name);
|
||||
setHasTitle(name.trim().length > 0);
|
||||
} else {
|
||||
setHasTitle(Boolean(initialValues?.title?.trim()));
|
||||
}
|
||||
setHasDescription(Boolean(initialValues?.description?.trim()));
|
||||
setRepositoryId(initialValues?.repositoryId ?? '');
|
||||
setBranch(initialValues?.branch ?? '');
|
||||
@@ -375,6 +446,7 @@ export function TaskCreateDialog({
|
||||
setEnvironmentId('');
|
||||
setExecutorId('');
|
||||
}, [
|
||||
isCreateMode,
|
||||
isEditMode,
|
||||
initialValues?.branch,
|
||||
initialValues?.description,
|
||||
@@ -495,6 +567,12 @@ export function TaskCreateDialog({
|
||||
setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value);
|
||||
}, []);
|
||||
|
||||
// Memoized callback for InlineTaskName
|
||||
const handleTaskNameChange = useCallback((value: string) => {
|
||||
setTaskName(value);
|
||||
setHasTitle(value.trim().length > 0);
|
||||
}, []);
|
||||
|
||||
// Memoized callback for BranchSelector to save selection to localStorage
|
||||
const handleBranchChange = useCallback((value: string) => {
|
||||
setBranch(value);
|
||||
@@ -550,6 +628,16 @@ export function TaskCreateDialog({
|
||||
];
|
||||
}, [repositories, discoveredRepositories]);
|
||||
|
||||
// Header-only repository options: name only, no path badge
|
||||
const headerRepositoryOptions = useMemo(() => {
|
||||
return repositoryOptions.map((opt) => ({
|
||||
...opt,
|
||||
renderLabel: () => (
|
||||
<span className="truncate">{opt.label}</span>
|
||||
),
|
||||
}));
|
||||
}, [repositoryOptions]);
|
||||
|
||||
// Memoize branch options to prevent re-renders when typing
|
||||
const branchOptions = useMemo(() => {
|
||||
return branchOptionsRaw.map((branchObj: Branch) => {
|
||||
@@ -562,10 +650,13 @@ export function TaskCreateDialog({
|
||||
label: displayName,
|
||||
renderLabel: () => (
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<span className="truncate" title={displayName}>
|
||||
{displayName}
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<IconGitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate" title={displayName}>
|
||||
{displayName}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={branchObj.type === 'local' ? 'default' : 'secondary'} className="text-xs">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{branchObj.type === 'local' ? 'local' : branchObj.remote || 'remote'}
|
||||
</Badge>
|
||||
</span>
|
||||
@@ -587,7 +678,7 @@ export function TaskCreateDialog({
|
||||
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<span className="truncate">{agentLabel}</span>
|
||||
{profileLabel ? (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{profileLabel}
|
||||
</Badge>
|
||||
) : null}
|
||||
@@ -597,6 +688,29 @@ export function TaskCreateDialog({
|
||||
});
|
||||
}, [agentProfiles]);
|
||||
|
||||
const executorOptions = useMemo(() => {
|
||||
return executors.map((executor: Executor) => {
|
||||
const Icon = getExecutorIcon(executor.type);
|
||||
return {
|
||||
value: executor.id,
|
||||
label: executor.name,
|
||||
renderLabel: () => (
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{executor.name}</span>
|
||||
</span>
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [executors]);
|
||||
|
||||
const executorHint = useMemo(() => {
|
||||
const selectedExecutor = executors.find((e: Executor) => e.id === executorId);
|
||||
if (selectedExecutor?.type === 'worktree') return 'A git worktree will be created from the base branch.';
|
||||
if (selectedExecutor?.type === 'local') return 'The agent will run directly on the repository.';
|
||||
return null;
|
||||
}, [executors, executorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId || !selectedLocalRepo) return;
|
||||
setLocalBranchesLoading(true);
|
||||
@@ -719,7 +833,6 @@ export function TaskCreateDialog({
|
||||
setAgentProfileId('');
|
||||
setExecutorId('');
|
||||
setEnvironmentId('');
|
||||
setShowAdvancedSettings(false);
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
@@ -760,7 +873,6 @@ export function TaskCreateDialog({
|
||||
setAgentProfileId('');
|
||||
setExecutorId('');
|
||||
setEnvironmentId('');
|
||||
setShowAdvancedSettings(false);
|
||||
|
||||
// Navigate to the new session (this will unmount the dialog)
|
||||
if (newSessionId) {
|
||||
@@ -781,7 +893,9 @@ export function TaskCreateDialog({
|
||||
}
|
||||
|
||||
// Task mode - create or edit task
|
||||
const trimmedTitle = title.trim();
|
||||
const trimmedTitle = isEditMode
|
||||
? title.trim()
|
||||
: taskName.trim();
|
||||
if (!trimmedTitle) return;
|
||||
if (isEditMode && editingTask) {
|
||||
try {
|
||||
@@ -891,6 +1005,7 @@ export function TaskCreateDialog({
|
||||
// Reset form and close dialog
|
||||
setHasTitle(false);
|
||||
setHasDescription(false);
|
||||
setTaskName('');
|
||||
setRepositoryId('');
|
||||
setBranch('');
|
||||
setStartAgent(!isEditMode);
|
||||
@@ -918,6 +1033,7 @@ export function TaskCreateDialog({
|
||||
const handleCancel = () => {
|
||||
setHasTitle(false);
|
||||
setHasDescription(false);
|
||||
setTaskName('');
|
||||
setRepositoryId('');
|
||||
setBranch('');
|
||||
setStartAgent(!isEditMode);
|
||||
@@ -931,15 +1047,57 @@ export function TaskCreateDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col bg-card">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isSessionMode ? 'Create New Session' : submitLabel === 'Create' ? 'Create Task' : 'Edit Task'}
|
||||
</DialogTitle>
|
||||
{isCreateMode ? (
|
||||
<DialogTitle asChild>
|
||||
<div className="flex items-center gap-1 min-w-0 text-sm font-medium">
|
||||
<RepositorySelector
|
||||
options={headerRepositoryOptions}
|
||||
value={repositoryId || discoveredRepoPath}
|
||||
onValueChange={handleRepositoryChange}
|
||||
placeholder={
|
||||
!workspaceId
|
||||
? 'Select workspace first'
|
||||
: repositoriesLoading || discoverReposLoading
|
||||
? 'Loading...'
|
||||
: 'Select repository'
|
||||
}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage={
|
||||
repositoriesLoading || discoverReposLoading
|
||||
? 'Loading repositories...'
|
||||
: 'No repositories found.'
|
||||
}
|
||||
disabled={!workspaceId || repositoriesLoading || discoverReposLoading}
|
||||
triggerClassName="w-auto text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground mr-2">/</span>
|
||||
<InlineTaskName value={taskName} onChange={handleTaskNameChange} autoFocus />
|
||||
</div>
|
||||
</DialogTitle>
|
||||
) : isSessionMode ? (
|
||||
<DialogTitle asChild>
|
||||
<div className="flex items-center gap-1 min-w-0 text-sm font-medium">
|
||||
{sessionRepoName && (
|
||||
<>
|
||||
<span className="truncate text-muted-foreground">{sessionRepoName}</span>
|
||||
<span className="text-muted-foreground mx-0.5">/</span>
|
||||
</>
|
||||
)}
|
||||
<span className="truncate">{initialValues?.title || 'Task'}</span>
|
||||
<span className="text-muted-foreground mx-0.5">/</span>
|
||||
<span className="text-muted-foreground whitespace-nowrap">new session</span>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
) : (
|
||||
<DialogTitle>Edit Task</DialogTitle>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
<TaskFormInputs
|
||||
key={`${open}-${initialValues?.title ?? ''}-${initialValues?.description ?? ''}`}
|
||||
isSessionMode={isSessionMode}
|
||||
showTitleInput={isEditMode}
|
||||
initialTitle={initialValues?.title ?? ''}
|
||||
initialDescription={initialValues?.description ?? ''}
|
||||
onTitleChange={setHasTitle}
|
||||
@@ -949,28 +1107,30 @@ export function TaskCreateDialog({
|
||||
descriptionValueRef={descriptionInputRef}
|
||||
/>
|
||||
{!isSessionMode && (
|
||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-3">
|
||||
<div>
|
||||
<RepositorySelector
|
||||
options={repositoryOptions}
|
||||
value={repositoryId || discoveredRepoPath}
|
||||
onValueChange={handleRepositoryChange}
|
||||
placeholder={
|
||||
!workspaceId
|
||||
? 'Select workspace first'
|
||||
: repositoriesLoading || discoverReposLoading
|
||||
<div className={`grid gap-4 grid-cols-1 ${isEditMode ? 'sm:grid-cols-4' : 'sm:grid-cols-3'}`}>
|
||||
{isEditMode && (
|
||||
<div>
|
||||
<RepositorySelector
|
||||
options={repositoryOptions}
|
||||
value={repositoryId || discoveredRepoPath}
|
||||
onValueChange={handleRepositoryChange}
|
||||
placeholder={
|
||||
!workspaceId
|
||||
? 'Select workspace first'
|
||||
: repositoriesLoading || discoverReposLoading
|
||||
? 'Loading repositories...'
|
||||
: 'Select repository'
|
||||
}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage={
|
||||
repositoriesLoading || discoverReposLoading
|
||||
? 'Loading repositories...'
|
||||
: 'Select repository'
|
||||
}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage={
|
||||
repositoriesLoading || discoverReposLoading
|
||||
? 'Loading repositories...'
|
||||
: 'No repositories found.'
|
||||
}
|
||||
disabled={!workspaceId || repositoriesLoading || discoverReposLoading || isEditMode}
|
||||
/>
|
||||
</div>
|
||||
: 'No repositories found.'
|
||||
}
|
||||
disabled={!workspaceId || repositoriesLoading || discoverReposLoading || isEditMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<BranchSelector
|
||||
options={branchOptions}
|
||||
@@ -1025,139 +1185,44 @@ export function TaskCreateDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{startAgent && (
|
||||
<div>
|
||||
<ExecutorSelector
|
||||
options={executorOptions}
|
||||
value={executorId}
|
||||
onValueChange={setExecutorId}
|
||||
placeholder={
|
||||
executorsLoading
|
||||
? 'Loading executors...'
|
||||
: 'Select executor'
|
||||
}
|
||||
disabled={executorsLoading || isEditMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent Profile - shown in session mode */}
|
||||
{/* Agent Profile + Executor - shown in session mode */}
|
||||
{isSessionMode && (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<AgentSelector
|
||||
options={agentProfileOptions}
|
||||
value={agentProfileId}
|
||||
onValueChange={handleAgentProfileChange}
|
||||
placeholder={agentProfilesLoading ? 'Loading agent profiles...' : 'Select agent profile'}
|
||||
disabled={agentProfilesLoading || isCreatingSession}
|
||||
triggerClassName="w-[280px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* More Options Toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
<IconSettings className="h-4 w-4" />
|
||||
More options
|
||||
</button>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<AgentSelector
|
||||
options={agentProfileOptions}
|
||||
value={agentProfileId}
|
||||
onValueChange={handleAgentProfileChange}
|
||||
placeholder={agentProfilesLoading ? 'Loading agent profiles...' : 'Select agent profile'}
|
||||
disabled={agentProfilesLoading || isCreatingSession}
|
||||
/>
|
||||
<ExecutorSelector
|
||||
options={executorOptions}
|
||||
value={executorId}
|
||||
onValueChange={setExecutorId}
|
||||
placeholder={executorsLoading ? 'Loading executors...' : 'Select executor'}
|
||||
disabled={executorsLoading || isCreatingSession}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isSessionMode && startAgent && (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedSettings(!showAdvancedSettings)}
|
||||
className="text-muted-foreground cursor-pointer"
|
||||
>
|
||||
<IconSettings className="h-4 w-4 mr-1" />
|
||||
{showAdvancedSettings ? 'Hide' : 'More Options'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showAdvancedSettings && isSessionMode && (
|
||||
<div className="pt-2 border-t">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="environment" className="text-sm whitespace-nowrap">
|
||||
Environment
|
||||
</Label>
|
||||
<Select value={environmentId} onValueChange={setEnvironmentId}>
|
||||
<SelectTrigger id="environment" className="w-full">
|
||||
<SelectValue placeholder={environmentsLoading ? 'Loading environments...' : 'Select environment'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{environments.map((env: Environment) => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
{env.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label htmlFor="executor" className="text-sm whitespace-nowrap">
|
||||
Executor
|
||||
</Label>
|
||||
<Select value={executorId} onValueChange={setExecutorId}>
|
||||
<SelectTrigger id="executor" className="w-full">
|
||||
<SelectValue placeholder={executorsLoading ? 'Loading executors...' : 'Select executor'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{executors.map((executor: Executor) => (
|
||||
<SelectItem key={executor.id} value={executor.id}>
|
||||
{executor.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showAdvancedSettings && !isSessionMode && startAgent && (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm whitespace-nowrap">Environment</Label>
|
||||
<Select value={environmentId} onValueChange={setEnvironmentId} disabled={isEditMode}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
environmentsLoading
|
||||
? 'Loading environments...'
|
||||
: environments.length === 0
|
||||
? 'No environments available'
|
||||
: 'Select environment'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{environments.map((environment: Environment) => (
|
||||
<SelectItem key={environment.id} value={environment.id}>
|
||||
{environment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Label className="text-sm whitespace-nowrap">Executor</Label>
|
||||
<Select value={executorId} onValueChange={setExecutorId} disabled={isEditMode}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
executorsLoading
|
||||
? 'Loading executors...'
|
||||
: executors.length === 0
|
||||
? 'No executors available'
|
||||
: 'Select executor'
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{executors.map((executor: Executor) => (
|
||||
<SelectItem key={executor.id} value={executor.id}>
|
||||
{executor.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
|
||||
{!isSessionMode && (
|
||||
@@ -1185,9 +1250,9 @@ export function TaskCreateDialog({
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
{startAgent && (
|
||||
{startAgent && executorHint && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
A git worktree will be created from the base branch.
|
||||
{executorHint}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ export const ImageAttachmentPreview = memo(function ImageAttachmentPreview({
|
||||
disabled && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
{/* Image thumbnail */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- base64/blob preview URLs are not compatible with next/image */}
|
||||
<img
|
||||
src={attachment.preview}
|
||||
alt="Attachment preview"
|
||||
|
||||
@@ -151,6 +151,7 @@ export const ChatMessage = memo(function ChatMessage({ comment, label, className
|
||||
{hasAttachments && (
|
||||
<div className={cn('flex flex-wrap gap-2', hasContent && 'mb-2')}>
|
||||
{imageAttachments.map((att, index) => (
|
||||
/* eslint-disable-next-line @next/next/no-img-element -- base64 data URLs are not compatible with next/image */
|
||||
<img
|
||||
key={index}
|
||||
src={`data:${att.mime_type};base64,${att.data}`}
|
||||
|
||||
@@ -23,7 +23,7 @@ type PermissionRequestMetadata = {
|
||||
path?: string;
|
||||
cwd?: string;
|
||||
};
|
||||
status?: 'pending' | 'approved' | 'rejected';
|
||||
status?: 'pending' | 'approved' | 'rejected' | 'expired';
|
||||
};
|
||||
|
||||
type PermissionRequestMessageProps = {
|
||||
@@ -34,7 +34,7 @@ export function PermissionRequestMessage({ comment }: PermissionRequestMessagePr
|
||||
const [isResponding, setIsResponding] = useState(false);
|
||||
const metadata = comment.metadata as PermissionRequestMetadata | undefined;
|
||||
|
||||
const isResolved = metadata?.status === 'approved' || metadata?.status === 'rejected';
|
||||
const isResolved = metadata?.status === 'approved' || metadata?.status === 'rejected' || metadata?.status === 'expired';
|
||||
const isPending = !isResolved;
|
||||
|
||||
const handleRespond = useCallback(
|
||||
@@ -99,6 +99,13 @@ export function PermissionRequestMessage({ comment }: PermissionRequestMessagePr
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (metadata?.status === 'expired') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||
Expired
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
Pending Approval
|
||||
|
||||
@@ -48,6 +48,9 @@ function mergeTreeNodes(existing: FileTreeNode, incoming: FileTreeNode): FileTre
|
||||
return { ...existing, ...incoming, children: mergedChildren };
|
||||
}
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 4;
|
||||
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
|
||||
|
||||
type FileBrowserProps = {
|
||||
sessionId: string;
|
||||
onOpenFile: (file: OpenFileTab) => void;
|
||||
@@ -87,9 +90,6 @@ export function FileBrowser({ sessionId, onOpenFile, onDeleteFile, isSearchOpen
|
||||
const retryTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const loadInFlightRef = useRef(false);
|
||||
|
||||
const MAX_RETRY_ATTEMPTS = 4;
|
||||
const RETRY_DELAYS_MS = [1000, 2000, 5000, 10000];
|
||||
|
||||
// Workspace path for header
|
||||
const fullPath = session?.worktree_path ?? '';
|
||||
const displayPath = fullPath.replace(/^\/(?:Users|home)\/[^/]+\//, '~/');
|
||||
|
||||
@@ -21,6 +21,7 @@ export function FileImageViewer({ path, content, worktreePath }: FileImageViewer
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center overflow-auto p-6">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- dynamic blob/data URLs from the agent workspace */}
|
||||
<img
|
||||
src={src}
|
||||
alt={path}
|
||||
|
||||
Reference in New Issue
Block a user