Files
fusion/packages/dashboard/app/components/SkillMultiselect.tsx
gsxdsm f11800f2c6 refactor(dashboard): split monolithic styles.css into per-component files
Split app/styles.css from ~40k lines down to ~4.5k. Created 56 co-located
component CSS files in app/components/, each imported by its owning .tsx.
The remainder of styles.css holds genuinely global rules (design tokens,
.btn/.card/.modal/.form-input primitives, cross-component @media overrides).

- Lazy-load 13 heavy views (AgentsView, RoadmapsView, NodesView, etc.) via
  React.lazy + Suspense; prefetch all chunks on idle so first navigation is
  instant. Initial JS bundle: 1.58 MB → 1.16 MB (-26%). Initial CSS bundle:
  635 kB → 471 kB (-26%); the rest splits into 13 per-view chunks.

- Add app/test/cssFixture.ts exposing loadAllAppCss() + loadAllAppCssBaseOnly()
  so CSS regression tests load the full per-component bundle (mirroring Vite
  source order). Migrate 30+ tests off direct readFileSync('../styles.css').

- Enable test.css: { include: [/.+/] } in vitest.config.ts so component CSS
  imports actually inject styles in jsdom (fixes getComputedStyle assertions).

- Add ESLint rule (no-restricted-syntax) banning direct styles.css reads in
  dashboard test files; points at loadAllAppCss() instead.

- Restore lost utility classes (.text-muted, .text-secondary, .text-dim,
  .form-input) and rescue dropped chat tool-call rules into QuickChatFAB.css.

- Mobile fixes along the way: scroll containment for view containers
  (min-height:0 + -webkit-overflow-scrolling), QuickChatFAB full-screen on
  mobile (with safe-area-inset for iOS home bar), AgentsView single-row
  header layout, ActivityLogModal close button on right, model-combobox
  z-index above the mobile quick-chat panel.

- Bug fix: SkillsView toggle was display:none which hid the input from the
  accessibility tree; replaced with the visually-hidden pattern so screen
  readers + getByRole still find the checkbox.

- Bug fix: standalone Delete button in TaskDetailModal for triage-column
  tasks (Actions dropdown is hidden in triage state, so previously no way
  to delete a freshly-created task without status change first).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 19:39:20 -07:00

150 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./SkillMultiselect.css";
import { useState, useEffect } from "react";
import { fetchDiscoveredSkills } from "../api";
import type { DiscoveredSkill } from "../api";
export interface SkillMultiselectProps {
/** Currently selected skill IDs */
value: string[];
/** Called when the selection changes */
onChange: (skills: string[]) => void;
/** Project context for API calls */
projectId?: string;
/** Disable the control */
disabled?: boolean;
/** HTML id for accessibility */
id?: string;
/** Label text shown above the component */
label?: string;
}
/**
* Reusable skill multiselect component.
* Shows selected skills as removable chips and a dropdown to add more skills.
*/
export function SkillMultiselect({
value,
onChange,
projectId,
disabled = false,
id,
label = "Skills",
}: SkillMultiselectProps) {
const [skills, setSkills] = useState<DiscoveredSkill[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetchDiscoveredSkills(projectId)
.then((discovered) => {
if (!cancelled) {
setSkills(discovered);
}
})
.catch(() => {
if (!cancelled) {
setSkills([]);
}
})
.finally(() => {
if (!cancelled) {
setIsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [projectId]);
const handleAddSkill = (skillId: string) => {
if (!value.includes(skillId)) {
onChange([...value, skillId]);
}
};
const handleRemoveSkill = (skillId: string) => {
onChange(value.filter((id) => id !== skillId));
};
// Get skill names for display
const getSkillName = (skillId: string): string => {
const skill = skills.find((s) => s.id === skillId);
return skill?.name ?? skillId;
};
// Skills available to add (not yet selected)
const availableSkills = skills.filter((s) => !value.includes(s.id));
return (
<div className="skill-multiselect" data-testid="skill-multiselect">
{label && (
<label
htmlFor={id ? `${id}-select` : undefined}
className="skill-multiselect-label"
>
{label}
</label>
)}
{/* Selected skill chips */}
{value.length > 0 && (
<div className="skill-multiselect-chips" data-testid="skill-chips">
{value.map((skillId) => (
<span key={skillId} className="skill-chip" data-testid={`skill-chip-${skillId}`}>
<span className="skill-chip-name">{getSkillName(skillId)}</span>
<button
type="button"
className="skill-chip-remove"
onClick={() => handleRemoveSkill(skillId)}
disabled={disabled}
aria-label={`Remove ${getSkillName(skillId)}`}
data-testid={`remove-skill-${skillId}`}
>
×
</button>
</span>
))}
</div>
)}
{/* Dropdown to add skills */}
<div className="skill-multiselect-add">
{isLoading ? (
<span className="skill-multiselect-loading" data-testid="skills-loading">
Loading skills
</span>
) : availableSkills.length === 0 ? (
<span className="skill-multiselect-empty" data-testid="skills-empty">
{value.length === 0 ? "No skills discovered" : "All skills selected"}
</span>
) : (
<select
id={id ? `${id}-select` : undefined}
className="select skill-multiselect-dropdown"
value=""
onChange={(e) => {
if (e.target.value) {
handleAddSkill(e.target.value);
// Reset to placeholder
e.target.value = "";
}
}}
disabled={disabled}
data-testid="skill-dropdown"
>
<option value="">Add a skill</option>
{availableSkills.map((skill) => (
<option key={skill.id} value={skill.id}>
{skill.name}
</option>
))}
</select>
)}
</div>
</div>
);
}