Removed unwanted files marked in .gitignore

This commit is contained in:
gsxdsm
2026-04-01 21:25:08 -07:00
parent fbf749a525
commit 2778027423
856 changed files with 2357 additions and 133439 deletions

View File

@@ -23,6 +23,7 @@ import { NewTaskModal } from "./components/NewTaskModal";
import { ScheduledTasksModal } from "./components/ScheduledTasksModal";
import { ActivityLogModal } from "./components/ActivityLogModal";
import { WorkflowStepManager } from "./components/WorkflowStepManager";
import { MissionManager } from "./components/MissionManager";
import { AgentListModal } from "./components/AgentListModal";
import { AgentsView } from "./components/AgentsView";
import { ScriptsModal } from "./components/ScriptsModal";
@@ -85,6 +86,7 @@ function AppInner() {
const [activityLogOpen, setActivityLogOpen] = useState(false);
const [gitManagerOpen, setGitManagerOpen] = useState(false);
const [workflowStepsOpen, setWorkflowStepsOpen] = useState(false);
const [missionsOpen, setMissionsOpen] = useState(false);
const [agentsOpen, setAgentsOpen] = useState(false);
const [scriptsOpen, setScriptsOpen] = useState(false);
const [terminalInitialCommand, setTerminalInitialCommand] = useState<string | undefined>(undefined);
@@ -177,7 +179,7 @@ function AppInner() {
// Fetch available models
useEffect(() => {
fetchModels()
.then((models) => setAvailableModels(models))
.then((response) => setAvailableModels(response.models))
.catch(() => {/* keep empty array on failure */});
}, []);
@@ -484,6 +486,7 @@ function AppInner() {
onOpenSchedules={handleOpenSchedules}
onOpenGitManager={handleOpenGitManager}
onOpenWorkflowSteps={() => setWorkflowStepsOpen(true)}
onOpenMissions={() => setMissionsOpen(true)}
onOpenAgents={handleOpenAgents}
onOpenScripts={handleOpenScripts}
onRunScript={handleRunScript}
@@ -621,6 +624,18 @@ function AppInner() {
onClose={() => setWorkflowStepsOpen(false)}
addToast={addToast}
/>
<MissionManager
isOpen={missionsOpen}
onClose={() => setMissionsOpen(false)}
addToast={addToast}
availableTasks={tasks.map((t) => ({ id: t.id, title: t.title }))}
onSelectTask={(taskId) => {
const task = tasks.find((t) => t.id === taskId);
if (task) {
setDetailTask(task as TaskDetail);
}
}}
/>
<AgentListModal
isOpen={agentsOpen}
onClose={handleCloseAgents}

View File

@@ -35,9 +35,9 @@ describe("column fixed-width CSS", () => {
});
describe("desktop .board grid template", () => {
it("uses repeat(6, minmax(260px, 1fr)) for 6 columns", () => {
it("uses repeat(6, minmax(280px, 1fr)) for 6 columns", () => {
expect(css).toContain(
"grid-template-columns: repeat(6, minmax(260px, 1fr))",
"grid-template-columns: repeat(6, minmax(280px, 1fr))",
);
});
});

View File

@@ -231,15 +231,18 @@ describe("fetchModels", () => {
globalThis.fetch = originalFetch;
});
it("returns available models", async () => {
const models = [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, models));
it("returns available models with favorites", async () => {
const response = {
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
],
favoriteProviders: ["anthropic"],
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await fetchModels();
expect(result).toEqual(models);
expect(result).toEqual(response);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/models", {
headers: { "Content-Type": "application/json" },
});

View File

@@ -287,6 +287,13 @@ export function fetchTaskComments(id: string): Promise<TaskComment[]> {
return api<TaskComment[]>(`/tasks/${id}/comments`);
}
export function addComment(id: string, text: string): Promise<Task> {
return api<Task>(`/tasks/${id}/steer`, {
method: "POST",
body: JSON.stringify({ text }),
});
}
export function addTaskComment(id: string, text: string, author?: string): Promise<Task> {
return api<Task>(`/tasks/${id}/comments`, {
method: "POST",
@@ -339,9 +346,15 @@ export interface ModelInfo {
contextWindow: number;
}
/** Fetch available AI models from the model registry */
export function fetchModels(): Promise<ModelInfo[]> {
return api<ModelInfo[]>("/models");
/** Response from the models endpoint */
export interface ModelsResponse {
models: ModelInfo[];
favoriteProviders: string[];
}
/** Fetch available AI models from the model registry along with favoriteProviders */
export function fetchModels(): Promise<ModelsResponse> {
return api<ModelsResponse>("/models");
}
// --- Usage API ---
@@ -1530,8 +1543,8 @@ export function cancelSubtaskBreakdown(sessionId: string): Promise<void> {
// ── Agent API ────────────────────────────────────────────────────────────
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentCreateInput, AgentUpdateInput } from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentCreateInput, AgentUpdateInput };
import type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput } from "@fusion/core";
export type { Agent, AgentDetail, AgentCapability, AgentState, AgentHeartbeatEvent, AgentHeartbeatRun, AgentCreateInput, AgentUpdateInput };
/** Fetch all agents, optionally filtered by state or role */
export function fetchAgents(filter?: { state?: AgentState; role?: AgentCapability }): Promise<Agent[]> {
@@ -1958,6 +1971,241 @@ export function fetchTaskFileDiffs(taskId: string): Promise<TaskFileDiff[]> {
return api<TaskFileDiff[]>(`/tasks/${encodeURIComponent(taskId)}/file-diffs`);
}
// ── Mission API ───────────────────────────────────────────────────────────
/** Mission status values */
export type MissionStatus = "planning" | "active" | "blocked" | "complete" | "archived";
/** Milestone status values */
export type MilestoneStatus = "planning" | "active" | "blocked" | "complete";
/** Slice status values */
export type SliceStatus = "pending" | "active" | "complete";
/** Feature status values */
export type FeatureStatus = "defined" | "triaged" | "in-progress" | "done";
/** Mission entity */
export interface Mission {
id: string;
title: string;
description?: string;
status: MissionStatus;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
autoAdvance?: boolean;
createdAt: string;
updatedAt: string;
}
/** Milestone entity */
export interface Milestone {
id: string;
missionId: string;
title: string;
description?: string;
status: MilestoneStatus;
orderIndex: number;
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
dependencies: string[];
createdAt: string;
updatedAt: string;
}
/** Slice entity */
export interface Slice {
id: string;
milestoneId: string;
title: string;
description?: string;
status: SliceStatus;
orderIndex: number;
activatedAt?: string;
createdAt: string;
updatedAt: string;
}
/** Feature entity */
export interface MissionFeature {
id: string;
sliceId: string;
taskId?: string;
title: string;
description?: string;
acceptanceCriteria?: string;
status: FeatureStatus;
createdAt: string;
updatedAt: string;
}
/** Milestone with slices (each slice has features) */
export interface MilestoneWithSlices extends Milestone {
slices: SliceWithFeatures[];
}
/** Slice with features */
export interface SliceWithFeatures extends Slice {
features: MissionFeature[];
}
/** Full mission hierarchy */
export interface MissionWithHierarchy extends Mission {
milestones: MilestoneWithSlices[];
}
/** Fetch all missions */
export function fetchMissions(): Promise<Mission[]> {
return api<Mission[]>("/missions");
}
/** Create a new mission */
export function createMission(input: { title: string; description?: string }): Promise<Mission> {
return api<Mission>("/missions", {
method: "POST",
body: JSON.stringify(input),
});
}
/** Get mission with full hierarchy */
export function fetchMission(missionId: string): Promise<MissionWithHierarchy> {
return api<MissionWithHierarchy>(`/missions/${encodeURIComponent(missionId)}`);
}
/** Update mission */
export function updateMission(missionId: string, updates: Partial<Mission>): Promise<Mission> {
return api<Mission>(`/missions/${encodeURIComponent(missionId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete mission */
export function deleteMission(missionId: string): Promise<void> {
return api<void>(`/missions/${encodeURIComponent(missionId)}`, {
method: "DELETE",
});
}
/** Get mission computed status */
export function fetchMissionStatus(missionId: string): Promise<{ status: string }> {
return api<{ status: string }>(`/missions/${encodeURIComponent(missionId)}/status`);
}
/** Add milestone to mission */
export function createMilestone(
missionId: string,
input: { title: string; description?: string; dependencies?: string[] }
): Promise<Milestone> {
return api<Milestone>(`/missions/${encodeURIComponent(missionId)}/milestones`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update milestone */
export function updateMilestone(milestoneId: string, updates: Partial<Milestone>): Promise<Milestone> {
return api<Milestone>(`/missions/milestones/${encodeURIComponent(milestoneId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete milestone */
export function deleteMilestone(milestoneId: string): Promise<void> {
return api<void>(`/missions/milestones/${encodeURIComponent(milestoneId)}`, {
method: "DELETE",
});
}
/** Reorder milestones */
export function reorderMilestones(missionId: string, orderedIds: string[]): Promise<void> {
return api<void>(`/missions/${encodeURIComponent(missionId)}/milestones/reorder`, {
method: "POST",
body: JSON.stringify({ orderedIds }),
});
}
/** Add slice to milestone */
export function createSlice(
milestoneId: string,
input: { title: string; description?: string }
): Promise<Slice> {
return api<Slice>(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update slice */
export function updateSlice(sliceId: string, updates: Partial<Slice>): Promise<Slice> {
return api<Slice>(`/missions/slices/${encodeURIComponent(sliceId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete slice */
export function deleteSlice(sliceId: string): Promise<void> {
return api<void>(`/missions/slices/${encodeURIComponent(sliceId)}`, {
method: "DELETE",
});
}
/** Activate slice */
export function activateSlice(sliceId: string): Promise<Slice> {
return api<Slice>(`/missions/slices/${encodeURIComponent(sliceId)}/activate`, {
method: "POST",
});
}
/** Reorder slices */
export function reorderSlices(milestoneId: string, orderedIds: string[]): Promise<void> {
return api<void>(`/missions/milestones/${encodeURIComponent(milestoneId)}/slices/reorder`, {
method: "POST",
body: JSON.stringify({ orderedIds }),
});
}
/** Add feature to slice */
export function createFeature(
sliceId: string,
input: { title: string; description?: string; acceptanceCriteria?: string }
): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/slices/${encodeURIComponent(sliceId)}/features`, {
method: "POST",
body: JSON.stringify(input),
});
}
/** Update feature */
export function updateFeature(featureId: string, updates: Partial<MissionFeature>): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
/** Delete feature */
export function deleteFeature(featureId: string): Promise<void> {
return api<void>(`/missions/features/${encodeURIComponent(featureId)}`, {
method: "DELETE",
});
}
/** Link feature to task */
export function linkFeatureToTask(featureId: string, taskId: string): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}/link-task`, {
method: "POST",
body: JSON.stringify({ taskId }),
});
}
/** Unlink feature from task */
export function unlinkFeatureFromTask(featureId: string): Promise<MissionFeature> {
return api<MissionFeature>(`/missions/features/${encodeURIComponent(featureId)}/unlink-task`, {
method: "POST",
});
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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}`;

View File

@@ -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

View File

@@ -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>
</>

View File

@@ -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 ? (

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 &apos;{localFilter}&apos;
</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>
</>

View File

@@ -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";

View File

@@ -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[];

View File

@@ -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,
)

View File

@@ -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. &quot;Use default&quot; 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>

View File

@@ -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 ? (
<>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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([])),
};

View File

@@ -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,

View File

@@ -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(),
}));

View File

@@ -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} />);

View File

@@ -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,

View File

@@ -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."),
}));

View File

@@ -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());