Removed unwanted files marked in .gitignore
This commit is contained in:
@@ -317,13 +317,6 @@ export function ActivityLogModal({
|
||||
<span className="activity-log-entry-type">
|
||||
{EVENT_TYPE_LABELS[entry.type]}
|
||||
</span>
|
||||
{/* Project name badge */}
|
||||
{entry.projectName && (
|
||||
<span className="activity-log-entry-project">
|
||||
<Folder size={10} />
|
||||
{entry.projectName}
|
||||
</span>
|
||||
)}
|
||||
<span className="activity-log-entry-time">
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</span>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List } from "lucide-react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, createAgent, updateAgent, updateAgentState, deleteAgent } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
@@ -31,6 +32,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
const [newAgentName, setNewAgentName] = useState("");
|
||||
const [newAgentRole, setNewAgentRole] = useState<AgentCapability>("custom");
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentView, setAgentView] = useState<"board" | "list">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
const saved = localStorage.getItem("kb-agent-view");
|
||||
@@ -251,26 +253,34 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
return (
|
||||
<div key={agent.id} className="agent-board-card" style={{ borderColor: stateStyle.border }}>
|
||||
<div className="agent-board-header">
|
||||
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
|
||||
<span
|
||||
className="agent-board-badge"
|
||||
style={{
|
||||
background: stateStyle.bg,
|
||||
color: stateStyle.text,
|
||||
border: `1px solid ${stateStyle.border}`,
|
||||
}}
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="agent-board-health" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}
|
||||
</span>
|
||||
<div
|
||||
className="agent-board-clickable"
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)}
|
||||
>
|
||||
<div className="agent-board-header">
|
||||
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
|
||||
<span
|
||||
className="agent-board-badge"
|
||||
style={{
|
||||
background: stateStyle.bg,
|
||||
color: stateStyle.text,
|
||||
border: `1px solid ${stateStyle.border}`,
|
||||
}}
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="agent-board-health" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div className="agent-board-name" title={agent.name}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
</div>
|
||||
<div className="agent-board-name" title={agent.name}>
|
||||
{agent.name}
|
||||
</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
<div className="agent-board-actions">
|
||||
{agent.state === "idle" && (
|
||||
<button
|
||||
@@ -338,7 +348,13 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
return (
|
||||
<div key={agent.id} className="agent-card" style={{ borderLeftColor: stateStyle.border }}>
|
||||
<div className="agent-card-header">
|
||||
<div className="agent-info">
|
||||
<div
|
||||
className="agent-info agent-info--clickable"
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)}
|
||||
>
|
||||
{editingRoleForAgent === agent.id ? (
|
||||
<select
|
||||
ref={roleSelectRef}
|
||||
@@ -358,12 +374,16 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
) : (
|
||||
<span
|
||||
className="agent-icon agent-icon--clickable"
|
||||
onClick={() => setEditingRoleForAgent(agent.id)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingRoleForAgent(agent.id);
|
||||
}}
|
||||
title="Click to change role"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
setEditingRoleForAgent(agent.id);
|
||||
}
|
||||
}}
|
||||
@@ -375,6 +395,7 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
<span className="agent-name">{agent.name}</span>
|
||||
<span className="agent-id text-secondary">{agent.id}</span>
|
||||
</div>
|
||||
<ChevronRight size={20} className="agent-card-chevron" />
|
||||
</div>
|
||||
<div className="agent-badges">
|
||||
<span
|
||||
@@ -474,6 +495,15 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Detail Modal */}
|
||||
{selectedAgentId && (
|
||||
<AgentDetailView
|
||||
agentId={selectedAgentId}
|
||||
onClose={() => setSelectedAgentId(null)}
|
||||
addToast={addToast}
|
||||
/>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.agents-view {
|
||||
display: flex;
|
||||
@@ -621,6 +651,14 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.agent-board-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.agent-board-clickable:hover .agent-board-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.agent-board-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
@@ -697,6 +735,37 @@ export function AgentsView({ addToast }: AgentsViewProps) {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agent-info--clickable {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-info--clickable:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.agent-info--clickable:hover .agent-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.agent-card-chevron {
|
||||
color: var(--text-muted);
|
||||
margin-left: auto;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-info--clickable:hover .agent-card-chevron {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
|
||||
@@ -12,6 +12,10 @@ export interface CustomModelDropdownProps {
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
label: string;
|
||||
/** List of favorite provider names in preferred order */
|
||||
favoriteProviders?: string[];
|
||||
/** Called when user toggles a provider's favorite status */
|
||||
onToggleFavorite?: (provider: string) => void;
|
||||
}
|
||||
|
||||
interface DropdownPosition {
|
||||
@@ -41,6 +45,8 @@ export function CustomModelDropdown({
|
||||
disabled = false,
|
||||
id,
|
||||
label,
|
||||
favoriteProviders = [],
|
||||
onToggleFavorite,
|
||||
}: CustomModelDropdownProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localFilter, setLocalFilter] = useState("");
|
||||
@@ -57,7 +63,7 @@ export function CustomModelDropdown({
|
||||
// Filter models based on local filter text
|
||||
const filteredModels = useMemo(() => filterModels(models, localFilter), [models, localFilter]);
|
||||
|
||||
// Group filtered models by provider
|
||||
// Group filtered models by provider and sort by favorites
|
||||
const modelsByProvider = useMemo(() => {
|
||||
return filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
(acc[m.provider] ??= []).push(m);
|
||||
@@ -65,6 +71,30 @@ export function CustomModelDropdown({
|
||||
}, {});
|
||||
}, [filteredModels]);
|
||||
|
||||
// Sort providers: favorites first (in order), then alphabetically
|
||||
const sortedProviderEntries = useMemo(() => {
|
||||
const entries = Object.entries(modelsByProvider);
|
||||
const favoritesSet = new Set(favoriteProviders);
|
||||
|
||||
return entries.sort(([a], [b]) => {
|
||||
const aFavorite = favoritesSet.has(a);
|
||||
const bFavorite = favoritesSet.has(b);
|
||||
|
||||
if (aFavorite && !bFavorite) return -1;
|
||||
if (!aFavorite && bFavorite) return 1;
|
||||
|
||||
// Both favorites: sort by favoriteProviders order
|
||||
if (aFavorite && bFavorite) {
|
||||
const aIdx = favoriteProviders.indexOf(a);
|
||||
const bIdx = favoriteProviders.indexOf(b);
|
||||
if (aIdx !== bIdx) return aIdx - bIdx;
|
||||
}
|
||||
|
||||
// Neither favorite: alphabetical
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [modelsByProvider, favoriteProviders]);
|
||||
|
||||
// Get current provider from value
|
||||
const currentProvider = useMemo(() => {
|
||||
if (!value) return null;
|
||||
@@ -78,7 +108,7 @@ export function CustomModelDropdown({
|
||||
{ type: "default", value: "", label: "Use default" },
|
||||
];
|
||||
|
||||
Object.entries(modelsByProvider).forEach(([provider, providerModels]) => {
|
||||
sortedProviderEntries.forEach(([provider, providerModels]) => {
|
||||
options.push({ type: "provider", value: `__group_${provider}`, label: provider, provider });
|
||||
providerModels.forEach((m) => {
|
||||
options.push({
|
||||
@@ -91,7 +121,7 @@ export function CustomModelDropdown({
|
||||
});
|
||||
|
||||
return options;
|
||||
}, [modelsByProvider]);
|
||||
}, [sortedProviderEntries]);
|
||||
|
||||
// Get current selection display text
|
||||
const selectedDisplayText = useMemo(() => {
|
||||
@@ -328,14 +358,29 @@ export function CustomModelDropdown({
|
||||
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
|
||||
{sortedProviderEntries.map(([provider, providerModels]) => {
|
||||
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
|
||||
const isFavorite = favoriteProviders.includes(provider);
|
||||
|
||||
return (
|
||||
<div key={provider} className="model-combobox-group">
|
||||
<div className="model-combobox-optgroup" data-index={groupStartIndex}>
|
||||
<ProviderIcon provider={provider} size="sm" />
|
||||
<span className="model-combobox-optgroup-text">{provider}</span>
|
||||
{onToggleFavorite && (
|
||||
<button
|
||||
type="button"
|
||||
className={`model-combobox-optgroup-favorite ${isFavorite ? "model-combobox-optgroup-favorite--active" : ""}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleFavorite(provider);
|
||||
}}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
aria-label={isFavorite ? `Remove ${provider} from favorites` : `Add ${provider} to favorites`}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{providerModels.map((m) => {
|
||||
const optionValue = `${m.provider}/${m.id}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Workflow, Bot, ChevronLeft, Target } from "lucide-react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import { ProjectSelector } from "./ProjectSelector";
|
||||
import { QuickScriptsDropdown } from "./QuickScriptsDropdown";
|
||||
@@ -28,6 +28,7 @@ export interface HeaderProps {
|
||||
onOpenSchedules?: () => void;
|
||||
onOpenGitManager?: () => void;
|
||||
onOpenWorkflowSteps?: () => void;
|
||||
onOpenMissions?: () => void;
|
||||
onOpenAgents?: () => void;
|
||||
onOpenScripts?: () => void;
|
||||
onRunScript?: (name: string, command: string) => void;
|
||||
@@ -76,6 +77,7 @@ export function Header({
|
||||
onOpenSchedules,
|
||||
onOpenGitManager,
|
||||
onOpenWorkflowSteps,
|
||||
onOpenMissions,
|
||||
onOpenAgents,
|
||||
onOpenScripts,
|
||||
onRunScript,
|
||||
@@ -386,6 +388,18 @@ export function Header({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Missions - desktop only */}
|
||||
{!isMobile && onOpenMissions && (
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={onOpenMissions}
|
||||
title="Mission Manager"
|
||||
data-testid="missions-btn"
|
||||
>
|
||||
<Target size={16} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Agents - desktop only */}
|
||||
{!isMobile && onOpenAgents && (
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Brain, Link, Lightbulb, ListTree, Zap, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import type { Task, TaskCreateInput, Settings } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, uploadAttachment, fetchSettings } from "../api";
|
||||
import { fetchModels, uploadAttachment, fetchSettings, updateGlobalSettings } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { applyPresetToSelection } from "../utils/modelPresets";
|
||||
@@ -85,6 +85,7 @@ export function InlineCreateCard({
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
@@ -110,7 +111,9 @@ export function InlineCreateCard({
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
try {
|
||||
setLoadedModels(await fetchModels());
|
||||
const response = await fetchModels();
|
||||
setLoadedModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
} catch (err: any) {
|
||||
setModelsError(err?.message || "Failed to load models");
|
||||
} finally {
|
||||
@@ -139,9 +142,10 @@ export function InlineCreateCard({
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then((models) => {
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setLoadedModels(models);
|
||||
setLoadedModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
@@ -413,6 +417,23 @@ export function InlineCreateCard({
|
||||
setValidatorModelId(next.modelId);
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders]);
|
||||
|
||||
const handleModelDropdownMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const target = e.target;
|
||||
if (
|
||||
@@ -700,6 +721,8 @@ export function InlineCreateCard({
|
||||
models={loadedModels}
|
||||
disabled={submitting}
|
||||
placeholder="Select executor model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -718,6 +741,8 @@ export function InlineCreateCard({
|
||||
models={loadedModels}
|
||||
disabled={submitting}
|
||||
placeholder="Select validator model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -48,6 +48,10 @@ interface ListViewProps {
|
||||
* Allows parent to refresh task list or handle optimistically.
|
||||
*/
|
||||
onTasksUpdated?: (updatedTasks: Task[]) => void;
|
||||
/** Project ID for multi-project context (optional) */
|
||||
projectId?: string;
|
||||
/** Project name for display (optional) */
|
||||
projectName?: string;
|
||||
}
|
||||
|
||||
function getStepProgress(steps: TaskStep[]): string {
|
||||
@@ -726,7 +730,6 @@ export function ListView({
|
||||
availableModels={availableModels}
|
||||
onPlanningMode={onPlanningMode}
|
||||
onSubtaskBreakdown={onSubtaskBreakdown}
|
||||
autoExpand={false}
|
||||
/>
|
||||
</div>
|
||||
{filteredCount === 0 ? (
|
||||
|
||||
@@ -14,6 +14,8 @@ interface ModelSelectionModalProps {
|
||||
modelsLoading: boolean;
|
||||
modelsError: string | null;
|
||||
onRetry: () => void;
|
||||
favoriteProviders?: string[];
|
||||
onToggleFavorite?: (provider: string) => void;
|
||||
}
|
||||
|
||||
function getModelBadgeLabel(models: ModelInfo[], value: string): string {
|
||||
@@ -37,6 +39,8 @@ export function ModelSelectionModal({
|
||||
modelsLoading,
|
||||
modelsError,
|
||||
onRetry,
|
||||
favoriteProviders = [],
|
||||
onToggleFavorite,
|
||||
}: ModelSelectionModalProps) {
|
||||
// Handle Escape key
|
||||
useEffect(() => {
|
||||
@@ -129,6 +133,8 @@ export function ModelSelectionModal({
|
||||
onChange={onExecutorChange}
|
||||
models={models}
|
||||
placeholder="Select executor model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,6 +157,8 @@ export function ModelSelectionModal({
|
||||
onChange={onValidatorChange}
|
||||
models={models}
|
||||
placeholder="Select validator model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { fetchModels, updateTask } from "../api";
|
||||
import { fetchModels, updateTask, updateGlobalSettings } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import type { Task, TaskDetail } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -68,6 +68,7 @@ function getSuccessToastMessage(target: "executor" | "validator", selection: Mod
|
||||
|
||||
export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
|
||||
@@ -84,8 +85,9 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then((models) => {
|
||||
setAvailableModels(models);
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
})
|
||||
.catch((err) => {
|
||||
setModelsError(err.message || "Failed to load models");
|
||||
@@ -95,6 +97,25 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle toggle favorite
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites]; // Add to front
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch (err) {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
addToast("Failed to update favorites", "error");
|
||||
}
|
||||
}, [favoriteProviders, addToast]);
|
||||
|
||||
useEffect(() => {
|
||||
activeTaskIdRef.current = task.id;
|
||||
|
||||
@@ -225,7 +246,10 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then(setAvailableModels)
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
})
|
||||
.catch((err) => setModelsError(err.message))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}}
|
||||
@@ -260,6 +284,8 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
models={availableModels}
|
||||
disabled={isSaving}
|
||||
placeholder="Select executor model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>The AI model used to implement this task.</small>
|
||||
</div>
|
||||
@@ -284,6 +310,8 @@ export function ModelSelectorTab({ task, addToast }: ModelSelectorTabProps) {
|
||||
models={availableModels}
|
||||
disabled={isSaving}
|
||||
placeholder="Select validator model…"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>The AI model used to review code and plans for this task.</small>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import type { Task, TaskCreateInput, ModelPreset, Settings, WorkflowStep } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, type RefinementType } from "../api";
|
||||
import { uploadAttachment, fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, type RefinementType } from "../api";
|
||||
import type { ModelInfo } from "../api";
|
||||
import { filterModels } from "../utils/modelFilter";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { Sparkles } from "lucide-react";
|
||||
|
||||
const ALLOWED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
||||
@@ -25,284 +24,6 @@ interface NewTaskModalProps {
|
||||
onSubtaskBreakdown?: (description: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simplified ModelCombobox for the New Task modal.
|
||||
* Reuses the same interaction pattern as ModelSelectorTab.
|
||||
*/
|
||||
function ModelCombobox({
|
||||
value,
|
||||
onChange,
|
||||
models,
|
||||
disabled = false,
|
||||
placeholder = "Select a model…",
|
||||
label,
|
||||
id,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
models: ModelInfo[];
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
label: string;
|
||||
id: string;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localFilter, setLocalFilter] = useState("");
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const filteredModels = filterModels(models, localFilter);
|
||||
|
||||
const modelsByProvider = filteredModels.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
(acc[m.provider] ??= []).push(m);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Get current provider from value for icon display
|
||||
const currentProvider = useMemo(() => {
|
||||
if (!value) return null;
|
||||
const slashIdx = value.indexOf("/");
|
||||
return slashIdx === -1 ? null : value.slice(0, slashIdx);
|
||||
}, [value]);
|
||||
|
||||
const optionsList = [
|
||||
{ type: "default" as const, value: "", label: "Use default" },
|
||||
...Object.entries(modelsByProvider).flatMap(([provider, providerModels]) => [
|
||||
{ type: "provider" as const, value: `__group_${provider}`, label: provider, provider },
|
||||
...providerModels.map((m) => ({
|
||||
type: "model" as const,
|
||||
value: `${m.provider}/${m.id}`,
|
||||
label: m.name,
|
||||
provider: m.provider
|
||||
})),
|
||||
]),
|
||||
];
|
||||
|
||||
const selectedDisplayText = !value
|
||||
? "Use default"
|
||||
: (() => {
|
||||
const slashIdx = value.indexOf("/");
|
||||
if (slashIdx === -1) return value;
|
||||
const provider = value.slice(0, slashIdx);
|
||||
const modelId = value.slice(slashIdx + 1);
|
||||
const model = models.find((m) => m.provider === provider && m.id === modelId);
|
||||
return model?.name || value;
|
||||
})();
|
||||
|
||||
const currentValueIndex = optionsList.findIndex((opt) => opt.value === value);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const selectableIndex = optionsList.findIndex((opt, idx) =>
|
||||
idx >= (currentValueIndex >= 0 ? currentValueIndex : 0) && opt.type !== "provider"
|
||||
);
|
||||
setHighlightedIndex(selectableIndex >= 0 ? selectableIndex : 0);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen, optionsList, currentValueIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
if (!isOpen) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
let nextIndex = highlightedIndex;
|
||||
for (let i = 1; i <= optionsList.length; i++) {
|
||||
const idx = (highlightedIndex + i) % optionsList.length;
|
||||
if (optionsList[idx]?.type !== "provider") {
|
||||
nextIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setHighlightedIndex(nextIndex);
|
||||
}
|
||||
break;
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
let prevIndex = highlightedIndex;
|
||||
for (let i = 1; i <= optionsList.length; i++) {
|
||||
const idx = (highlightedIndex - i + optionsList.length) % optionsList.length;
|
||||
if (optionsList[idx]?.type !== "provider") {
|
||||
prevIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setHighlightedIndex(prevIndex);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
e.preventDefault();
|
||||
if (isOpen) {
|
||||
const option = optionsList[highlightedIndex];
|
||||
if (option && option.type !== "provider") {
|
||||
onChange(option.value);
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
e.preventDefault();
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
break;
|
||||
case "Tab":
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}, [isOpen, highlightedIndex, optionsList, onChange]);
|
||||
|
||||
const handleSelect = useCallback((optionValue: string) => {
|
||||
onChange(optionValue);
|
||||
setIsOpen(false);
|
||||
setLocalFilter("");
|
||||
}, [onChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && listRef.current) {
|
||||
const highlightedEl = listRef.current.querySelector(`[data-index="${highlightedIndex}"]`);
|
||||
if (highlightedEl && typeof highlightedEl.scrollIntoView === "function") {
|
||||
highlightedEl.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
}, [highlightedIndex, isOpen]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="model-combobox" onKeyDown={handleKeyDown}>
|
||||
<button
|
||||
type="button"
|
||||
id={id}
|
||||
className="model-combobox-trigger"
|
||||
onClick={() => setIsOpen((prev) => !prev)}
|
||||
disabled={disabled}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
aria-label={label}
|
||||
>
|
||||
{currentProvider && (
|
||||
<span className="model-combobox-trigger-icon">
|
||||
<ProviderIcon provider={currentProvider} size="sm" />
|
||||
</span>
|
||||
)}
|
||||
<span className="model-combobox-trigger-text">{selectedDisplayText}</span>
|
||||
<span className="model-combobox-trigger-arrow">▼</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="model-combobox-dropdown" role="listbox">
|
||||
<div className="model-combobox-search-wrapper">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="model-combobox-search"
|
||||
placeholder="Filter models…"
|
||||
value={localFilter}
|
||||
onChange={(e) => setLocalFilter(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{localFilter && (
|
||||
<button
|
||||
type="button"
|
||||
className="model-combobox-clear"
|
||||
onClick={() => {
|
||||
setLocalFilter("");
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="model-combobox-results-count">
|
||||
{filteredModels.length} model{filteredModels.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="model-combobox-list">
|
||||
<div
|
||||
data-index={0}
|
||||
className={`model-combobox-option ${highlightedIndex === 0 ? "model-combobox-option--highlighted" : ""} ${value === "" ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect("")}
|
||||
onMouseEnter={() => setHighlightedIndex(0)}
|
||||
role="option"
|
||||
aria-selected={value === ""}
|
||||
>
|
||||
<span className="model-combobox-option-text model-combobox-option-text--default">Use default</span>
|
||||
</div>
|
||||
|
||||
{Object.entries(modelsByProvider).map(([provider, providerModels]) => {
|
||||
const groupStartIndex = optionsList.findIndex((opt) => opt.value === `__group_${provider}`);
|
||||
|
||||
return (
|
||||
<div key={provider} className="model-combobox-group">
|
||||
<div
|
||||
className="model-combobox-optgroup"
|
||||
data-index={groupStartIndex}
|
||||
>
|
||||
<ProviderIcon provider={provider} size="sm" />
|
||||
<span className="model-combobox-optgroup-text">{provider}</span>
|
||||
</div>
|
||||
{providerModels.map((m) => {
|
||||
const optionValue = `${m.provider}/${m.id}`;
|
||||
const optionIndex = optionsList.findIndex((opt) => opt.value === optionValue);
|
||||
const isHighlighted = highlightedIndex === optionIndex;
|
||||
const isSelected = value === optionValue;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={optionValue}
|
||||
data-index={optionIndex}
|
||||
className={`model-combobox-option ${isHighlighted ? "model-combobox-option--highlighted" : ""} ${isSelected ? "model-combobox-option--selected" : ""}`}
|
||||
onClick={() => handleSelect(optionValue)}
|
||||
onMouseEnter={() => setHighlightedIndex(optionIndex)}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<span className="model-combobox-option-text">{m.name}</span>
|
||||
<span className="model-combobox-option-id">{m.id}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredModels.length === 0 && localFilter && (
|
||||
<div className="model-combobox-no-results">
|
||||
No models match '{localFilter}'
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, onPlanningMode, onSubtaskBreakdown }: NewTaskModalProps) {
|
||||
const [description, setDescription] = useState("");
|
||||
const [dependencies, setDependencies] = useState<string[]>([]);
|
||||
@@ -311,6 +32,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
const [pendingImages, setPendingImages] = useState<PendingImage[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [executorModel, setExecutorModel] = useState("");
|
||||
const [validatorModel, setValidatorModel] = useState("");
|
||||
@@ -335,7 +57,10 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
if (isOpen) {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((models) => setAvailableModels(models))
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
})
|
||||
.catch(() => {/* silently fail - models just won't be available */})
|
||||
.finally(() => setModelsLoading(false));
|
||||
fetchSettings()
|
||||
@@ -582,6 +307,23 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
}
|
||||
}, [description, isRefining, addToast]);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const availableDeps = tasks
|
||||
@@ -806,7 +548,7 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
) : null}
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="executor-model" className="model-select-label">Executor</label>
|
||||
<ModelCombobox
|
||||
<CustomModelDropdown
|
||||
id="executor-model"
|
||||
label="Executor Model"
|
||||
value={executorModel}
|
||||
@@ -817,11 +559,13 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting || presetMode === "preset"}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="model-select-row">
|
||||
<label htmlFor="validator-model" className="model-select-label">Validator</label>
|
||||
<ModelCombobox
|
||||
<CustomModelDropdown
|
||||
id="validator-model"
|
||||
label="Validator Model"
|
||||
value={validatorModel}
|
||||
@@ -832,6 +576,8 @@ export function NewTaskModal({ isOpen, onClose, tasks, onCreateTask, addToast, o
|
||||
}}
|
||||
models={availableModels}
|
||||
disabled={isSubmitting || presetMode === "preset"}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { Plus, LayoutGrid, Filter, ArrowUpDown, Activity, CheckCircle, AlertCircle, Folder, Inbox } from "lucide-react";
|
||||
import type { ProjectInfo, ProjectHealth, ProjectStatus } from "../api";
|
||||
import type { ProjectInfo, ProjectHealth } from "../api";
|
||||
import type { ProjectStatus } from "@fusion/core";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
import { ProjectGridSkeleton } from "./ProjectGridSkeleton";
|
||||
import { useProjectHealth } from "../hooks/useProjectHealth";
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
Loader2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ProjectInfo, ProjectStatus } from "../api";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { ProjectStatus } from "@fusion/core";
|
||||
|
||||
export interface ProjectSelectorProps {
|
||||
projects: ProjectInfo[];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from "react-dom";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import type { Task, TaskCreateInput } from "@fusion/core";
|
||||
import type { ModelInfo, RefinementType } from "../api";
|
||||
import { fetchModels, refineText, getRefineErrorMessage } from "../api";
|
||||
import { fetchModels, refineText, getRefineErrorMessage, updateGlobalSettings } from "../api";
|
||||
import { Link, Brain, Lightbulb, ListTree, Sparkles, Save, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ModelSelectionModal } from "./ModelSelectionModal";
|
||||
@@ -69,6 +69,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsError, setModelsError] = useState<string | null>(null);
|
||||
const [loadedModels, setLoadedModels] = useState<ModelInfo[]>(availableModels ?? []);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
|
||||
// AI Refinement state
|
||||
const [isRefineMenuOpen, setIsRefineMenuOpen] = useState(false);
|
||||
@@ -91,9 +92,10 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
fetchModels()
|
||||
.then((models) => {
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
setLoadedModels(models);
|
||||
setLoadedModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
@@ -335,6 +337,23 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setValidatorModelId(next.modelId);
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders]);
|
||||
|
||||
const handlePlanClick = useCallback(() => {
|
||||
const trimmed = description.trim();
|
||||
if (!trimmed) {
|
||||
@@ -399,7 +418,9 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
setModelsLoading(true);
|
||||
setModelsError(null);
|
||||
try {
|
||||
setLoadedModels(await fetchModels());
|
||||
const response = await fetchModels();
|
||||
setLoadedModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
} catch (err: any) {
|
||||
setModelsError(err?.message || "Failed to load models");
|
||||
} finally {
|
||||
@@ -633,6 +654,8 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
modelsLoading={modelsLoading}
|
||||
modelsError={modelsError}
|
||||
onRetry={loadModels}
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>,
|
||||
document.body,
|
||||
)
|
||||
|
||||
@@ -95,6 +95,7 @@ export function SettingsModal({
|
||||
|
||||
// Model state
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
|
||||
// Test notification state
|
||||
@@ -142,7 +143,10 @@ export function SettingsModal({
|
||||
if (activeSection === "model") {
|
||||
setModelsLoading(true);
|
||||
fetchModels()
|
||||
.then((models) => setAvailableModels(models))
|
||||
.then((response) => {
|
||||
setAvailableModels(response.models);
|
||||
setFavoriteProviders(response.favoriteProviders);
|
||||
})
|
||||
.catch(() => setAvailableModels([]))
|
||||
.finally(() => setModelsLoading(false));
|
||||
}
|
||||
@@ -312,7 +316,7 @@ export function SettingsModal({
|
||||
try {
|
||||
const result = await importSettings(importPreview, { scope: importScope, merge: importMerge });
|
||||
if (result.success) {
|
||||
const parts = [];
|
||||
const parts: string[] = [];
|
||||
if (result.globalCount > 0) parts.push(`${result.globalCount} global`);
|
||||
if (result.projectCount > 0) parts.push(`${result.projectCount} project`);
|
||||
addToast(`Imported ${parts.join(", ")} setting(s)`, "success");
|
||||
@@ -426,6 +430,24 @@ export function SettingsModal({
|
||||
setPresetIdTouched(false);
|
||||
};
|
||||
|
||||
/** Toggle provider favorite status */
|
||||
const handleToggleFavorite = useCallback(async (provider: string) => {
|
||||
const currentFavorites = favoriteProviders;
|
||||
const isFavorite = currentFavorites.includes(provider);
|
||||
const newFavorites = isFavorite
|
||||
? currentFavorites.filter((p) => p !== provider)
|
||||
: [provider, ...currentFavorites];
|
||||
|
||||
setFavoriteProviders(newFavorites);
|
||||
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: newFavorites });
|
||||
} catch {
|
||||
// Revert on error
|
||||
setFavoriteProviders(currentFavorites);
|
||||
}
|
||||
}, [favoriteProviders]);
|
||||
|
||||
/** Render a scope indicator banner for the current section */
|
||||
const renderScopeBanner = () => {
|
||||
if (activeSectionScope === "global") {
|
||||
@@ -532,6 +554,8 @@ export function SettingsModal({
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>Default AI model used for task execution when no per-task override is set. "Use default" lets the engine choose automatically.</small>
|
||||
</div>
|
||||
@@ -555,6 +579,8 @@ export function SettingsModal({
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>AI model used for task planning and specification (triage). Falls back to Default Model when not set.</small>
|
||||
</div>
|
||||
@@ -578,6 +604,8 @@ export function SettingsModal({
|
||||
}
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
<small>AI model used for code and specification review. Falls back to Default Model when not set.</small>
|
||||
</div>
|
||||
@@ -753,6 +781,8 @@ export function SettingsModal({
|
||||
} : current);
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
@@ -775,6 +805,8 @@ export function SettingsModal({
|
||||
} : current);
|
||||
}}
|
||||
placeholder="Use default"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -887,6 +919,8 @@ export function SettingsModal({
|
||||
}));
|
||||
}}
|
||||
placeholder="Use fallback model"
|
||||
favoriteProviders={favoriteProviders}
|
||||
onToggleFavorite={handleToggleFavorite}
|
||||
/>
|
||||
)}
|
||||
<small>
|
||||
|
||||
@@ -396,7 +396,7 @@ export function SetupWizard({ isOpen, onClose, onProjectCreated, onRegisterProje
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleValidate}
|
||||
disabled={state.isValidating || state.validationError}
|
||||
disabled={state.isValidating || !!state.validationError}
|
||||
>
|
||||
{state.isValidating ? (
|
||||
<>
|
||||
|
||||
@@ -54,7 +54,7 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
setDiffData(data);
|
||||
// Auto-expand first file if there are files
|
||||
if (data.files.length > 0) {
|
||||
setExpandedFiles(new Set([data.files[0]]));
|
||||
setExpandedFiles(new Set([data.files[0].path]));
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load diff");
|
||||
@@ -146,19 +146,18 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
</div>
|
||||
|
||||
<div className="changes-file-list">
|
||||
{diffData.files.map((file) => {
|
||||
const fileDiff = diffData.diffs[file];
|
||||
const status = getFileStatus(file, fileDiff?.patch || "");
|
||||
const isExpanded = expandedFiles.has(file);
|
||||
{diffData.files.map((fileEntry) => {
|
||||
const { path, status, patch } = fileEntry;
|
||||
const isExpanded = expandedFiles.has(path);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={file}
|
||||
key={path}
|
||||
className={`changes-file-item ${isExpanded ? "expanded" : ""}`}
|
||||
>
|
||||
<button
|
||||
className="changes-file-header"
|
||||
onClick={() => toggleFile(file)}
|
||||
onClick={() => toggleFile(path)}
|
||||
>
|
||||
<span className="changes-file-toggle">
|
||||
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
@@ -171,22 +170,19 @@ export function TaskChangesTab({ taskId, worktree }: TaskChangesTabProps) {
|
||||
{status === "added" && "A"}
|
||||
{status === "modified" && "M"}
|
||||
{status === "deleted" && "D"}
|
||||
{status === "unknown" && "?"}
|
||||
</span>
|
||||
<span className="changes-file-path" title={file}>
|
||||
{file}
|
||||
<span className="changes-file-path" title={path}>
|
||||
{path}
|
||||
</span>
|
||||
<span className="changes-file-stat" title={`+${fileEntry.additions} -${fileEntry.deletions}`}>
|
||||
+{fileEntry.additions} -{fileEntry.deletions}
|
||||
</span>
|
||||
{fileDiff?.stat && (
|
||||
<span className="changes-file-stat" title={fileDiff.stat}>
|
||||
{fileDiff.stat}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && fileDiff?.patch && (
|
||||
{isExpanded && patch && (
|
||||
<div className="changes-file-content">
|
||||
<pre className="changes-diff-patch">
|
||||
<code>{fileDiff.patch}</code>
|
||||
<code>{patch}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Task, TaskComment } from "@fusion/core";
|
||||
import { addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import { addSteeringComment, addTaskComment, updateTaskComment, deleteTaskComment } from "../api";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
const MAX_COMMENT_LENGTH = 2000;
|
||||
|
||||
interface TaskCommentsProps {
|
||||
task: Task;
|
||||
onTaskUpdated?: (task: Task) => void;
|
||||
@@ -10,30 +12,51 @@ interface TaskCommentsProps {
|
||||
currentAuthor?: string;
|
||||
}
|
||||
|
||||
type CommentType = "comment" | "guidance";
|
||||
|
||||
function formatCommentTimestamp(comment: TaskComment): string {
|
||||
const timestamp = comment.updatedAt || comment.createdAt;
|
||||
const label = new Date(timestamp).toLocaleString();
|
||||
return comment.updatedAt ? `${label} (edited)` : label;
|
||||
}
|
||||
|
||||
function isAIGuidanceComment(author: string): boolean {
|
||||
return author === "agent" || author === "system";
|
||||
}
|
||||
|
||||
export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "user" }: TaskCommentsProps) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||||
const [commentType, setCommentType] = useState<CommentType>("comment");
|
||||
|
||||
const comments = useMemo(() => task.comments || [], [task.comments]);
|
||||
// Sort comments by createdAt descending (newest first)
|
||||
const comments = useMemo(() => {
|
||||
return [...(task.comments || [])].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}, [task.comments]);
|
||||
|
||||
const isOverLimit = draft.length > MAX_COMMENT_LENGTH;
|
||||
|
||||
async function handleAddComment() {
|
||||
const text = draft.trim();
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment added", "success");
|
||||
if (commentType === "guidance") {
|
||||
const updated = await addSteeringComment(task.id, text);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("AI Guidance added", "success");
|
||||
} else {
|
||||
const updated = await addTaskComment(task.id, text, currentAuthor);
|
||||
setDraft("");
|
||||
onTaskUpdated?.(updated);
|
||||
addToast("Comment added", "success");
|
||||
}
|
||||
} catch (error: any) {
|
||||
addToast(error.message || "Failed to add comment", "error");
|
||||
} finally {
|
||||
@@ -71,6 +94,16 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(event: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
void handleAddComment();
|
||||
}
|
||||
}
|
||||
|
||||
const placeholder = commentType === "guidance" ? "Add guidance for the AI agent" : "Add a comment";
|
||||
const buttonLabel = commentType === "guidance" ? "Add Guidance" : "Add Comment";
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<h4>Comments</h4>
|
||||
@@ -81,12 +114,17 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
{comments.map((comment) => {
|
||||
const canEdit = comment.author === currentAuthor;
|
||||
const isEditing = editingId === comment.id;
|
||||
const isAIGuidance = isAIGuidanceComment(comment.author);
|
||||
return (
|
||||
<div key={comment.id} className="detail-log-entry">
|
||||
<div className="detail-log-header" style={{ justifyContent: "space-between", gap: 12 }}>
|
||||
<div>
|
||||
<strong>{comment.author}</strong>
|
||||
<span className="detail-log-timestamp" style={{ marginLeft: 8 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
{isAIGuidance ? (
|
||||
<span className="ai-guidance-badge" data-testid="ai-guidance-badge">AI Guidance</span>
|
||||
) : (
|
||||
<strong>{comment.author}</strong>
|
||||
)}
|
||||
<span className="detail-log-timestamp" style={{ marginLeft: isAIGuidance ? 0 : 8 }}>
|
||||
{formatCommentTimestamp(comment)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -148,16 +186,43 @@ export function TaskComments({ task, onTaskUpdated, addToast, currentAuthor = "u
|
||||
)}
|
||||
|
||||
<div style={{ display: "grid", gap: 8, marginTop: 12 }}>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
className={`btn btn-sm ${commentType === "comment" ? "btn-primary" : ""}`}
|
||||
onClick={() => setCommentType("comment")}
|
||||
>
|
||||
Comment
|
||||
</button>
|
||||
<button
|
||||
className={`btn btn-sm ${commentType === "guidance" ? "btn-primary" : ""}`}
|
||||
onClick={() => setCommentType("guidance")}
|
||||
>
|
||||
AI Guidance
|
||||
</button>
|
||||
</div>
|
||||
{commentType === "guidance" && (
|
||||
<div style={{ fontSize: "0.875rem", color: "var(--color-text-muted, #666)" }}>
|
||||
AI Guidance comments are injected into the task execution context
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
rows={3}
|
||||
placeholder="Add a comment"
|
||||
placeholder={placeholder}
|
||||
className="spec-editor-feedback"
|
||||
/>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={() => void handleAddComment()} disabled={submitting || !draft.trim()}>
|
||||
{submitting ? "Posting…" : "Add Comment"}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<span style={{ fontSize: "0.75rem", color: isOverLimit ? "var(--color-danger, red)" : undefined }}>
|
||||
{draft.length} / {MAX_COMMENT_LENGTH}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleAddComment()}
|
||||
disabled={submitting || !draft.trim() || isOverLimit}
|
||||
>
|
||||
{submitting ? "Posting…" : buttonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
),
|
||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve([])),
|
||||
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [] })),
|
||||
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
|
||||
fetchAgents: vi.fn(() => Promise.resolve([])),
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("lucide-react", () => ({
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue([]),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
@@ -91,7 +91,7 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
vi.mocked(fetchModels).mockResolvedValue(MOCK_MODELS);
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [] });
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Task, TaskDetail } from "@fusion/core";
|
||||
|
||||
// Mock the API
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue([]),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
||||
fetchTaskDetail: vi.fn(),
|
||||
batchUpdateTaskModels: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -39,6 +39,12 @@ const MOCK_MODELS = [
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
];
|
||||
|
||||
// Mock response format (with models and favoriteProviders)
|
||||
const MOCK_MODELS_RESPONSE = {
|
||||
models: MOCK_MODELS,
|
||||
favoriteProviders: [],
|
||||
};
|
||||
|
||||
describe("ModelSelectorTab", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
@@ -76,7 +82,7 @@ describe("ModelSelectorTab", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchModels.mockResolvedValue(MOCK_MODELS);
|
||||
mockFetchModels.mockResolvedValue(MOCK_MODELS_RESPONSE);
|
||||
mockUpdateTask.mockImplementation(async (_id: string, updates: Record<string, unknown>) => ({
|
||||
...FAKE_TASK,
|
||||
...updates,
|
||||
@@ -350,7 +356,7 @@ describe("ModelSelectorTab", () => {
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", async () => {
|
||||
mockFetchModels.mockResolvedValue([]);
|
||||
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [] });
|
||||
|
||||
render(<ModelSelectorTab task={FAKE_TASK} addToast={mockAddToast} />);
|
||||
|
||||
|
||||
@@ -6,10 +6,10 @@ import type { Task, Column } from "@fusion/core";
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||
fetchModels: vi.fn().mockResolvedValue([
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
]),
|
||||
], favoriteProviders: [] }),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
|
||||
@@ -49,7 +49,7 @@ const mockTasks: Task[] = [
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue([
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [
|
||||
{
|
||||
provider: "anthropic",
|
||||
id: "claude-sonnet-4-5",
|
||||
@@ -64,7 +64,7 @@ vi.mock("../../api", () => ({
|
||||
reasoning: true,
|
||||
contextWindow: 128_000,
|
||||
},
|
||||
]),
|
||||
], favoriteProviders: [] }),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
}));
|
||||
|
||||
@@ -32,10 +32,10 @@ vi.mock("../../api", () => ({
|
||||
fetchAuthStatus: vi.fn(() => Promise.resolve({ providers: [{ id: "anthropic", name: "Anthropic", authenticated: false }] })),
|
||||
loginProvider: vi.fn(() => Promise.resolve({ url: "https://auth.example.com/login" })),
|
||||
logoutProvider: vi.fn(() => Promise.resolve({ success: true })),
|
||||
fetchModels: vi.fn(() => Promise.resolve([
|
||||
fetchModels: vi.fn(() => Promise.resolve({ models: [
|
||||
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
|
||||
{ provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 },
|
||||
])),
|
||||
], favoriteProviders: [] })),
|
||||
testNtfyNotification: vi.fn(() => Promise.resolve({ success: true })),
|
||||
}));
|
||||
|
||||
@@ -540,7 +540,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
|
||||
it("shows empty state when no models available", async () => {
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce([]);
|
||||
(fetchModels as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ models: [], favoriteProviders: [] });
|
||||
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
Reference in New Issue
Block a user