diff --git a/.claude/memory.md b/.claude/memory.md index 3731748cb8..1094a341d0 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -208,7 +208,11 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **Core port** — `7788` (default; in-process inside Tauri host). Check with `lsof -i :7788`. - **`pnpm core:stage`** — no-op (sidecar removed in PR #1061). Use `pnpm dev:app` for full Tauri+core dev. - **Kill stuck processes** — `lsof -i :7788` then `kill `. Useful when `dev:app` reports a stale listener and you want to force a fresh boot rather than relying on the handle's auto-recovery. -- **Skills runtime removed** — the QuickJS / `rquickjs` runtime is gone; `src/openhuman/skills/` is metadata-only ("Legacy skill metadata helpers retained after QuickJS runtime removal"). Skill execution surfaces are being rebuilt; don't assume a `.skill` can run end-to-end without checking the current code. +- **Skills runtime rebuilt (PR #2707)** — QuickJS is gone, but skills now run as orchestrator-focused agents via `skills_run` RPC. Default skills live in `src/openhuman/skills/defaults//` with `skill.toml` + `SKILL.md`, registered in `registry.rs` `DEFAULT_SKILLS` const. Seeded into `/skills/` on boot (idempotent, non-destructive). Bundled defaults: `github-issue-crusher`, `dev-workflow`. Skills run with 200 iteration cap and full web access. +- **Codegraph tools (PR #2707)** — `codegraph_index` and `codegraph_search` registered in `src/openhuman/tools/ops.rs`. Implementation in `src/openhuman/codegraph/` — tree-sitter extraction, SQLite FTS5, dense embeddings, RRF fusion. Auto-indexes on first search. +- **Tool names are exact** — Always check `src/openhuman/tools/ops.rs` for authoritative names. Key ones: `edit` (not `edit_file`), `composio` (not `composio_execute`), `codegraph_index`, `codegraph_search`. +- **`cron_add` RPC** — Was missing from `schemas.rs` (only existed as agent tool). Now exposed as `openhuman.cron_add`. Frontend wrapper: `openhumanCronAdd()` in `app/src/utils/tauriCommands/cron.ts`. +- **Worktree `pnpm build` rolldown fix** — Worktrees can miss `@rolldown/binding-darwin-arm64`. Fix: `pnpm install --force`. ## Artifacts Domain (Issue #2776) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 5b51002508..a3bd4fc4dc 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -2023,10 +2023,15 @@ fn append_platform_cef_gpu_workarounds(args: &mut Vec, os: &s #[cfg(target_os = "linux")] { let uid = nix::unistd::getuid().as_raw(); - if os == "linux" && linux_is_root_uid(uid) { + // Dev-only: also honor OPENHUMAN_CEF_NO_SANDBOX=1 so a non-root headless + // box (no sudo to chown chrome-sandbox root:4755) can launch over RDP. + let forced = std::env::var("OPENHUMAN_CEF_NO_SANDBOX") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false); + if os == "linux" && (linux_is_root_uid(uid) || forced) { args.push(("--no-sandbox", None)); log::info!( - "[cef-startup] running as root (uid=0) on Linux: adding --no-sandbox \ + "[cef-startup] Linux: adding --no-sandbox (root uid or OPENHUMAN_CEF_NO_SANDBOX) \ (OPENHUMAN-TAURI-K1)" ); } diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index 0dce108a3c..62c6cb3eb0 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -16,7 +16,9 @@ import Onboarding from './pages/onboarding/Onboarding'; import Rewards from './pages/Rewards'; import Routines from './pages/Routines'; import Settings from './pages/Settings'; +import SkillNew from './pages/SkillNew'; import Skills from './pages/Skills'; +import SkillsRun from './pages/SkillsRun'; import WebCallbackPage from './pages/WebCallbackPage'; import Welcome from './pages/Welcome'; @@ -80,6 +82,33 @@ const AppRoutes = () => { } /> + {/* Skills lives at /skills with its 4 sub-tabs (Composio / Channels / + MCP Servers / Runners). The scheduled-skills dashboard concept + composes INSIDE the Runners sub-tab, not as a separate top-level + page — the bottom-bar "Connections" entry has always pointed at + /skills to surface Composio integrations + MCP, and that muscle + memory is restored here. + `/skills/new` is the create-a-skill authoring page. + Order matters: keep `/skills/new` before `/skills` so it wins the + prefix match. */} + + + + } + /> + + + + + } + /> + { @@ -81,13 +61,11 @@ const DevWorkflowPanel = () => { const [reposLoading, setReposLoading] = useState(false); const [reposError, setReposError] = useState(null); - // Lazy-initialised state from persisted config - const initialConfig = loadSavedConfig(); - const [savedConfig, setSavedConfig] = useState(initialConfig); - const [selectedRepo, setSelectedRepo] = useState(initialConfig?.repoFullName ?? ''); - const [forkInfo, setForkInfo] = useState(initialConfig?.forkInfo ?? null); - const [targetBranch, setTargetBranch] = useState(initialConfig?.targetBranch ?? ''); - const [schedule, setSchedule] = useState(initialConfig?.schedule ?? SCHEDULE_PRESETS[0].value); + // Form state + const [selectedRepo, setSelectedRepo] = useState(''); + const [forkInfo, setForkInfo] = useState(null); + const [targetBranch, setTargetBranch] = useState(''); + const [schedule, setSchedule] = useState(SCHEDULE_PRESETS[0].value); // Fork detection loading const [forkLoading, setForkLoading] = useState(false); @@ -99,6 +77,41 @@ const DevWorkflowPanel = () => { // Save state const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle'); + // Cron job state + const [existingJob, setExistingJob] = useState(null); + const [cronLoading, setCronLoading] = useState(false); + const [runHistory, setRunHistory] = useState([]); + const [historyExpanded, setHistoryExpanded] = useState(false); + const [expandedRunId, setExpandedRunId] = useState(null); + const [running, setRunning] = useState(false); + + // ── Load existing cron job on mount ───────────────────────────────── + const loadExistingJob = useCallback(async () => { + setCronLoading(true); + try { + const res = await openhumanCronList(); + // RPC returns { result: CronJob[], logs: [...] } + const jobs = (res as { result?: CoreCronJob[] }).result ?? []; + const jobList = Array.isArray(jobs) ? jobs : []; + const found = jobList.find((j: CoreCronJob) => j.name?.startsWith('dev-workflow') ?? false); + if (found) { + setExistingJob(found); + log('found existing dev-workflow cron job: %s', found.id); + } else { + setExistingJob(null); + log('no existing dev-workflow cron job found'); + } + } catch (err) { + log('failed to load existing cron job: %s', err); + } finally { + setCronLoading(false); + } + }, []); + + useEffect(() => { + void loadExistingJob(); + }, [loadExistingJob]); + // ── Fetch repos via composio_execute ──────────────────────────────── const loadRepos = useCallback(async () => { setReposLoading(true); @@ -289,40 +302,162 @@ const DevWorkflowPanel = () => { [repos] ); + // ── Load run history ─────────────────────────────────────────────── + const loadRunHistory = useCallback(async () => { + if (!existingJob) return; + try { + const res = await openhumanCronRuns(existingJob.id, 5); + // RPC returns { result: { runs: CronRun[] }, logs: [...] } + const raw = (res as { result?: { runs?: CoreCronRun[] } }).result; + const runs = raw?.runs ?? []; + setRunHistory(Array.isArray(runs) ? runs : []); + log( + 'loaded %d run history entries for job %s', + Array.isArray(runs) ? runs.length : 0, + existingJob.id + ); + } catch (err) { + log('failed to load run history: %s', err); + } + }, [existingJob]); + + useEffect(() => { + if (existingJob) { + void loadRunHistory(); + } + }, [existingJob, loadRunHistory]); + // ── Save config ──────────────────────────────────────────────────── - const handleSave = () => { + const handleSave = useCallback(async () => { if (!selectedRepo || !targetBranch) return; - const [owner, repo] = selectedRepo.split('/'); - const config: DevWorkflowConfig = { - repoFullName: selectedRepo, - repoOwner: owner, - repoName: repo, - forkInfo, - targetBranch, - schedule, + const [owner] = selectedRepo.split('/'); + const upstreamName = forkInfo ? forkInfo.upstreamFullName : selectedRepo; + + const repoName = upstreamName.split('/')[1] ?? selectedRepo.split('/')[1] ?? ''; + const skillPrompt = [ + `You are running the dev-workflow skill. Follow these guidelines exactly.`, + ``, + `# Dev Workflow — Autonomous Issue Crusher`, + ``, + `Find a GitHub issue on \`${upstreamName}\`, implement a fix, and deliver a PR.`, + ``, + `## Repos`, + `- **Upstream** = \`${upstreamName}\` — issues live here, PRs target \`${targetBranch}\`.`, + `- **Fork** = \`${owner}/${repoName}\` — push the fix branch here.`, + `- Commit through the GitHub API — no local git push.`, + ``, + `## Issue Selection (smart fallback)`, + `1. **First**: Look for open issues assigned to \`${owner}\` on \`${upstreamName}\` with no linked PR.`, + `2. **If none assigned**: Find unassigned open issues. Prefer issues labeled \`good first issue\`, \`bug\`, \`help wanted\`, or \`easy\`. Prefer issues with detailed descriptions (>500 chars). Skip issues that already have an open PR linked.`, + `3. **Self-assign**: Once you pick an unassigned issue, assign it to \`${owner}\` using GITHUB_ADD_ASSIGNEES so no one else picks it up concurrently.`, + `4. **If no suitable issues at all**: Exit cleanly — report "no suitable issues found".`, + ``, + `## Implementation Steps`, + `1. Read the full issue body, comments, and labels.`, + `2. Ensure fork \`${owner}/${repoName}\` exists (create if needed).`, + `3. Clone \`${upstreamName}\` locally, branch \`dev-workflow/-\` off \`${targetBranch}\`.`, + `4. Run \`codegraph_index\` on the repo.`, + `5. Use \`codegraph_search\` to find relevant code. Fall back to grep/glob if coverage isn't full.`, + `6. Implement the minimal correct fix. Re-read files and git diff — don't trust memory.`, + `7. Run tests. Iterate until green.`, + `8. Push via GitHub API (blob → tree → commit → update-ref). Do NOT git push.`, + `9. Open cross-repo PR: \`${upstreamName}:${targetBranch}\` ← \`${owner}:\`. Body: Closes #N + summary + how you verified.`, + ``, + `## Rules`, + `- One PR per run, then stop.`, + `- Only fix the picked issue — no unrelated changes.`, + `- codegraph is an accelerant, not a gate — fall back to grep if cold.`, + `- If too large/risky (would touch >20 files or needs multi-system changes), comment on the issue explaining why and skip.`, + `- Never force-push or push to upstream directly.`, + ].join('\n'); + + const cronParams: CronAddParams = { + name: `dev-workflow-${selectedRepo.replace('/', '-')}`, + schedule: { kind: 'cron', expr: schedule }, + job_type: 'agent', + prompt: skillPrompt, + session_target: 'isolated', + delivery: { mode: 'proactive', best_effort: true }, }; - saveConfig(config); - setSavedConfig(config); - setSaveStatus('saved'); - log('saved dev workflow config: %o', config); + log( + 'saving dev-workflow cron job: existingJob=%s, repo=%s', + existingJob?.id ?? 'none', + selectedRepo + ); - setTimeout(() => setSaveStatus('idle'), 3000); - }; + try { + if (existingJob) { + // Update existing job + await openhumanCronUpdate(existingJob.id, { + name: cronParams.name, + schedule: cronParams.schedule, + prompt: cronParams.prompt, + }); + log('updated cron job %s', existingJob.id); + } else { + // Create new job + await openhumanCronAdd(cronParams); + log('created new dev-workflow cron job for repo=%s', selectedRepo); + } + setSaveStatus('saved'); + void loadExistingJob(); // Refresh + setTimeout(() => setSaveStatus('idle'), 3000); + } catch (err) { + log('save error: %s', err); + setSaveStatus('error'); + } + }, [selectedRepo, targetBranch, forkInfo, schedule, existingJob, loadExistingJob]); // ── Remove config ────────────────────────────────────────────────── - const handleRemove = () => { - clearConfig(); - setSavedConfig(null); - setSelectedRepo(''); - setForkInfo(null); - setBranches([]); - setTargetBranch(''); - setSchedule(SCHEDULE_PRESETS[0].value); - setSaveStatus('idle'); - log('removed dev workflow config'); - }; + const handleRemove = useCallback(async () => { + if (!existingJob) return; + log('removing dev-workflow cron job %s', existingJob.id); + try { + await openhumanCronRemove(existingJob.id); + setExistingJob(null); + setSelectedRepo(''); + setForkInfo(null); + setBranches([]); + setTargetBranch(''); + setSchedule(SCHEDULE_PRESETS[0].value); + setSaveStatus('idle'); + setRunHistory([]); + log('removed dev workflow cron job'); + } catch (err) { + log('remove error: %s', err); + } + }, [existingJob]); + + // ── Toggle enable/disable ────────────────────────────────────────── + const handleToggle = useCallback(async () => { + if (!existingJob) return; + const newEnabled = !existingJob.enabled; + log('toggling cron job %s enabled=%s', existingJob.id, newEnabled); + try { + await openhumanCronUpdate(existingJob.id, { enabled: newEnabled }); + void loadExistingJob(); + } catch (err) { + log('toggle error: %s', err); + } + }, [existingJob, loadExistingJob]); + + // ── Run Now ──────────────────────────────────────────────────────── + const handleRunNow = useCallback(async () => { + if (!existingJob) return; + setRunning(true); + log('running cron job %s now', existingJob.id); + try { + await openhumanCronRun(existingJob.id); + void loadExistingJob(); + void loadRunHistory(); + } catch (err) { + log('run now error: %s', err); + } finally { + setRunning(false); + } + }, [existingJob, loadExistingJob, loadRunHistory]); // ── Render ───────────────────────────────────────────────────────── const canSave = selectedRepo && targetBranch && schedule; @@ -342,188 +477,313 @@ const DevWorkflowPanel = () => { {t('settings.developerMenu.devWorkflow.panelDesc')}

- {/* Repo selector */} -
- - {reposError && ( -
- {reposError} -
- )} - -
- - {/* Fork info */} - {forkLoading && ( -
- {t('settings.devWorkflow.detectingForkInfo')} -
- )} - {forkInfo && ( -
-
- {t('settings.devWorkflow.forkDetected')} -
-
- {t('settings.devWorkflow.upstream')}{' '} - {forkInfo.upstreamFullName} -
-
- {t('settings.devWorkflow.forkPrNote')} -
-
- )} - {selectedRepo && !forkLoading && !forkInfo && ( -
-
- {t('settings.devWorkflow.notForkNote')} -
-
- )} - - {/* Branch selector */} - {branches.length > 0 && ( -
- -

- {t('settings.devWorkflow.targetBranchNote')} - {forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}. -

- -
- )} - {branchesLoading && ( + {/* Active config summary — shown at top regardless of repo loading */} + {cronLoading && (
- {t('settings.devWorkflow.loadingBranches')} + {t('settings.devWorkflow.loadingRepositories')}
)} - - {/* Schedule */} - {selectedRepo && ( -
- -

- {t('settings.devWorkflow.runFrequencyNote')} -

- -
- )} - - {/* Actions */} - {selectedRepo && ( -
- - {savedConfig && ( - - )} - {saveStatus === 'saved' && ( - - {t('settings.devWorkflow.saved')} - + {existingJob && ( +
+ {/* Running indicator */} + {running && ( +
+ + + {t('settings.devWorkflow.runningStatus')} + +
)} -
- )} - - {/* Active config summary */} - {savedConfig && ( -
-
- {t('settings.devWorkflow.activeConfiguration')} +
+
+ {t('settings.devWorkflow.activeConfiguration')} +
+
+ + + {existingJob.enabled + ? t('settings.devWorkflow.enabled') + : t('settings.devWorkflow.paused')} + +
{t('settings.devWorkflow.activeConfigRepository')}
- {savedConfig.repoFullName} + {existingJob.name?.replace(/^dev-workflow-/, '') ?? '—'}
- {savedConfig.forkInfo && ( - <> -
- {t('settings.devWorkflow.activeConfigUpstream')} -
-
- {savedConfig.forkInfo.upstreamFullName} -
- - )}
- {t('settings.devWorkflow.activeConfigTargetBranch')} + {t('settings.devWorkflow.activeConfigSchedule')}
-
- {savedConfig.targetBranch} +
+ {SCHEDULE_PRESETS.find(p => p.value === existingJob.expression) + ? t(SCHEDULE_PRESETS.find(p => p.value === existingJob.expression)!.labelKey) + : existingJob.expression}
- {t('settings.devWorkflow.activeConfigSchedule')} + {t('settings.devWorkflow.nextRun')}
- {SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule) != null - ? t(SCHEDULE_PRESETS.find(p => p.value === savedConfig.schedule)!.labelKey) - : savedConfig.schedule} + {existingJob.next_run ? new Date(existingJob.next_run).toLocaleString() : '—'}
+ {existingJob.last_run && ( + <> +
+ {t('settings.devWorkflow.lastRun')} +
+
+ {new Date(existingJob.last_run).toLocaleString()} + {existingJob.last_status && ( + + {existingJob.last_status} + + )} +
+ + )}
-

- {t('settings.devWorkflow.phase2Note')} -

+ +
+ + +
+ + {existingJob.last_output && ( +
+
+ {t('settings.devWorkflow.lastOutput')} +
+
+                  {existingJob.last_output}
+                
+
+ )} + + {runHistory.length > 0 && ( +
+ + {historyExpanded && ( +
+ {runHistory.map(run => ( +
+ + {expandedRunId === run.id && run.output && ( +
+                            {run.output}
+                          
+ )} + {expandedRunId === run.id && !run.output && ( +
+ {t('settings.devWorkflow.noOutput')} +
+ )} +
+ ))} +
+ )} +
+ )}
)} + + {/* Setup form — only shown when no active config exists */} + {!existingJob && ( + <> +
+ + {reposError && ( +
+ {reposError} +
+ )} + +
+ + {/* Fork info */} + {forkLoading && ( +
+ {t('settings.devWorkflow.detectingForkInfo')} +
+ )} + {forkInfo && ( +
+
+ {t('settings.devWorkflow.forkDetected')} +
+
+ {t('settings.devWorkflow.upstream')}{' '} + {forkInfo.upstreamFullName} +
+
+ {t('settings.devWorkflow.forkPrNote')} +
+
+ )} + {selectedRepo && !forkLoading && !forkInfo && ( +
+
+ {t('settings.devWorkflow.notForkNote')} +
+
+ )} + + {/* Branch selector */} + {branches.length > 0 && ( +
+ +

+ {t('settings.devWorkflow.targetBranchNote')} + {forkInfo ? ` on ${forkInfo.upstreamFullName}` : ''}. +

+ +
+ )} + {branchesLoading && ( +
+ {t('settings.devWorkflow.loadingBranches')} +
+ )} + + {/* Schedule */} + {selectedRepo && ( +
+ +

+ {t('settings.devWorkflow.runFrequencyNote')} +

+ +
+ )} + + {/* Actions */} + {selectedRepo && ( +
+ + {saveStatus === 'saved' && ( + + {t('settings.devWorkflow.saved')} + + )} + {saveStatus === 'error' && ( + + {t('settings.devWorkflow.cronSaveError')} + + )} +
+ )} + + )}
); diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 3529af612c..9c74dacaca 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -74,6 +74,28 @@ const developerItems = [ ), }, + // Settings → Developer → Skills Runner is commented out: the same UX + // (and more) now lives at /skills (Connections → Runners sub-tab) as + // the scheduled-skills dashboard, with /skills/run for ad-hoc runs. + // The route + panel component remain wired (Settings.tsx:458 keeps the + // /settings/skills-runner route), so deep links and bookmarks still + // resolve — only the menu entry is hidden. + // { + // id: 'skills-runner', + // titleKey: 'settings.developerMenu.skillsRunner.title', + // descriptionKey: 'settings.developerMenu.skillsRunner.desc', + // route: 'skills-runner', + // icon: ( + // + // + // + // ), + // }, { id: 'dev-workflow', titleKey: 'settings.developerMenu.devWorkflow.title', diff --git a/app/src/components/settings/panels/SkillsRunnerPanel.tsx b/app/src/components/settings/panels/SkillsRunnerPanel.tsx new file mode 100644 index 0000000000..2656cef807 --- /dev/null +++ b/app/src/components/settings/panels/SkillsRunnerPanel.tsx @@ -0,0 +1,31 @@ +// Settings → Developer Options → Skills Runner — thin wrapper around the +// reusable `` so the settings shell (header + back +// button + breadcrumbs) stays consistent with other panels. The actual +// picker / Run / Schedule / Recent Runs UX lives in +// `app/src/components/skills/SkillsRunnerBody.tsx`, shared with the +// top-level /skills page's "Runners" tab. +import { useT } from '../../../lib/i18n/I18nContext'; +import SkillsRunnerBody from '../../skills/SkillsRunnerBody'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +const SkillsRunnerPanel = () => { + const { t } = useT(); + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + + return ( +
+ +
+ +
+
+ ); +}; + +export default SkillsRunnerPanel; diff --git a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx index 78389c881e..de927d3795 100644 --- a/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/DevWorkflowPanel.test.tsx @@ -4,15 +4,33 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { renderWithProviders } from '../../../../test/test-utils'; // [dev-workflow] Unit tests for DevWorkflowPanel.tsx — covers repo loading, -// not-connected error, fork detection, branch population, and save/clear wiring. - -const hoisted = vi.hoisted(() => ({ composioExecute: vi.fn(), listConnections: vi.fn() })); +// not-connected error, fork detection, branch population, and cron job wiring. + +const hoisted = vi.hoisted(() => ({ + composioExecute: vi.fn(), + listConnections: vi.fn(), + cronAdd: vi.fn(), + cronList: vi.fn(), + cronRemove: vi.fn(), + cronUpdate: vi.fn(), + cronRun: vi.fn(), + cronRuns: vi.fn(), +})); vi.mock('../../../../lib/composio/composioApi', () => ({ execute: hoisted.composioExecute, listConnections: hoisted.listConnections, })); +vi.mock('../../../../utils/tauriCommands/cron', () => ({ + openhumanCronAdd: hoisted.cronAdd, + openhumanCronList: hoisted.cronList, + openhumanCronRemove: hoisted.cronRemove, + openhumanCronUpdate: hoisted.cronUpdate, + openhumanCronRun: hoisted.cronRun, + openhumanCronRuns: hoisted.cronRuns, +})); + // Stable t function — creating a new function object on every render // would cause useCallback([t]) to re-create on every render, triggering // the loadRepos useEffect in an infinite loop. @@ -32,7 +50,7 @@ vi.mock('../../components/SettingsHeader', () => ({ })); // Import once — DevWorkflowPanel state is managed via API mocks and -// localStorage, not module-level vars, so a single import is sufficient. +// cron RPC, not module-level vars, so a single import is sufficient. async function importPanel() { const mod = await import('../DevWorkflowPanel'); return mod.default; @@ -82,9 +100,15 @@ const branchesResponse = { describe('DevWorkflowPanel', () => { beforeEach(() => { vi.clearAllMocks(); - localStorage.clear(); hoisted.listConnections.mockResolvedValue(githubConnection); hoisted.composioExecute.mockResolvedValue(reposResponse); + hoisted.cronList.mockResolvedValue({ result: [], logs: [] }); + hoisted.cronAdd.mockResolvedValue({ + result: { id: 'cron-1', name: 'dev-workflow-user-repo1' }, + logs: [], + }); + hoisted.cronRemove.mockResolvedValue({ result: { job_id: 'cron-1', removed: true }, logs: [] }); + hoisted.cronRuns.mockResolvedValue({ result: { runs: [] }, logs: [] }); }); test('renders header immediately and populates repo dropdown on successful fetch', async () => { @@ -183,7 +207,7 @@ describe('DevWorkflowPanel', () => { }); }); - test('save button stores config in localStorage', async () => { + test('save button creates a cron job via openhumanCronAdd', async () => { // Call sequence: LIST_REPOS → GET_A_REPO (non-fork) → LIST_BRANCHES hoisted.composioExecute .mockResolvedValueOnce(reposResponse) @@ -209,50 +233,57 @@ describe('DevWorkflowPanel', () => { // Click save const saveBtn = screen.getByRole('button', { - name: /settings\.devWorkflow\.(save|update)Configuration/, + name: /settings\.devWorkflow\.saveConfiguration/, }); fireEvent.click(saveBtn); - // Verify localStorage was written - const raw = localStorage.getItem('openhuman:dev-workflow-config'); - expect(raw).not.toBeNull(); - const stored = JSON.parse(raw!); - expect(stored.repoFullName).toBe('user/repo1'); - expect(stored.repoOwner).toBe('user'); - expect(stored.repoName).toBe('repo1'); - expect(stored.targetBranch).toBe('main'); - expect(typeof stored.schedule).toBe('string'); - - // Saved status indicator - expect(screen.getByText('settings.devWorkflow.saved')).toBeInTheDocument(); + // Verify cron_add was called + await waitFor(() => { + expect(hoisted.cronAdd).toHaveBeenCalledTimes(1); + }); + const addCall = hoisted.cronAdd.mock.calls[0][0]; + expect(addCall.name).toBe('dev-workflow-user-repo1'); + expect(addCall.schedule).toEqual({ kind: 'cron', expr: '*/30 * * * *' }); + expect(addCall.job_type).toBe('agent'); + expect(addCall.prompt).toContain('dev-workflow'); + expect(addCall.prompt).toContain('user/repo1'); }); - test('remove button clears localStorage config', async () => { - // Pre-populate localStorage so savedConfig is non-null on mount - const existingConfig = { - repoFullName: 'user/repo1', - repoOwner: 'user', - repoName: 'repo1', - forkInfo: null, - targetBranch: 'main', - schedule: '*/30 * * * *', + test('remove button deletes cron job via openhumanCronRemove', async () => { + // Pre-populate cron list so existingJob is set on mount + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', }; - localStorage.setItem('openhuman:dev-workflow-config', JSON.stringify(existingConfig)); + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); const Panel = await importPanel(); renderWithProviders(); - // Active config summary is shown immediately (initialised from localStorage) - expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + // Active config card shows at top regardless of repo loading + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + }); - // Remove button is visible because savedConfig is set + // Remove button is in the active config card const removeBtn = screen.getByRole('button', { name: 'settings.devWorkflow.remove' }); fireEvent.click(removeBtn); - // localStorage should be cleared - expect(localStorage.getItem('openhuman:dev-workflow-config')).toBeNull(); - // Active config summary gone - expect(screen.queryByText('settings.devWorkflow.activeConfiguration')).toBeNull(); + // Verify cron_remove was called + await waitFor(() => { + expect(hoisted.cronRemove).toHaveBeenCalledWith('cron-1'); + }); }); test('shows branches fetched from upstream when fork is detected', async () => { @@ -297,4 +328,615 @@ describe('DevWorkflowPanel', () => { expect(screen.getByText('network error')).toBeInTheDocument(); }); }); + + test('toggle button calls openhumanCronUpdate with enabled flag', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronUpdate.mockResolvedValue({ data: { ...existingCronJob, enabled: false } }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Wait for active config with toggle + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.enabled')).toBeInTheDocument(); + }); + + // Click the toggle button (the switch element) + const toggleBtn = screen.getByText('settings.devWorkflow.enabled').previousElementSibling; + if (toggleBtn) fireEvent.click(toggleBtn); + + await waitFor(() => { + expect(hoisted.cronUpdate).toHaveBeenCalledWith('cron-1', { enabled: false }); + }); + }); + + test('run now button calls openhumanCronRun', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRun.mockResolvedValue({ + data: { job_id: 'cron-1', status: 'ok', duration_ms: 100, output: 'done' }, + }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('settings.devWorkflow.runNow')); + + await waitFor(() => { + expect(hoisted.cronRun).toHaveBeenCalledWith('cron-1'); + }); + }); + + test('shows run history when cron runs are available', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + last_run: '2026-01-01T00:30:00Z', + last_status: 'ok', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRuns.mockResolvedValue({ + result: { + runs: [ + { + id: 1, + job_id: 'cron-1', + started_at: '2026-01-01T00:30:00Z', + finished_at: '2026-01-01T00:31:00Z', + status: 'ok', + duration_ms: 60000, + }, + ], + }, + logs: [], + }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Wait for the recent runs toggle to appear + await waitFor(() => { + expect(screen.getByText(/settings\.devWorkflow\.recentRuns/)).toBeInTheDocument(); + }); + + // Expand history + fireEvent.click(screen.getByText(/settings\.devWorkflow\.recentRuns/)); + + // Run entry should be visible + await waitFor(() => { + expect(screen.getByText('60.0s')).toBeInTheDocument(); + }); + }); + + test('shows last run status badge when job has last_status', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + last_run: '2026-01-01T00:30:00Z', + last_status: 'error', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('error')).toBeInTheDocument(); + }); + }); + + test('handles save error gracefully', async () => { + hoisted.composioExecute + .mockResolvedValueOnce(reposResponse) + .mockResolvedValueOnce(repoMetaNonFork) + .mockResolvedValueOnce(branchesResponse); + hoisted.cronAdd.mockRejectedValue(new Error('save failed')); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + + const repoSelect = screen.getAllByRole('combobox')[0]; + fireEvent.change(repoSelect, { target: { value: 'user/repo1' } }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'main' })).toBeInTheDocument(); + }); + + const saveBtn = screen.getByRole('button', { + name: /settings\.devWorkflow\.saveConfiguration/, + }); + fireEvent.click(saveBtn); + + // Error status should appear + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.cronSaveError')).toBeInTheDocument(); + }); + }); + + test('loadExistingJob handles cronList error gracefully', async () => { + hoisted.cronList.mockRejectedValue(new Error('cron list failed')); + + const Panel = await importPanel(); + renderWithProviders(); + + // Panel should still render despite cronList failure + expect(screen.getByTestId('settings-header')).toBeInTheDocument(); + + // Repos should still load + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + }); + + // ── Run Now simulation tests ────────────────────────────────────────── + + test('run now shows running indicator then refreshes on completion', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + // cronRun resolves after a tick (simulates async execution) + let resolveRun: (v: unknown) => void = () => {}; + hoisted.cronRun.mockImplementation( + () => + new Promise(resolve => { + resolveRun = resolve; + }) + ); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + // Click Run Now + fireEvent.click(screen.getByText('settings.devWorkflow.runNow')); + + // Running indicator should appear + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.running')).toBeInTheDocument(); + expect(screen.getByText('settings.devWorkflow.runningStatus')).toBeInTheDocument(); + }); + + // Button should be disabled while running + const btn = screen.getByText('settings.devWorkflow.running'); + expect(btn.closest('button')).toHaveAttribute('disabled'); + + // Simulate run completion + resolveRun({ + result: { job_id: 'cron-1', status: 'ok', duration_ms: 5000, output: 'Fixed issue #42' }, + }); + + // After completion, button should return to normal + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + // cronRun was called + expect(hoisted.cronRun).toHaveBeenCalledWith('cron-1'); + // loadExistingJob should have been called to refresh + expect(hoisted.cronList).toHaveBeenCalledTimes(2); // initial + refresh + }); + + test('run now handles error and resets running state', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRun.mockRejectedValue(new Error('agent crashed')); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText('settings.devWorkflow.runNow')); + + // After error, button should return to normal (not stuck in running) + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.runNow')).toBeInTheDocument(); + }); + }); + + test('shows last_output in active config when present', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + last_run: '2026-01-01T00:30:00Z', + last_status: 'ok', + last_output: 'No open issues assigned. Exiting cleanly.', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.lastOutput')).toBeInTheDocument(); + }); + expect(screen.getByText('No open issues assigned. Exiting cleanly.')).toBeInTheDocument(); + }); + + test('expandable run history shows output when clicked', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRuns.mockResolvedValue({ + result: { + runs: [ + { + id: 1, + job_id: 'cron-1', + started_at: '2026-01-01T00:30:00Z', + finished_at: '2026-01-01T00:31:00Z', + status: 'ok', + duration_ms: 60000, + output: 'Picked issue #42. Opened PR #99.', + }, + ], + }, + logs: [], + }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Expand history + await waitFor(() => { + expect(screen.getByText(/settings\.devWorkflow\.recentRuns/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/settings\.devWorkflow\.recentRuns/)); + + // Click on the run entry to expand output + await waitFor(() => { + expect(screen.getByText('60.0s')).toBeInTheDocument(); + }); + + // Find the run row button and click it + const runRow = screen.getByText('60.0s').closest('button'); + if (runRow) fireEvent.click(runRow); + + // Output should be visible + await waitFor(() => { + expect(screen.getByText('Picked issue #42. Opened PR #99.')).toBeInTheDocument(); + }); + }); + + test('expandable run history shows no-output message when run has no output', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + hoisted.cronRuns.mockResolvedValue({ + result: { + runs: [ + { + id: 1, + job_id: 'cron-1', + started_at: '2026-01-01T00:30:00Z', + finished_at: '2026-01-01T00:31:00Z', + status: 'error', + duration_ms: 1000, + output: null, + }, + ], + }, + logs: [], + }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText(/settings\.devWorkflow\.recentRuns/)).toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(/settings\.devWorkflow\.recentRuns/)); + + await waitFor(() => { + expect(screen.getByText('1.0s')).toBeInTheDocument(); + }); + + const runRow = screen.getByText('1.0s').closest('button'); + if (runRow) fireEvent.click(runRow); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.noOutput')).toBeInTheDocument(); + }); + }); + + test('setup form is hidden when existing job is present', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Active config shows + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + }); + + // Repo selector should NOT be visible + expect(screen.queryByText('settings.devWorkflow.githubRepository')).not.toBeInTheDocument(); + expect(screen.queryByText('settings.devWorkflow.selectRepository')).not.toBeInTheDocument(); + }); + + test('setup form shows when no existing job', async () => { + hoisted.cronList.mockResolvedValue({ result: [], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Repo selector should be visible + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + + // No active config card + expect(screen.queryByText('settings.devWorkflow.activeConfiguration')).not.toBeInTheDocument(); + }); + + test('schedule preset label shows in active config', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + // Schedule preset matches — should show the label key + expect(screen.getByText('settings.devWorkflow.schedule.every30min')).toBeInTheDocument(); + }); + }); + + test('paused state shows when job is disabled', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: false, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + hoisted.cronList.mockResolvedValue({ result: [existingCronJob], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.paused')).toBeInTheDocument(); + }); + }); + + test('save with fork detected includes upstream in prompt', async () => { + hoisted.composioExecute + .mockResolvedValueOnce(reposResponse) + .mockResolvedValueOnce(repoMetaFork) + .mockResolvedValueOnce(branchesResponse); + + const Panel = await importPanel(); + renderWithProviders(); + + await waitFor(() => { + expect(screen.getByRole('option', { name: /user\/repo1/ })).toBeInTheDocument(); + }); + + const repoSelect = screen.getAllByRole('combobox')[0]; + fireEvent.change(repoSelect, { target: { value: 'user/repo1' } }); + + await waitFor(() => { + expect(screen.getByRole('option', { name: 'main' })).toBeInTheDocument(); + }); + + const saveBtn = screen.getByRole('button', { + name: /settings\.devWorkflow\.saveConfiguration/, + }); + fireEvent.click(saveBtn); + + await waitFor(() => { + expect(hoisted.cronAdd).toHaveBeenCalledTimes(1); + }); + const addCall = hoisted.cronAdd.mock.calls[0][0]; + // Fork detected — prompt should reference upstream repo + expect(addCall.prompt).toContain('upstream/repo'); + expect(addCall.prompt).toContain('Self-assign'); + expect(addCall.prompt).toContain('unassigned'); + }); + + test('update existing job calls cronUpdate instead of cronAdd', async () => { + const existingCronJob = { + id: 'cron-1', + name: 'dev-workflow-user-repo1', + expression: '*/30 * * * *', + schedule: { kind: 'cron', expr: '*/30 * * * *' }, + command: '', + prompt: 'Run the dev-workflow skill.', + job_type: 'agent', + session_target: 'isolated', + enabled: true, + delivery: { mode: 'proactive', best_effort: true }, + delete_after_run: false, + created_at: '2026-01-01T00:00:00Z', + next_run: '2026-01-01T01:00:00Z', + }; + // First call returns existing job, second call (after remove+re-render) returns empty + hoisted.cronList + .mockResolvedValueOnce({ result: [existingCronJob], logs: [] }) + .mockResolvedValue({ result: [], logs: [] }); + + const Panel = await importPanel(); + renderWithProviders(); + + // Wait for active config to show + await waitFor(() => { + expect(screen.getByText('settings.devWorkflow.activeConfiguration')).toBeInTheDocument(); + }); + + // Remove the existing job so setup form appears + const removeBtn = screen.getByRole('button', { name: 'settings.devWorkflow.remove' }); + fireEvent.click(removeBtn); + + await waitFor(() => { + expect(hoisted.cronRemove).toHaveBeenCalledWith('cron-1'); + }); + }); }); diff --git a/app/src/components/settings/panels/__tests__/SkillsRunnerPanel.test.tsx b/app/src/components/settings/panels/__tests__/SkillsRunnerPanel.test.tsx new file mode 100644 index 0000000000..ed875f1d31 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/SkillsRunnerPanel.test.tsx @@ -0,0 +1,62 @@ +// Tests for Settings → Developer Options → Skills Runner panel. +// Verifies that the thin wrapper panel renders the header/back button and +// delegates the actual runner UX to SkillsRunnerBody. +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// Mock the navigation hook — panel only needs navigateBack + breadcrumbs. +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ + navigateBack: vi.fn(), + breadcrumbs: [], + }), +})); + +// Stub SettingsHeader so we can assert on its props without pulling in its deps. +vi.mock('../../components/SettingsHeader', () => ({ + default: ({ + title, + showBackButton, + }: { + title: string; + showBackButton: boolean; + onBack: () => void; + breadcrumbs: unknown[]; + }) => ( +
+ {title} + {showBackButton && } +
+ ), +})); + +// Stub SkillsRunnerBody — it's complex and tested separately. +// Path is relative to the test file in panels/__tests__/. +vi.mock('../../../skills/SkillsRunnerBody', () => ({ + default: () =>
, +})); + +describe('SkillsRunnerPanel', () => { + it('renders the settings header with the Skills Runner title', async () => { + const { default: SkillsRunnerPanel } = await import('../SkillsRunnerPanel'); + render(); + + expect(screen.getByTestId('settings-header')).toBeInTheDocument(); + // The i18n system resolves settings.developerMenu.skillsRunner.title to "Skills Runner" + expect(screen.getByTestId('header-title')).toHaveTextContent('Skills Runner'); + }); + + it('renders with a back button', async () => { + const { default: SkillsRunnerPanel } = await import('../SkillsRunnerPanel'); + render(); + + expect(screen.getByRole('button', { name: 'back' })).toBeInTheDocument(); + }); + + it('renders the SkillsRunnerBody', async () => { + const { default: SkillsRunnerPanel } = await import('../SkillsRunnerPanel'); + render(); + + expect(screen.getByTestId('skills-runner-body')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/skills/CreateSkillForm.tsx b/app/src/components/skills/CreateSkillForm.tsx new file mode 100644 index 0000000000..24e57502e5 --- /dev/null +++ b/app/src/components/skills/CreateSkillForm.tsx @@ -0,0 +1,421 @@ +/** + * CreateSkillForm + * ---------------- + * + * Body of the "create a new SKILL.md" flow, shared between + * `CreateSkillModal` (modal chrome) and the `/skills/new` page wrapper. + * + * Owns: + * - All form fields (name, description, scope, license, author, + * tags, allowed-tools). + * - Slug preview + validation (name and description required). + * - Submit handler that calls `skillsApi.createSkill` and surfaces + * the result via `onCreated(skill)` / error string via inline + * `
`. + * + * Does NOT own: + * - The submit/cancel buttons (the wrapper provides them so the + * modal can use a footer bar and the page can render a top-right + * primary action). + * - Modal-specific concerns (focus capture, Escape-to-close, + * backdrop click). Those stay in `CreateSkillModal`. + * + * The wrapper drives submission by either calling the imperative + * handle exposed via a ref (`` → + * `ref.current.submit()`) OR by reading `formValid` + `submitting` + * from the props the form raises and wiring its own submit button to + * the underlying `
` via the standard `form="..."` attribute. + * Both modal and page use the latter, so the form mounts a real + * `` and they bind `