Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

903 lines
31 KiB
TypeScript

import {
MakeLogicType,
actions,
afterMount,
defaults,
kea,
key,
listeners,
path,
props,
reducers,
selectors,
} from 'kea'
import { forms } from 'kea-forms'
import type { DeepPartial, DeepPartialMap, FieldName, ValidationErrorType } from 'kea-forms'
import { loaders } from 'kea-loaders'
import { combineUrl, router, urlToAction } from 'kea-router'
import { ApiConfig } from '~/lib/api'
import { toast } from '~/lib/elements/Toast/Toast'
import { urls } from '~/scenes/urls'
import { Breadcrumb } from '~/types'
import {
llmSkillsCreate,
llmSkillsNameArchiveCreate,
llmSkillsNameFilesRetrieve,
llmSkillsNamePartialUpdate,
llmSkillsResolveNameRetrieve,
} from 'products/skills/frontend/generated/api'
import type {
LLMSkillApi,
LLMSkillFileApi,
LLMSkillFileInputApi,
LLMSkillListApi,
LLMSkillVersionSummaryApi,
} from 'products/skills/frontend/generated/api.schemas'
import { exportAndDownloadSkill, llmSkillsLogic, LLM_SKILLS_FORCE_RELOAD_PARAM } from './llmSkillsLogic'
import {
SKILL_DESCRIPTION_MAX_LENGTH,
SKILL_FILE_MAX_BYTES,
SKILL_FILE_MAX_COUNT,
validateSkillName,
} from './skillConstants'
import type { SkillFileUpload } from './skillFileUpload'
export enum SkillMode {
View = 'view',
Edit = 'edit',
}
export interface SkillLogicProps {
skillName: string | 'new'
mode?: SkillMode
selectedVersion?: number | null
}
export interface SkillFormFileValues {
path: string
content: string
content_type: string
}
export interface SkillFormValues {
name: string
description: string
body: string
license: string
compatibility: string
files: SkillFormFileValues[]
}
export interface ResolvedLLMSkill extends LLMSkillApi {
versions: LLMSkillVersionSummaryApi[]
has_more: boolean
}
export function isSkill(skill: LLMSkillApi | ResolvedLLMSkill | SkillFormValues | null): skill is ResolvedLLMSkill {
return skill !== null && 'id' in skill
}
// Values must stay within COMMON_CONTENT_TYPES in LLMSkillScene, so the content-type
// select can display whatever gets inferred here.
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
md: 'text/markdown',
markdown: 'text/markdown',
py: 'text/x-python',
sh: 'text/x-shellscript',
bash: 'text/x-shellscript',
zsh: 'text/x-shellscript',
json: 'application/json',
yaml: 'text/yaml',
yml: 'text/yaml',
js: 'text/javascript',
mjs: 'text/javascript',
cjs: 'text/javascript',
jsx: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
}
function inferFileContentType(fileName: string): string {
const extension = fileName.toLowerCase().split('.').pop() ?? ''
return CONTENT_TYPE_BY_EXTENSION[extension] ?? 'text/plain'
}
const DEFAULT_SKILL_FORM_VALUES: SkillFormValues = {
name: '',
description: '',
body: '',
license: '',
compatibility: '',
files: [],
}
const SKILL_VERSIONS_LIMIT = 50
async function fetchResolvedSkill(
skillName: string,
params?: { version?: number; offset?: number; before_version?: number; limit?: number }
): Promise<ResolvedLLMSkill> {
const response = await llmSkillsResolveNameRetrieve(String(ApiConfig.getCurrentTeamId()), skillName, {
...params,
limit: params?.limit ?? SKILL_VERSIONS_LIMIT,
})
return {
...response.skill,
versions: response.versions,
has_more: response.has_more,
}
}
function getSkillFormDefaults(skill: LLMSkillApi, fileContents?: LLMSkillFileApi[]): SkillFormValues {
const files: SkillFormFileValues[] = fileContents
? fileContents.map((f) => ({ path: f.path, content: f.content, content_type: f.content_type || 'text/plain' }))
: skill.files.map((f) => ({ path: f.path, content: '', content_type: f.content_type || 'text/plain' }))
return {
name: skill.name,
description: skill.description,
body: skill.body,
license: skill.license || '',
compatibility: skill.compatibility || '',
files,
}
}
async function fetchAllFileContents(skillName: string, skill: LLMSkillApi): Promise<LLMSkillFileApi[]> {
if (!skill.files || skill.files.length === 0) {
return []
}
const teamId = String(ApiConfig.getCurrentTeamId())
const results = await Promise.all(
skill.files.map((f) =>
llmSkillsNameFilesRetrieve(teamId, skillName, f.path, {
version: skill.is_latest ? undefined : skill.version,
})
)
)
return results
}
function findExistingSkill(skillName: string): LLMSkillListApi | undefined {
return llmSkillsLogic.findMounted()?.values.skills.results.find((s) => s.name === skillName)
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface llmSkillLogicValues {
breadcrumbs: Breadcrumb[]
canCompareVersions: boolean
canLoadMoreVersions: boolean
compareSkill: LLMSkillApi | null
compareSkillLoading: boolean
compareVersion: number | null
compareVersionOptions: Array<{
label: string
value: number
}>
downloadingZip: boolean
fileContentsLoading: boolean
isDiffVisible: boolean
isEditMode: boolean
isHistoricalVersion: boolean
isNewSkill: boolean
isOutlineExpanded: boolean
isSkillFormSubmitting: boolean
isSkillFormValid: boolean
isSkillMissing: boolean
isViewMode: boolean
mode: SkillMode
shouldDisplaySkeleton: boolean
showSkillFormErrors: boolean
skill: ResolvedLLMSkill | SkillFormValues | null
skillFetched: boolean
skillForm: SkillFormValues
skillFormAllErrors: Record<string, any>
skillFormChanged: boolean
skillFormErrors: DeepPartialMap<SkillFormValues, ValidationErrorType>
skillFormHasErrors: boolean
skillFormManualErrors: Record<string, any>
skillFormTouched: boolean
skillFormTouches: Record<string, boolean>
skillFormValidationErrors: DeepPartialMap<SkillFormValues, ValidationErrorType>
skillLoading: boolean
versions: LLMSkillVersionSummaryApi[]
versionsLoading: boolean
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface llmSkillLogicActions {
addUploadedFiles: (files: SkillFileUpload[]) => {
files: SkillFileUpload[]
}
deleteSkill: () => {
value: true
}
downloadSkill: () => {
value: true
}
loadCompareSkill: (version: number) => number
loadCompareSkillFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadCompareSkillSuccess: (
compareSkill: LLMSkillApi,
payload?: number
) => {
compareSkill: LLMSkillApi
payload?: number
}
loadFileContents: () => {
value: true
}
loadMoreVersions: () => {
value: true
}
loadSkill: () => any
loadSkillFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadSkillSuccess: (
skill: ResolvedLLMSkill,
payload?: any
) => {
skill: ResolvedLLMSkill
payload?: any
}
resetSkillForm: (values?: SkillFormValues) => {
values?: SkillFormValues
}
setCompareVersion: (compareVersion: number | null) => {
compareVersion: number | null
}
setDownloadingZip: (downloadingZip: boolean) => {
downloadingZip: boolean
}
setFileContentsLoading: (loading: boolean) => {
loading: boolean
}
setMode: (mode: SkillMode) => {
mode: SkillMode
}
setSkill: (skill: ResolvedLLMSkill | SkillFormValues) => {
skill: ResolvedLLMSkill | SkillFormValues
}
setSkillFormManualErrors: (errors: Record<string, any>) => {
errors: Record<string, any>
}
setSkillFormValue: (
key: FieldName,
value: any
) => {
name: FieldName
value: any
}
setSkillFormValues: (values: DeepPartial<SkillFormValues>) => {
values: DeepPartial<SkillFormValues>
}
setVersionsLoading: (versionsLoading: boolean) => {
versionsLoading: boolean
}
submitSkillForm: () => {
value: boolean
}
submitSkillFormFailure: (
error: Error,
errors: Record<string, any>
) => {
error: Error
errors: Record<string, any>
}
submitSkillFormRequest: (skillForm: SkillFormValues) => {
skillForm: SkillFormValues
}
submitSkillFormSuccess: (skillForm: SkillFormValues) => {
skillForm: SkillFormValues
}
toggleOutlineExpanded: () => {
value: true
}
touchSkillFormField: (key: string) => {
key: string
}
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface llmSkillLogicMeta {
key: string
__keaTypeGenInternalSelectorTypes: {
isNewSkill: (arg: any) => boolean
isSkillMissing: (
skill: ResolvedLLMSkill | SkillFormValues | null,
skillLoading: boolean,
skillFetched: boolean
) => boolean
shouldDisplaySkeleton: (
skill: ResolvedLLMSkill | SkillFormValues | null,
skillLoading: boolean,
skillFetched: boolean,
isNewSkill: boolean
) => boolean
isHistoricalVersion: (skill: ResolvedLLMSkill | SkillFormValues | null) => boolean
breadcrumbs: (
skill: ResolvedLLMSkill | SkillFormValues | null,
searchParams: Record<string, any>
) => Breadcrumb[]
isViewMode: (mode: SkillMode, arg: any) => boolean
isEditMode: (mode: SkillMode, arg: any) => boolean
versions: (skill: ResolvedLLMSkill | SkillFormValues | null) => LLMSkillVersionSummaryApi[]
canLoadMoreVersions: (skill: ResolvedLLMSkill | SkillFormValues | null) => boolean
isDiffVisible: (compareVersion: number | null) => boolean
canCompareVersions: (skill: ResolvedLLMSkill | SkillFormValues | null) => boolean
compareVersionOptions: (
skill: ResolvedLLMSkill | SkillFormValues | null,
versions: LLMSkillVersionSummaryApi[]
) => Array<{
label: string
value: number
}>
}
}
export type llmSkillLogicType = MakeLogicType<
llmSkillLogicValues,
llmSkillLogicActions,
SkillLogicProps,
llmSkillLogicMeta
>
export const llmSkillLogic = kea<llmSkillLogicType>([
path(['scenes', 'skills', 'llmSkillLogic']),
props({ skillName: 'new' } as SkillLogicProps),
key(({ skillName, selectedVersion }) => `skill-${skillName}:${selectedVersion ?? 'latest'}`),
actions({
addUploadedFiles: (files: SkillFileUpload[]) => ({ files }),
setSkill: (skill: ResolvedLLMSkill | SkillFormValues) => ({ skill }),
deleteSkill: true,
loadMoreVersions: true,
setVersionsLoading: (versionsLoading: boolean) => ({ versionsLoading }),
setMode: (mode: SkillMode) => ({ mode }),
loadFileContents: true,
setFileContentsLoading: (loading: boolean) => ({ loading }),
toggleOutlineExpanded: true,
setCompareVersion: (compareVersion: number | null) => ({ compareVersion }),
downloadSkill: true,
setDownloadingZip: (downloadingZip: boolean) => ({ downloadingZip }),
}),
reducers(({ props }) => ({
skill: [
null as ResolvedLLMSkill | SkillFormValues | null,
{
loadSkillSuccess: (_, { skill }) => skill,
setSkill: (_, { skill }) => skill,
},
],
skillFetched: [
props.skillName === 'new',
{
loadSkillSuccess: () => true,
loadSkillFailure: () => true,
},
],
versionsLoading: [
false,
{
loadMoreVersions: () => true,
setVersionsLoading: (_, { versionsLoading }) => versionsLoading,
loadSkillSuccess: () => false,
},
],
fileContentsLoading: [
false,
{
loadFileContents: () => true,
setFileContentsLoading: (_, { loading }) => loading,
},
],
isOutlineExpanded: [
false,
{
toggleOutlineExpanded: (state) => !state,
},
],
mode: [
props.mode ?? SkillMode.View,
{
setMode: (_, { mode }) => mode,
},
],
compareVersion: [
null as number | null,
{
setCompareVersion: (_, { compareVersion }) => compareVersion,
loadSkillSuccess: () => null,
},
],
compareSkill: [
null as LLMSkillApi | null,
{
setCompareVersion: () => null,
loadSkillSuccess: () => null,
},
],
downloadingZip: [
false,
{
setDownloadingZip: (_, { downloadingZip }) => downloadingZip,
},
],
})),
loaders(({ props }) => ({
skill: {
__default: null as ResolvedLLMSkill | SkillFormValues | null,
loadSkill: async () =>
fetchResolvedSkill(props.skillName, {
version: props.selectedVersion ?? undefined,
}),
},
compareSkill: {
__default: null as LLMSkillApi | null,
loadCompareSkill: async (version: number) => {
const resolved = await fetchResolvedSkill(props.skillName, { version, limit: 1 })
return resolved as LLMSkillApi
},
},
})),
forms(({ actions, props, values }) => ({
skillForm: {
defaults: DEFAULT_SKILL_FORM_VALUES,
options: { showErrorsOnTouch: true },
errors: ({ name, description, body }) => ({
name: validateSkillName(name),
description: !description?.trim()
? 'Description is required'
: description.length > SKILL_DESCRIPTION_MAX_LENGTH
? `Description must be ${SKILL_DESCRIPTION_MAX_LENGTH} characters or fewer`
: undefined,
body: !body?.trim() ? 'Skill body is required' : undefined,
}),
submit: async (formValues) => {
const isNew = props.skillName === 'new'
try {
let savedSkill: LLMSkillApi
const filesToSend: LLMSkillFileInputApi[] | undefined =
formValues.files.length > 0
? formValues.files.map((f) => ({
path: f.path,
content: f.content,
content_type: f.content_type || undefined,
}))
: undefined
if (isNew) {
const createResponse = await llmSkillsCreate(String(ApiConfig.getCurrentTeamId()), {
name: formValues.name,
description: formValues.description,
body: formValues.body,
license: formValues.license || undefined,
compatibility: formValues.compatibility || undefined,
files: filesToSend,
})
savedSkill = { ...createResponse, files: [] }
llmSkillsLogic.findMounted()?.actions.loadSkills(false)
toast.success('Skill created successfully')
router.actions.replace(urls.skill(savedSkill.name))
} else {
const currentSkill = values.skill
if (!isSkill(currentSkill)) {
throw new Error('Cannot publish skill version: skill data not loaded')
}
savedSkill = await llmSkillsNamePartialUpdate(
String(ApiConfig.getCurrentTeamId()),
props.skillName,
{
body: formValues.body,
description: formValues.description,
license: formValues.license || undefined,
compatibility: formValues.compatibility || undefined,
allowed_tools: currentSkill.allowed_tools,
metadata: currentSkill.metadata,
base_version: currentSkill.latest_version,
files: filesToSend,
}
)
llmSkillsLogic.findMounted()?.actions.loadSkills(false)
toast.success('Skill version published successfully')
actions.setSkill({
...savedSkill,
versions: [
{
id: savedSkill.id,
version: savedSkill.version,
created_by: savedSkill.created_by,
created_at: savedSkill.created_at,
is_latest: true,
},
...currentSkill.versions
.filter((v) => v.id !== savedSkill.id)
.map((v) => ({ ...v, is_latest: false })),
],
has_more: currentSkill.has_more,
})
actions.setSkillFormValues(getSkillFormDefaults(savedSkill))
router.actions.replace(urls.skill(props.skillName))
try {
const latest = await fetchResolvedSkill(props.skillName)
actions.setSkill(latest)
actions.setSkillFormValues(getSkillFormDefaults(latest))
} catch (err) {
console.error('Failed to refresh skill after publish', err)
}
}
actions.setMode(SkillMode.View)
if (isNew) {
actions.setSkill({
...savedSkill,
versions: [],
has_more: false,
})
actions.setSkillFormValues(getSkillFormDefaults(savedSkill))
}
} catch (error: unknown) {
const detail =
error !== null && typeof error === 'object' && 'detail' in error
? (error as { detail: string }).detail
: undefined
toast.error(detail || 'Failed to save skill')
throw error
}
},
},
})),
selectors({
isNewSkill: [() => [(_, props) => props], (props) => props.skillName === 'new'],
isSkillMissing: [
(s) => [s.skill, s.skillLoading, s.skillFetched],
(skill: ResolvedLLMSkill | SkillFormValues | null, skillLoading: boolean, skillFetched: boolean) =>
skillFetched && !skillLoading && skill === null,
],
shouldDisplaySkeleton: [
(s) => [s.skill, s.skillLoading, s.skillFetched, s.isNewSkill],
(
skill: ResolvedLLMSkill | SkillFormValues | null,
skillLoading: boolean,
skillFetched: boolean,
isNewSkill: boolean
) => !isNewSkill && (!skillFetched || (skillLoading && skill === null)),
],
isHistoricalVersion: [
(s) => [s.skill],
(skill: ResolvedLLMSkill | SkillFormValues | null) => (isSkill(skill) ? !skill.is_latest : false),
],
breadcrumbs: [
(s) => [s.skill, router.selectors.searchParams],
(skill: LLMSkillApi | SkillFormValues | null, searchParams: Record<string, any>): Breadcrumb[] => [
{
name: 'Skills',
path: combineUrl(urls.skills(), searchParams).url,
key: 'Skills',
},
{
name:
skill && 'name' in skill
? isSkill(skill)
? `${skill.name} v${skill.version}`
: skill.name || 'New skill'
: 'New skill',
key: 'Skill',
},
],
],
isViewMode: [
(s) => [s.mode, (_, props) => props],
(mode: SkillMode, props) => props.skillName !== 'new' && mode === SkillMode.View,
],
isEditMode: [
(s) => [s.mode, (_, props) => props],
(mode: SkillMode, props) => props.skillName === 'new' || mode === SkillMode.Edit,
],
versions: [
(s) => [s.skill],
(skill: ResolvedLLMSkill | SkillFormValues | null): LLMSkillVersionSummaryApi[] =>
isSkill(skill) ? skill.versions : [],
],
canLoadMoreVersions: [
(s) => [s.skill],
(skill: ResolvedLLMSkill | SkillFormValues | null) => (isSkill(skill) ? skill.has_more : false),
],
isDiffVisible: [(s) => [s.compareVersion], (compareVersion: number | null): boolean => compareVersion !== null],
canCompareVersions: [
(s) => [s.skill],
(skill: ResolvedLLMSkill | SkillFormValues | null): boolean => isSkill(skill) && skill.version_count > 1,
],
compareVersionOptions: [
(s) => [s.skill, s.versions],
(
skill: ResolvedLLMSkill | SkillFormValues | null,
versions: LLMSkillVersionSummaryApi[]
): Array<{ value: number; label: string }> => {
if (!isSkill(skill)) {
return []
}
return versions
.filter((v) => v.version !== skill.version)
.map((v) => ({
value: v.version,
label: `v${v.version}${v.is_latest ? ' (latest)' : ''}`,
}))
},
],
}),
listeners(({ actions, props, values }) => ({
addUploadedFiles: async ({ files }) => {
const uploaded: SkillFormFileValues[] = []
for (const { path, file } of files) {
// Case-insensitive to match the backend's reserved-path validation
if (path.toLowerCase() === 'skill.md') {
toast.info(
"SKILL.md wasn't added as a bundled file. Its body belongs in the skill body field."
)
continue
}
if (file.size > SKILL_FILE_MAX_BYTES) {
toast.error(`Couldn't add ${path}: files must be 1 MB or smaller`)
continue
}
// Strict decoding distinguishes real binary (invalid UTF-8, or NUL bytes) from
// text that legitimately contains a U+FFFD replacement character.
let content: string
try {
content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer())
} catch {
toast.error(`Couldn't add ${path}: only text files are supported`)
continue
}
if (content.includes('\u0000')) {
toast.error(`Couldn't add ${path}: only text files are supported`)
continue
}
uploaded.push({ path, content, content_type: inferFileContentType(path) })
}
if (uploaded.length === 0) {
return
}
const uploadedByPath = new Map(uploaded.map((f) => [f.path, f]))
const merged = values.skillForm.files.map((existing) => {
const replacement = uploadedByPath.get(existing.path)
if (replacement) {
uploadedByPath.delete(existing.path)
return replacement
}
return existing
})
const combined = [...merged, ...uploadedByPath.values()]
if (combined.length > SKILL_FILE_MAX_COUNT) {
toast.error(
`Some files weren't added: a skill can have at most ${SKILL_FILE_MAX_COUNT} bundled files`
)
}
actions.setSkillFormValues({ files: combined.slice(0, SKILL_FILE_MAX_COUNT) })
},
downloadSkill: async () => {
if (props.skillName === 'new') {
return
}
actions.setDownloadingZip(true)
try {
await exportAndDownloadSkill(props.skillName)
} catch (e) {
console.error('Failed to export skill', e)
const detail =
e !== null && typeof e === 'object' && 'detail' in e ? (e as { detail?: string }).detail : undefined
toast.error(detail || (e instanceof Error ? e.message : 'Failed to export skill'))
} finally {
actions.setDownloadingZip(false)
}
},
deleteSkill: async () => {
if (props.skillName !== 'new' && values.skill && isSkill(values.skill)) {
try {
await llmSkillsNameArchiveCreate(String(ApiConfig.getCurrentTeamId()), values.skill.name)
toast.info(`${values.skill.name || 'Skill'} has been archived.`)
llmSkillsLogic.findMounted()?.actions.loadSkills(false)
router.actions.replace(urls.skills(), {
...router.values.searchParams,
[LLM_SKILLS_FORCE_RELOAD_PARAM]: String(Date.now()),
})
} catch (e) {
console.error('Failed to archive skill', e)
toast.error('Failed to archive skill')
}
}
},
loadMoreVersions: async () => {
if (props.skillName === 'new' || !isSkill(values.skill)) {
actions.setVersionsLoading(false)
return
}
try {
const oldestLoadedVersion = values.skill.versions[values.skill.versions.length - 1]?.version
if (!oldestLoadedVersion) {
actions.setVersionsLoading(false)
return
}
const response = await fetchResolvedSkill(props.skillName, {
version: values.skill.version,
before_version: oldestLoadedVersion,
})
const existingVersionIds = new Set(values.skill.versions.map((v) => v.id))
const appendedVersions = response.versions.filter((v) => !existingVersionIds.has(v.id))
actions.setSkill({
...response,
versions: [...values.skill.versions, ...appendedVersions],
has_more: response.has_more,
})
} catch (e) {
console.error('Failed to load more versions', e)
toast.error('Failed to load more versions')
} finally {
actions.setVersionsLoading(false)
}
},
loadFileContents: async () => {
const skill = values.skill
if (!isSkill(skill) || !skill.files || skill.files.length === 0) {
actions.setFileContentsLoading(false)
return
}
try {
const fileContents = await fetchAllFileContents(props.skillName, skill)
actions.setSkillFormValues({
files: fileContents.map((f) => ({
path: f.path,
content: f.content,
content_type: f.content_type || 'text/plain',
})),
})
} catch (e) {
console.error('Failed to load file contents for editing', e)
} finally {
actions.setFileContentsLoading(false)
}
},
setMode: ({ mode }) => {
if (
mode === SkillMode.Edit &&
isSkill(values.skill) &&
values.skill.files &&
values.skill.files.length > 0
) {
actions.loadFileContents()
}
},
loadSkillSuccess: ({ skill }) => {
if (skill && isSkill(skill)) {
actions.resetSkillForm()
actions.setSkillFormValues(getSkillFormDefaults(skill))
}
},
setCompareVersion: ({ compareVersion }) => {
if (compareVersion !== null) {
actions.loadCompareSkill(compareVersion)
}
},
loadCompareSkillFailure: () => {
toast.error('Failed to load comparison version')
},
})),
defaults(
({
props,
}): {
skill: SkillFormValues | ResolvedLLMSkill | null
skillForm: SkillFormValues
versionsLoading: boolean
} => {
if (props.skillName === 'new') {
return {
skill: DEFAULT_SKILL_FORM_VALUES,
skillForm: DEFAULT_SKILL_FORM_VALUES,
versionsLoading: false,
}
}
const existingSkill = findExistingSkill(props.skillName)
if (existingSkill) {
// The list endpoint omits body and files for progressive disclosure; pad
// them so the cached preview satisfies the full shape until loadSkill runs.
const paddedSkill: ResolvedLLMSkill = {
...existingSkill,
body: '',
body_total_length: 0,
body_next_offset: null,
files: [],
versions: [],
has_more: false,
}
return {
skill: paddedSkill,
skillForm: getSkillFormDefaults(paddedSkill),
versionsLoading: false,
}
}
return {
skill: null,
skillForm: DEFAULT_SKILL_FORM_VALUES,
versionsLoading: false,
}
}
),
afterMount(({ actions, values }) => {
if (values.isNewSkill) {
actions.setSkill(DEFAULT_SKILL_FORM_VALUES)
actions.resetSkillForm(DEFAULT_SKILL_FORM_VALUES)
} else {
actions.loadSkill()
}
}),
urlToAction(({ actions, values }) => ({
'/skills/:name': (_, __, ___, { method }) => {
if (method === 'PUSH' && values.isNewSkill) {
actions.setSkill(DEFAULT_SKILL_FORM_VALUES)
actions.resetSkillForm(DEFAULT_SKILL_FORM_VALUES)
return
}
if (method === 'PUSH' && !values.isNewSkill) {
actions.loadSkill()
}
},
})),
])