Files
vibe-kanban/frontend/src/pages/settings/AgentSettings.tsx
Louis Knight-Webb 81b92c6ec3 Resolve frontend warnings final (#1319)
* ##  Complete - First 3 Categories Fixed

Successfully resolved **13 of 26 TypeScript warnings** (50% reduction):

### **Changes Made:**

**Category 1: Intl Polyfill (1 warning fixed)**
- [TaskPanel.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/components/panels/TaskPanel.tsx#L39-L42): Replaced `(Intl as any).RelativeTimeFormat` with proper feature detection

**Category 2: Lexical Transformers (5 warnings fixed)**
- [wysiwyg.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/components/ui/wysiwyg.tsx): Imported `Transformer` type and typed all arrays/props
- [ImageChipNode.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/components/ui/wysiwyg/ImageChipNode.tsx): Used `unknown` with type assertion for `importJSON`

**Category 3: MCP Config JSON (7 warnings fixed)**
- [mcp-strategies.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/lib/mcp-strategies.ts):
  - Added `JsonObject` type and `isJsonObject` guard using existing `JsonValue` from shared/types
  - Updated all 4 methods with proper types and runtime guards
  - Fixed edge case for empty `servers_path` in `addPreconfiguredToConfig`

### **Verification:**
-  `pnpm run lint` - Warnings reduced from 26 → 13
-  `pnpm run check` - TypeScript compilation passes
-  Rust backend lint passes

### **Remaining Warnings (13):**
- GitOperations.tsx: 3 warnings (branch merge parsing)
- ClickedElementsBanner.tsx: 1 warning
- useFollowUpSend.ts: 1 warning
- usePinnedTodos.ts: 1 warning
- useTaskMutations.ts: 2 warnings
- project-tasks.tsx: 3 warnings
- AgentSettings.tsx: 2 warnings

* Excellent! All warnings are resolved. Let me create a summary:

##  Complete - Categories 4-6 Fixed (All 26 warnings resolved!)

Successfully resolved **all 26 TypeScript warnings** (100% completion):

### **Category 4: Git Merge Data** (3 warnings fixed)
- [GitOperations.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/components/tasks/Toolbar/GitOperations.tsx): Imported `Merge` type from shared/types and properly typed merge filtering/finding operations

### **Category 5: Error Handlers** (5 warnings fixed)
- [useFollowUpSend.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/hooks/follow-up/useFollowUpSend.ts): Used `CreateFollowUpAttempt` type for API request body
- [usePinnedTodos.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/hooks/usePinnedTodos.ts): Used `NormalizedEntry` type from shared/types
- [useTaskMutations.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/hooks/useTaskMutations.ts): Removed unnecessary casts (parent_task_attempt exists on TaskWithAttemptStatus)
- [ClickedElementsBanner.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/components/tasks/ClickedElementsBanner.tsx): Defined `ComponentInfo` type derived from ClickedEntry

### **Category 6: Settings Page** (2 warnings fixed)
- [AgentSettings.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/pages/settings/AgentSettings.tsx):
  - Defined `ProfilesDoc` type for parsed profiles structure
  - Changed `selectedExecutorType` from `string` to `BaseCodingAgent` enum

### **Additional Fixes:**
- [project-tasks.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/pages/project-tasks.tsx): Added proper types for DiffsPanelContainer props (TaskAttempt, BranchStatus)

### **Verification:**
-  `pnpm run lint` - **0 warnings** (reduced from 26)
-  `pnpm run check` - TypeScript compilation passes
-  Rust backend lint passes

**All frontend TypeScript warnings have been eliminated while maintaining code readability and type safety!**

* ##  Complete - ExecutorConfigs Added as Shared Type

Successfully replaced the local `ProfilesDoc` type with the backend-generated `ExecutorConfigs` type.

### **Changes Made:**

**Backend:**
- [generate_types.rs](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/crates/server/src/bin/generate_types.rs#L108): Added `ExecutorConfigs` to exported types
- [shared/types.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/shared/types.ts#L225): Generated new `ExecutorConfigs` type

**Frontend:**
- [AgentSettings.tsx](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/pages/settings/AgentSettings.tsx):
  - Removed local `ProfilesDoc` type
  - Imported and used `ExecutorConfigs` from shared/types
  - Added `ExecutorsMap` helper type for accessing the nested structure with string keys
  - Used `as unknown as ExecutorsMap` to safely access dynamic executor configurations

### **Benefits:**
-  Single source of truth between frontend and backend
-  Frontend shape matches backend contract exactly
-  Better maintainability - type changes sync automatically
-  More explicit about what the profiles structure represents

### **Verification:**
-  `pnpm run check` - TypeScript compilation passes
-  `pnpm run lint` - **0 warnings** (all 26 warnings resolved)
-  Rust backend lint/check passes

**All frontend warnings have been resolved and ProfilesDoc is now properly typed using the backend-generated ExecutorConfigs type!**

* Cleanup script changes for task attempt 3bbe8d2c-14b3-43eb-af98-abbc74f60742

* ##  Complete - JSON Handling Improved

Successfully improved JSON handling in mcp-strategies.ts with a simple, idiomatic change:

### **Change Made:**
[mcp-strategies.ts](file:///private/var/folders/m1/9q_ct1913z10v6wbnv54j25r0000gn/T/vibe-kanban/worktrees/3bbe-resolve-frontend/frontend/src/lib/mcp-strategies.ts#L3):
```typescript
// Before:
type JsonObject = { [key: string]: JsonValue };

// After:
type JsonObject = Record<string, JsonValue>;
```

### **Oracle's Analysis:**
-  **Keep it local** - Only this file needs it, no need for shared utilities
-  **Keep the type guard** - Essential for runtime safety when parsing/traversing JSON
-  **Use Record** - More idiomatic TypeScript than index signature
-  **No backend export needed** - Would add unnecessary complexity
-  **No shared file needed** - Single-use type doesn't warrant it

### **Verification:**
-  `pnpm run check` - TypeScript compilation passes
-  `pnpm run lint` - **0 warnings**
-  Rust backend passes

**The local JSON handling approach is the simplest and most readable solution for this use case!**
2025-11-18 16:22:06 +00:00

595 lines
19 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
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 { JSONEditor } from '@/components/ui/json-editor';
import { Loader2 } from 'lucide-react';
import { ExecutorConfigForm } from '@/components/ExecutorConfigForm';
import { useProfiles } from '@/hooks/useProfiles';
import { useUserSystem } from '@/components/config-provider';
import { CreateConfigurationDialog } from '@/components/dialogs/settings/CreateConfigurationDialog';
import { DeleteConfigurationDialog } from '@/components/dialogs/settings/DeleteConfigurationDialog';
import type { BaseCodingAgent, ExecutorConfigs } from 'shared/types';
type ExecutorsMap = Record<string, Record<string, Record<string, unknown>>>;
export function AgentSettings() {
const { t } = useTranslation('settings');
// Use profiles hook for server state
const {
profilesContent: serverProfilesContent,
profilesPath,
isLoading: profilesLoading,
isSaving: profilesSaving,
error: profilesError,
save: saveProfiles,
} = useProfiles();
const { reloadSystem } = useUserSystem();
// Local editor state (draft that may differ from server)
const [localProfilesContent, setLocalProfilesContent] = useState('');
const [profilesSuccess, setProfilesSuccess] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
// Form-based editor state
const [useFormEditor, setUseFormEditor] = useState(true);
const [selectedExecutorType, setSelectedExecutorType] =
useState<BaseCodingAgent>('CLAUDE_CODE' as BaseCodingAgent);
const [selectedConfiguration, setSelectedConfiguration] =
useState<string>('DEFAULT');
const [localParsedProfiles, setLocalParsedProfiles] =
useState<ExecutorConfigs | null>(null);
const [isDirty, setIsDirty] = useState(false);
// Sync server state to local state when not dirty
useEffect(() => {
if (!isDirty && serverProfilesContent) {
setLocalProfilesContent(serverProfilesContent);
// Parse JSON inside effect to avoid object dependency
try {
const parsed = JSON.parse(serverProfilesContent);
setLocalParsedProfiles(parsed);
} catch (err) {
console.error('Failed to parse profiles JSON:', err);
setLocalParsedProfiles(null);
}
}
}, [serverProfilesContent, isDirty]);
// Sync raw profiles with parsed profiles
const syncRawProfiles = (profiles: unknown) => {
setLocalProfilesContent(JSON.stringify(profiles, null, 2));
};
// Mark profiles as dirty
const markDirty = (nextProfiles: unknown) => {
setLocalParsedProfiles(nextProfiles as ExecutorConfigs);
syncRawProfiles(nextProfiles);
setIsDirty(true);
};
// Open create dialog
const openCreateDialog = async () => {
try {
const result = await CreateConfigurationDialog.show({
executorType: selectedExecutorType,
existingConfigs: Object.keys(
localParsedProfiles?.executors?.[selectedExecutorType] || {}
),
});
if (result.action === 'created' && result.configName) {
createConfiguration(
selectedExecutorType,
result.configName,
result.cloneFrom
);
}
} catch (error) {
// User cancelled - do nothing
}
};
// Create new configuration
const createConfiguration = (
executorType: string,
configName: string,
baseConfig?: string | null
) => {
if (!localParsedProfiles || !localParsedProfiles.executors) return;
const executorsMap =
localParsedProfiles.executors as unknown as ExecutorsMap;
const base =
baseConfig && executorsMap[executorType]?.[baseConfig]?.[executorType]
? executorsMap[executorType][baseConfig][executorType]
: {};
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[executorType]: {
...executorsMap[executorType],
[configName]: {
[executorType]: base,
},
},
},
};
markDirty(updatedProfiles);
setSelectedConfiguration(configName);
};
// Open delete dialog
const openDeleteDialog = async (configName: string) => {
try {
const result = await DeleteConfigurationDialog.show({
configName,
executorType: selectedExecutorType,
});
if (result === 'deleted') {
await handleDeleteConfiguration(configName);
}
} catch (error) {
// User cancelled - do nothing
}
};
// Handle delete configuration
const handleDeleteConfiguration = async (configToDelete: string) => {
if (!localParsedProfiles) {
return;
}
// Clear any previous errors
setSaveError(null);
try {
// Validate that the configuration exists
if (
!localParsedProfiles.executors[selectedExecutorType]?.[configToDelete]
) {
return;
}
// Check if this is the last configuration
const currentConfigs = Object.keys(
localParsedProfiles.executors[selectedExecutorType] || {}
);
if (currentConfigs.length <= 1) {
return;
}
// Remove the configuration from the executor
const remainingConfigs = {
...localParsedProfiles.executors[selectedExecutorType],
};
delete remainingConfigs[configToDelete];
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[selectedExecutorType]: remainingConfigs,
},
};
const executorsMap = updatedProfiles.executors as unknown as ExecutorsMap;
// If no configurations left, create a blank DEFAULT (should not happen due to check above)
if (Object.keys(remainingConfigs).length === 0) {
executorsMap[selectedExecutorType] = {
DEFAULT: { [selectedExecutorType]: {} },
};
}
try {
// Save using hook
await saveProfiles(JSON.stringify(updatedProfiles, null, 2));
// Update local state and reset dirty flag
setLocalParsedProfiles(updatedProfiles);
setLocalProfilesContent(JSON.stringify(updatedProfiles, null, 2));
setIsDirty(false);
// Select the next available configuration
const nextConfigs = Object.keys(
executorsMap[selectedExecutorType] || {}
);
const nextSelected = nextConfigs[0] || 'DEFAULT';
setSelectedConfiguration(nextSelected);
// Show success
setProfilesSuccess(true);
setTimeout(() => setProfilesSuccess(false), 3000);
// Refresh global system so deleted configs are removed elsewhere
reloadSystem();
} catch (saveError: unknown) {
console.error('Failed to save deletion to backend:', saveError);
setSaveError(t('settings.agents.errors.deleteFailed'));
}
} catch (error) {
console.error('Error deleting configuration:', error);
}
};
const handleProfilesChange = (value: string) => {
setLocalProfilesContent(value);
setIsDirty(true);
// Validate JSON on change
if (value.trim()) {
try {
const parsed = JSON.parse(value);
setLocalParsedProfiles(parsed);
} catch (err) {
// Invalid JSON, keep local content but clear parsed
setLocalParsedProfiles(null);
}
}
};
const handleSaveProfiles = async () => {
// Clear any previous errors
setSaveError(null);
try {
const contentToSave =
useFormEditor && localParsedProfiles
? JSON.stringify(localParsedProfiles, null, 2)
: localProfilesContent;
await saveProfiles(contentToSave);
setProfilesSuccess(true);
setIsDirty(false);
setTimeout(() => setProfilesSuccess(false), 3000);
// Update the local content if using form editor
if (useFormEditor && localParsedProfiles) {
setLocalProfilesContent(contentToSave);
}
// Refresh global system so new profiles are available elsewhere
reloadSystem();
} catch (err: unknown) {
console.error('Failed to save profiles:', err);
setSaveError(t('settings.agents.errors.saveFailed'));
}
};
const handleExecutorConfigChange = (
executorType: string,
configuration: string,
formData: unknown
) => {
if (!localParsedProfiles || !localParsedProfiles.executors) return;
const executorsMap =
localParsedProfiles.executors as unknown as ExecutorsMap;
// Update the parsed profiles with the new config
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[executorType]: {
...executorsMap[executorType],
[configuration]: {
[executorType]: formData,
},
},
},
};
markDirty(updatedProfiles);
};
const handleExecutorConfigSave = async (formData: unknown) => {
if (!localParsedProfiles || !localParsedProfiles.executors) return;
// Clear any previous errors
setSaveError(null);
// Update the parsed profiles with the saved config
const updatedProfiles = {
...localParsedProfiles,
executors: {
...localParsedProfiles.executors,
[selectedExecutorType]: {
...localParsedProfiles.executors[selectedExecutorType],
[selectedConfiguration]: {
[selectedExecutorType]: formData,
},
},
},
};
// Update state
setLocalParsedProfiles(updatedProfiles);
// Save the updated profiles directly
try {
const contentToSave = JSON.stringify(updatedProfiles, null, 2);
await saveProfiles(contentToSave);
setProfilesSuccess(true);
setIsDirty(false);
setTimeout(() => setProfilesSuccess(false), 3000);
// Update the local content as well
setLocalProfilesContent(contentToSave);
// Refresh global system so new profiles are available elsewhere
reloadSystem();
} catch (err: unknown) {
console.error('Failed to save profiles:', err);
setSaveError(t('settings.agents.errors.saveConfigFailed'));
}
};
if (profilesLoading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('settings.agents.loading')}</span>
</div>
);
}
return (
<div className="space-y-6">
{!!profilesError && (
<Alert variant="destructive">
<AlertDescription>
{profilesError instanceof Error
? profilesError.message
: String(profilesError)}
</AlertDescription>
</Alert>
)}
{profilesSuccess && (
<Alert variant="success">
<AlertDescription className="font-medium">
{t('settings.agents.save.success')}
</AlertDescription>
</Alert>
)}
{saveError && (
<Alert variant="destructive">
<AlertDescription>{saveError}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle>{t('settings.agents.title')}</CardTitle>
<CardDescription>{t('settings.agents.description')}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Editor type toggle */}
<div className="flex items-center space-x-2">
<Checkbox
id="use-form-editor"
checked={!useFormEditor}
onCheckedChange={(checked) => setUseFormEditor(!checked)}
disabled={profilesLoading || !localParsedProfiles}
/>
<Label htmlFor="use-form-editor">
{t('settings.agents.editor.formLabel')}
</Label>
</div>
{useFormEditor &&
localParsedProfiles &&
localParsedProfiles.executors ? (
// Form-based editor
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="executor-type">
{t('settings.agents.editor.agentLabel')}
</Label>
<Select
value={selectedExecutorType}
onValueChange={(value) => {
setSelectedExecutorType(value as BaseCodingAgent);
// Reset configuration selection when executor type changes
setSelectedConfiguration('DEFAULT');
}}
>
<SelectTrigger id="executor-type">
<SelectValue
placeholder={t(
'settings.agents.editor.agentPlaceholder'
)}
/>
</SelectTrigger>
<SelectContent>
{Object.keys(localParsedProfiles.executors).map(
(type) => (
<SelectItem key={type} value={type}>
{type}
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="configuration">
{t('settings.agents.editor.configLabel')}
</Label>
<div className="flex gap-2">
<Select
value={selectedConfiguration}
onValueChange={(value) => {
if (value === '__create__') {
openCreateDialog();
} else {
setSelectedConfiguration(value);
}
}}
disabled={
!localParsedProfiles.executors[selectedExecutorType]
}
>
<SelectTrigger id="configuration">
<SelectValue
placeholder={t(
'settings.agents.editor.configPlaceholder'
)}
/>
</SelectTrigger>
<SelectContent>
{Object.keys(
localParsedProfiles.executors[selectedExecutorType] ||
{}
).map((configuration) => (
<SelectItem key={configuration} value={configuration}>
{configuration}
</SelectItem>
))}
<SelectItem value="__create__">
{t('settings.agents.editor.createNew')}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="destructive"
size="sm"
className="h-10"
onClick={() => openDeleteDialog(selectedConfiguration)}
disabled={
profilesSaving ||
!localParsedProfiles.executors[selectedExecutorType] ||
Object.keys(
localParsedProfiles.executors[selectedExecutorType] ||
{}
).length <= 1
}
title={
Object.keys(
localParsedProfiles.executors[selectedExecutorType] ||
{}
).length <= 1
? t('settings.agents.editor.deleteTitle')
: t('settings.agents.editor.deleteButton', {
name: selectedConfiguration,
})
}
>
{t('settings.agents.editor.deleteText')}
</Button>
</div>
</div>
</div>
{(() => {
const executorsMap =
localParsedProfiles.executors as unknown as ExecutorsMap;
return (
!!executorsMap[selectedExecutorType]?.[
selectedConfiguration
]?.[selectedExecutorType] && (
<ExecutorConfigForm
executor={selectedExecutorType}
value={
(executorsMap[selectedExecutorType][
selectedConfiguration
][selectedExecutorType] as Record<string, unknown>) ||
{}
}
onChange={(formData) =>
handleExecutorConfigChange(
selectedExecutorType,
selectedConfiguration,
formData
)
}
onSave={handleExecutorConfigSave}
disabled={profilesSaving}
isSaving={profilesSaving}
isDirty={isDirty}
/>
)
);
})()}
</div>
) : (
// Raw JSON editor
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="profiles-editor">
{t('settings.agents.editor.jsonLabel')}
</Label>
<JSONEditor
id="profiles-editor"
placeholder={t('settings.agents.editor.jsonPlaceholder')}
value={
profilesLoading
? t('settings.agents.editor.jsonLoading')
: localProfilesContent
}
onChange={handleProfilesChange}
disabled={profilesLoading}
minHeight={300}
/>
</div>
{!profilesError && profilesPath && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
<span className="font-medium">
{t('settings.agents.editor.pathLabel')}
</span>{' '}
<span className="font-mono text-xs">{profilesPath}</span>
</p>
</div>
)}
</div>
)}
</CardContent>
</Card>
{!useFormEditor && (
<div className="sticky bottom-0 z-10 bg-background/80 backdrop-blur-sm border-t py-4">
<div className="flex justify-end">
<Button
onClick={handleSaveProfiles}
disabled={!isDirty || profilesSaving || !!profilesError}
>
{profilesSaving && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t('settings.agents.save.button')}
</Button>
</div>
</div>
)}
</div>
);
}