diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json
index 27be8e0..8477c85 100644
--- a/orchestrator/package-lock.json
+++ b/orchestrator/package-lock.json
@@ -8,6 +8,7 @@
"name": "job-ops-orchestrator",
"version": "1.0.0",
"dependencies": {
+ "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -1329,6 +1330,36 @@
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="
},
+ "node_modules/@radix-ui/react-accordion": {
+ "version": "1.2.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz",
+ "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-collapsible": "1.1.12",
+ "@radix-ui/react-collection": "1.1.7",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-direction": "1.1.1",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-alert-dialog": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz",
@@ -1424,6 +1455,35 @@
}
}
},
+ "node_modules/@radix-ui/react-collapsible": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz",
+ "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==",
+ "dependencies": {
+ "@radix-ui/primitive": "1.1.3",
+ "@radix-ui/react-compose-refs": "1.1.2",
+ "@radix-ui/react-context": "1.1.2",
+ "@radix-ui/react-id": "1.1.1",
+ "@radix-ui/react-presence": "1.1.5",
+ "@radix-ui/react-primitive": "2.1.3",
+ "@radix-ui/react-use-controllable-state": "1.2.2",
+ "@radix-ui/react-use-layout-effect": "1.1.1"
+ },
+ "peerDependencies": {
+ "@types/react": "*",
+ "@types/react-dom": "*",
+ "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
+ "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
diff --git a/orchestrator/package.json b/orchestrator/package.json
index 9725965..e7c2d3d 100644
--- a/orchestrator/package.json
+++ b/orchestrator/package.json
@@ -18,6 +18,7 @@
"pipeline:run": "tsx src/server/pipeline/run.ts"
},
"dependencies": {
+ "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dropdown-menu": "^2.1.15",
diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx
index dabedf5..25f5c3c 100644
--- a/orchestrator/src/client/pages/SettingsPage.tsx
+++ b/orchestrator/src/client/pages/SettingsPage.tsx
@@ -17,6 +17,12 @@ import {
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
+import {
+ Accordion,
+ AccordionContent,
+ AccordionItem,
+ AccordionTrigger,
+} from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
@@ -299,437 +305,519 @@ export const SettingsPage: React.FC = () => {
Configure runtime behavior for this app.
-
-
- Model
-
-
-
-
-
Override model
-
setModelDraft(event.target.value)}
- placeholder={defaultModel || "openai/gpt-4o-mini"}
- disabled={isLoading || isSaving}
- />
-
- Leave blank to use the default from server env (`MODEL`).
-
-
-
-
-
-
-
-
Effective
-
{effectiveModel || "—"}
-
-
-
Default (env)
-
{defaultModel || "—"}
-
-
-
-
-
-
-
- Pipeline Webhook
-
-
-
-
-
Pipeline status webhook URL
-
setPipelineWebhookUrlDraft(event.target.value)}
- placeholder={defaultPipelineWebhookUrl || "https://..."}
- disabled={isLoading || isSaving}
- />
-
- When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
-
-
-
-
-
-
-
-
Effective
-
{effectivePipelineWebhookUrl || "—"}
-
-
-
Default (env)
-
{defaultPipelineWebhookUrl || "—"}
-
-
-
-
-
-
-
- Job Complete Webhook
-
-
-
-
-
Job completion webhook URL
-
setJobCompleteWebhookUrlDraft(event.target.value)}
- placeholder={defaultJobCompleteWebhookUrl || "https://..."}
- disabled={isLoading || isSaving}
- />
-
- When set, the server sends a POST when you mark a job as applied (includes the job description).
-
-
-
-
-
-
-
-
Effective
-
{effectiveJobCompleteWebhookUrl || "—"}
-
-
-
Default (env)
-
{defaultJobCompleteWebhookUrl || "—"}
-
-
-
-
-
-
-
- UKVisaJobs Extractor
-
-
-
-
-
Max jobs to fetch
-
{
- const value = parseInt(event.target.value, 10)
- if (Number.isNaN(value)) {
- setUkvisajobsMaxJobsDraft(null)
- } else {
- setUkvisajobsMaxJobsDraft(Math.min(200, Math.max(1, value)))
- }
- }}
- disabled={isLoading || isSaving}
- />
-
- Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-200.
-
-
-
-
-
-
-
-
Effective
-
{effectiveUkvisajobsMaxJobs}
-
-
-
Default
-
{defaultUkvisajobsMaxJobs}
-
-
-
-
-
-
-
- Search Terms
-
-
-
-
-
-
-
-
-
-
Effective
-
{(effectiveSearchTerms || []).join(', ') || "—"}
-
-
-
Default (env)
-
{(defaultSearchTerms || []).join(', ') || "—"}
-
-
-
-
-
-
-
- JobSpy Scraper
-
-
-
-
-
-
Location
-
setJobspyLocationDraft(event.target.value)}
- placeholder={defaultJobspyLocation || "UK"}
- disabled={isLoading || isSaving}
- />
-
- Location to search for jobs (e.g. "UK", "London", "Remote").
+
+
+
+ Model
+
+
+
+
+
Override model
+
setModelDraft(event.target.value)}
+ placeholder={defaultModel || "openai/gpt-4o-mini"}
+ disabled={isLoading || isSaving}
+ />
+
+ Leave blank to use the default from server env (`MODEL`).
+
-
-
Effective: {effectiveJobspyLocation || "—"}
-
Default: {defaultJobspyLocation || "—"}
+
+
+
+
+
+
Effective
+
{effectiveModel || "—"}
+
+
+
Default (env)
+
{defaultModel || "—"}
+
+
+
-
-
Results Wanted
-
{
- const value = parseInt(event.target.value, 10)
- if (Number.isNaN(value)) {
- setJobspyResultsWantedDraft(null)
- } else {
- setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
- }
- }}
- disabled={isLoading || isSaving}
- />
-
- Number of results to fetch per term per site. Max 500.
+
+
+ Pipeline Webhook
+
+
+
+
+
Pipeline status webhook URL
+
setPipelineWebhookUrlDraft(event.target.value)}
+ placeholder={defaultPipelineWebhookUrl || "https://..."}
+ disabled={isLoading || isSaving}
+ />
+
+ When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
+
-
-
Effective: {effectiveJobspyResultsWanted}
-
Default: {defaultJobspyResultsWanted}
+
+
+
+
+
+
Effective
+
{effectivePipelineWebhookUrl || "—"}
+
+
+
Default (env)
+
{defaultPipelineWebhookUrl || "—"}
+
+
+
-
-
Hours Old
-
{
- const value = parseInt(event.target.value, 10)
- if (Number.isNaN(value)) {
- setJobspyHoursOldDraft(null)
- } else {
- setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
- }
- }}
- disabled={isLoading || isSaving}
- />
-
- Max age of jobs in hours (e.g. 72 for 3 days).
+
+
+ Job Complete Webhook
+
+
+
+
+
Job completion webhook URL
+
setJobCompleteWebhookUrlDraft(event.target.value)}
+ placeholder={defaultJobCompleteWebhookUrl || "https://..."}
+ disabled={isLoading || isSaving}
+ />
+
+ When set, the server sends a POST when you mark a job as applied (includes the job description).
+
-
-
Effective: {effectiveJobspyHoursOld}h
-
Default: {defaultJobspyHoursOld}h
+
+
+
+
+
+
Effective
+
{effectiveJobCompleteWebhookUrl || "—"}
+
+
+
Default (env)
+
{defaultJobCompleteWebhookUrl || "—"}
+
+
+
-
-
Indeed Country
-
setJobspyCountryIndeedDraft(event.target.value)}
- placeholder={defaultJobspyCountryIndeed || "UK"}
- disabled={isLoading || isSaving}
- />
-
- Country domain for Indeed (e.g. "UK" for indeed.co.uk).
+
+
+ UKVisaJobs Extractor
+
+
+
+
+
Max jobs to fetch
+
{
+ const value = parseInt(event.target.value, 10)
+ if (Number.isNaN(value)) {
+ setUkvisajobsMaxJobsDraft(null)
+ } else {
+ setUkvisajobsMaxJobsDraft(Math.min(200, Math.max(1, value)))
+ }
+ }}
+ disabled={isLoading || isSaving}
+ />
+
+ Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-200.
+
-
-
Effective: {effectiveJobspyCountryIndeed || "—"}
-
Default: {defaultJobspyCountryIndeed || "—"}
+
+
+
+
+
+
Effective
+
{effectiveUkvisajobsMaxJobs}
+
+
+
Default
+
{defaultUkvisajobsMaxJobs}
+
-
+
+
-
+
+
+ Search Terms
+
+
+
+
-
-
setJobspyLinkedinFetchDescriptionDraft(!!checked)}
- disabled={isLoading || isSaving}
- />
-
-
- Fetch LinkedIn Description
-
-
- If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
-
-
-
Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}
-
Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}
+
+
+
+
+
Effective
+
{(effectiveSearchTerms || []).join(', ') || "—"}
+
+
+
Default (env)
+
{(defaultSearchTerms || []).join(', ') || "—"}
+
-
-
-
+
+
-
-
- Resume Projects
-
+
+
+ JobSpy Scraper
+
+
+
+
+
+
Location
+
setJobspyLocationDraft(event.target.value)}
+ placeholder={defaultJobspyLocation || "UK"}
+ disabled={isLoading || isSaving}
+ />
+
+ Location to search for jobs (e.g. "UK", "London", "Remote").
+
+
+ Effective: {effectiveJobspyLocation || "—"}
+ Default: {defaultJobspyLocation || "—"}
+
+
-
-
-
Max projects included
-
{
- if (!resumeProjectsDraft) return
- const next = Number(event.target.value)
- const clamped = clampInt(next, lockedCount, maxProjectsTotal)
- setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
- }}
- disabled={isLoading || isSaving || !resumeProjectsDraft}
- />
-
- Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
- {resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
+
+
Results Wanted
+
{
+ const value = parseInt(event.target.value, 10)
+ if (Number.isNaN(value)) {
+ setJobspyResultsWantedDraft(null)
+ } else {
+ setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value)))
+ }
+ }}
+ disabled={isLoading || isSaving}
+ />
+
+ Number of results to fetch per term per site. Max 500.
+
+
+ Effective: {effectiveJobspyResultsWanted}
+ Default: {defaultJobspyResultsWanted}
+
+
+
+
+
Hours Old
+
{
+ const value = parseInt(event.target.value, 10)
+ if (Number.isNaN(value)) {
+ setJobspyHoursOldDraft(null)
+ } else {
+ setJobspyHoursOldDraft(Math.min(168, Math.max(1, value)))
+ }
+ }}
+ disabled={isLoading || isSaving}
+ />
+
+ Max age of jobs in hours (e.g. 72 for 3 days).
+
+
+ Effective: {effectiveJobspyHoursOld}h
+ Default: {defaultJobspyHoursOld}h
+
+
+
+
+
Indeed Country
+
setJobspyCountryIndeedDraft(event.target.value)}
+ placeholder={defaultJobspyCountryIndeed || "UK"}
+ disabled={isLoading || isSaving}
+ />
+
+ Country domain for Indeed (e.g. "UK" for indeed.co.uk).
+
+
+ Effective: {effectiveJobspyCountryIndeed || "—"}
+ Default: {defaultJobspyCountryIndeed || "—"}
+
+
+
+
+
+
+
+
setJobspyLinkedinFetchDescriptionDraft(!!checked)}
+ disabled={isLoading || isSaving}
+ />
+
+
+ Fetch LinkedIn Description
+
+
+ If enabled, JobSpy will make extra requests to fetch full descriptions. Slower but better data.
+
+
+ Effective: {effectiveJobspyLinkedinFetchDescription ? "Yes" : "No"}
+ Default: {defaultJobspyLinkedinFetchDescription ? "Yes" : "No"}
+
+
+
-
+
+
-
+
+
+ Resume Projects
+
+
+
+
+
Max projects included
+
{
+ if (!resumeProjectsDraft) return
+ const next = Number(event.target.value)
+ const clamped = clampInt(next, lockedCount, maxProjectsTotal)
+ setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped })
+ }}
+ disabled={isLoading || isSaving || !resumeProjectsDraft}
+ />
+
+ Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "}
+ {resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal}
+
+
-
-
-
- Project
- Base visible
- Locked
- AI selectable
-
-
-
- {profileProjects.map((project) => {
- const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
- const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
- const excluded = !locked && !aiSelectable
+
- return (
-
-
-
-
{project.name || project.id}
-
- {[project.description, project.date].filter(Boolean).join(" · ")}
- {excluded ? " · Excluded" : ""}
-
-
-
- {project.isVisibleInBase ? "Yes" : "No"}
-
- {
- if (!resumeProjectsDraft) return
- const isChecked = checked === true
- const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
- const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
-
- if (isChecked) {
- if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
- const nextSelectable = selectableIds.filter((id) => id !== project.id)
- const minCap = lockedIds.length
- setResumeProjectsDraft({
- ...resumeProjectsDraft,
- lockedProjectIds: lockedIds,
- aiSelectableProjectIds: nextSelectable,
- maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
- })
- return
- }
-
- const nextLocked = lockedIds.filter((id) => id !== project.id)
- if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
- setResumeProjectsDraft({
- ...resumeProjectsDraft,
- lockedProjectIds: nextLocked,
- aiSelectableProjectIds: selectableIds,
- maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
- })
- }}
- />
-
-
- {
- if (!resumeProjectsDraft) return
- const isChecked = checked === true
- const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
- const nextSelectable = isChecked
- ? selectableIds.includes(project.id)
- ? selectableIds
- : [...selectableIds, project.id]
- : selectableIds.filter((id) => id !== project.id)
- setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
- }}
- />
-
+
+
+
+ Project
+ Base visible
+ Locked
+ AI selectable
- )
- })}
-
-
-
-
+
+
+ {profileProjects.map((project) => {
+ const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id))
+ const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id))
+ const excluded = !locked && !aiSelectable
+
+ return (
+
+
+
+
{project.name || project.id}
+
+ {[project.description, project.date].filter(Boolean).join(" · ")}
+ {excluded ? " · Excluded" : ""}
+
+
+
+ {project.isVisibleInBase ? "Yes" : "No"}
+
+ {
+ if (!resumeProjectsDraft) return
+ const isChecked = checked === true
+ const lockedIds = resumeProjectsDraft.lockedProjectIds.slice()
+ const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
+
+ if (isChecked) {
+ if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
+ const nextSelectable = selectableIds.filter((id) => id !== project.id)
+ const minCap = lockedIds.length
+ setResumeProjectsDraft({
+ ...resumeProjectsDraft,
+ lockedProjectIds: lockedIds,
+ aiSelectableProjectIds: nextSelectable,
+ maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap),
+ })
+ return
+ }
+
+ const nextLocked = lockedIds.filter((id) => id !== project.id)
+ if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
+ setResumeProjectsDraft({
+ ...resumeProjectsDraft,
+ lockedProjectIds: nextLocked,
+ aiSelectableProjectIds: selectableIds,
+ maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal),
+ })
+ }}
+ />
+
+
+ {
+ if (!resumeProjectsDraft) return
+ const isChecked = checked === true
+ const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice()
+ const nextSelectable = isChecked
+ ? selectableIds.includes(project.id)
+ ? selectableIds
+ : [...selectableIds, project.id]
+ : selectableIds.filter((id) => id !== project.id)
+ setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable })
+ }}
+ />
+
+
+ )
+ })}
+
+
+
+
+
+
+
+
+ Danger Zone
+
+
+
+
+
+
Clear Discovered Jobs
+
+ Delete all jobs with the status "discovered". Ready, applied, and rejected jobs are kept.
+
+
+
+
+
+
+ Clear Discovered
+
+
+
+
+ Clear discovered jobs?
+
+ This deletes all jobs with the status "discovered". This action cannot be undone.
+
+
+
+ Cancel
+
+ Clear discovered
+
+
+
+
+
+
+
+
+
+
+
Clear Database
+
+ Delete all jobs and pipeline runs from the database.
+
+
+
+
+
+
+ Clear Database
+
+
+
+
+ Clear all jobs?
+
+ This deletes all jobs and pipeline runs from the database. This action cannot be undone.
+
+
+
+ Cancel
+
+ Clear database
+
+
+
+
+
+
+
+
+
@@ -739,80 +827,6 @@ export const SettingsPage: React.FC = () => {
Reset to default
-
-
-
- Danger Zone
-
- Irreversible actions that modify your database.
-
-
-
-
-
-
Clear Discovered Jobs
-
- Delete all jobs with the status "discovered". Ready, applied, and rejected jobs are kept.
-
-
-
-
-
-
- Clear Discovered
-
-
-
-
- Clear discovered jobs?
-
- This deletes all jobs with the status "discovered". This action cannot be undone.
-
-
-
- Cancel
-
- Clear discovered
-
-
-
-
-
-
-
-
-
-
-
Clear Database
-
- Delete all jobs and pipeline runs from the database.
-
-
-
-
-
-
- Clear Database
-
-
-
-
- Clear all jobs?
-
- This deletes all jobs and pipeline runs from the database. This action cannot be undone.
-
-
-
- Cancel
-
- Clear database
-
-
-
-
-
-
-
)
}
diff --git a/orchestrator/src/components/ui/accordion.tsx b/orchestrator/src/components/ui/accordion.tsx
new file mode 100644
index 0000000..24c788c
--- /dev/null
+++ b/orchestrator/src/components/ui/accordion.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as AccordionPrimitive from "@radix-ui/react-accordion"
+import { ChevronDown } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const Accordion = AccordionPrimitive.Root
+
+const AccordionItem = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AccordionItem.displayName = "AccordionItem"
+
+const AccordionTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ svg]:rotate-180",
+ className
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+))
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+
+const AccordionContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+ {children}
+
+))
+
+AccordionContent.displayName = AccordionPrimitive.Content.displayName
+
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
diff --git a/orchestrator/tailwind.config.ts b/orchestrator/tailwind.config.ts
index a2766a1..64f9d9b 100644
--- a/orchestrator/tailwind.config.ts
+++ b/orchestrator/tailwind.config.ts
@@ -4,7 +4,22 @@ export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
- extend: {},
+ extend: {
+ keyframes: {
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
+ },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
+ },
+ },
+ animation: {
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
+ },
+ },
},
plugins: [],
} satisfies Config;