Files
zeekayandHanzo Dev 841ea1c0da no hogs: products and insights
716 files rewritten, 30 renamed, same family mapping as the frontend pass.

Held back, and each for a reason rather than by oversight:

  db_table values          insights_hogfunction, insights_hogflow,
                           insights_hogfunctiontemplate, hogflow_templates.
                           Renaming a table is ALTER TABLE, so it ships as a
                           migration with the raw SQL in nodejs that names it.
  hog_invocation_results   a Datastore table with Kafka engine tables on it
  products/review_hog      the module path is the Django app path; moving it
                           moves app_label and every migration is recorded under
                           (app_label, name). That is the same history break as
                           renaming a migration file, one level up.
  */migrations/*           five files, untouched
  whogho                   an integration vendor

Checked: 395 of 397 changed Python files compile. The two that do not —
insightsql/database/database.py and temporal/schedule.py — fail identically at
origin/main under the same interpreter, because they use `type X = ...` and
`except*`, which need the 3.13 this repo pins and not the 3.9 doing the check.
Running the check against the original is what told those apart from a real
break.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-09 09:16:43 -07:00

790 lines
30 KiB
TypeScript

import { MakeLogicType, actions, afterMount, kea, listeners, path, props, reducers, selectors } from 'kea'
import { loaders } from 'kea-loaders'
import { router, urlToAction } from 'kea-router'
import { objectsEqual } from 'lib/utils/objects'
import api, { ApiConfig } from '~/lib/api'
import { downloadBlob } from '~/lib/components/ExportButton/exporter'
import { Sorting } from '~/lib/elements/Table'
import { toast } from '~/lib/elements/Toast/Toast'
import { PaginationManual } from '~/lib/elements/PaginationControl'
import { trackedActionToUrl } from '~/lib/logic/scenes/trackedActionToUrl'
import { sceneLogic } from '~/scenes/sceneLogic'
import { urls } from '~/scenes/urls'
import {
getLlmSkillsNameExportRetrieveUrl,
llmSkillsImportCreate,
llmSkillsList,
llmSkillsMarketplaceInstallCommandCreate,
llmSkillsMarketplaceInstallCommandRetrieve,
llmSkillsNameArchiveCreate,
llmSkillsNameDuplicateCreate,
} from 'products/skills/frontend/generated/api'
import type {
LLMSkillListApi,
LLMSkillMarketplaceCommandApi,
PaginatedLLMSkillListListApi,
} from 'products/skills/frontend/generated/api.schemas'
/** Builds the `/plugin marketplace add` command. The token is embedded once minted; until then
* a placeholder is shown. */
export function buildMarketplaceCommand(token: string | null): string {
const teamId = ApiConfig.getCurrentTeamId()
const origin = window.location.origin
const scheme = origin.startsWith('https') ? 'https' : 'http'
const host = origin.replace(/^https?:\/\//, '')
return `/plugin marketplace add ${scheme}://x-access-token:${token ?? 'YOUR_PHS_TOKEN'}@${host}/v1/projects/${teamId}/llm_skills/marketplace.git`
}
function errorDetail(error: unknown): string | undefined {
return error !== null && typeof error === 'object' && 'detail' in error
? (error as { detail?: string }).detail
: undefined
}
/** Download a skill as a spec-compliant zip. The generated export client JSON-parses the
* binary response, so fetch the raw blob via the generated URL builder instead. */
export async function exportAndDownloadSkill(skillName: string): Promise<void> {
const url = getLlmSkillsNameExportRetrieveUrl(String(ApiConfig.getCurrentTeamId()), skillName, {})
const response = await api.getResponse(url)
if (!response.ok) {
let detail = 'Failed to export skill'
try {
detail = (await response.json())?.detail || detail
} catch {
// non-JSON error body; keep the default message
}
throw new Error(detail)
}
downloadBlob(await response.blob(), `${skillName}.zip`)
}
export const SKILLS_PER_PAGE = 30
// Hard upper bound for "group by prefix" mode — grouping is a client-side aggregation,
// so we fetch the full list once instead of paging.
export const SKILLS_GROUP_LIMIT = 500
// Cap on hierarchical nesting depth. Pathological chains like a-b-c-d-e-f-g-h would
// otherwise push content infinitely right; remaining segments below this depth render
// as leaves of the deepest allowed group.
export const SKILLS_GROUP_MAX_DEPTH = 4
export const LLM_SKILLS_FORCE_RELOAD_PARAM = 'llm_skills_force_reload'
/** URL/UI key for the default tab — the uncategorized skills (everything not pulled into a category tab). */
export const DEFAULT_SKILLS_TAB_KEY: string = 'skills'
/** Sub-title shown for the default "Skills" tab. Category tabs carry their own copy (below). */
export const DEFAULT_SKILLS_TAB_DESCRIPTION = 'Manage versioned agent skills that any agent can discover and use.'
export interface SkillCategoryTab {
/** Tab key, also the `/skills/<key>` URL segment. */
key: string
/** The `LLMSkill.category` value this tab filters to. */
category: string
label: string
/** Sub-title shown under the scene title while this tab is active. */
description: string
}
/**
* Category-backed tabs shown alongside the default "Skills" tab. Each entry surfaces one
* `LLMSkill.category` value as its own tab and removes it from the default list (which only shows
* uncategorized skills). To add a tab — e.g. official AI-plugin skills — add an entry here, register
* the matching `/skills/<key>` route in manifest.tsx, and have the producer stamp that `category`.
*/
export const SKILL_CATEGORY_TABS: SkillCategoryTab[] = [
{
key: 'scouts',
category: 'scout',
label: 'Scouts',
description:
"Scouts — scheduled agents that scan your project and surface findings in your inbox. Includes Insights's canonical scouts and any custom ones you author.",
},
{
key: 'review-script',
category: 'review',
label: 'Code review',
description:
"Review's code-review skills: the specialist perspectives applied in parallel when reviewing a pull request (logic & correctness, contracts & security, performance & reliability) and the validation criteria that decide which findings are worth surfacing. Edit a skill to retune it for your team.",
},
]
export function skillCategoryForTabKey(tabKey: string): string {
return SKILL_CATEGORY_TABS.find((tab) => tab.key === tabKey)?.category ?? ''
}
export function skillTabDescription(tabKey: string): string {
return SKILL_CATEGORY_TABS.find((tab) => tab.key === tabKey)?.description ?? DEFAULT_SKILLS_TAB_DESCRIPTION
}
/** The `/skills` or `/skills/<categoryKey>` path for a tab key. */
export function skillTabUrl(tabKey: string): string {
return tabKey === DEFAULT_SKILLS_TAB_KEY ? urls.skills() : urls.skillsCategoryTab(tabKey)
}
export interface SkillFilters {
page: number
search: string
order_by: string
group_by_prefix: boolean
created_by_id?: number
}
function parseBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value
}
if (typeof value === 'string') {
return value === 'true' || value === '1'
}
return false
}
function cleanFilters(values: Partial<SkillFilters>): SkillFilters {
return {
page: parseInt(String(values.page)) || 1,
search: String(values.search || ''),
order_by: values.order_by || '-created_at',
group_by_prefix: parseBoolean(values.group_by_prefix),
created_by_id: values.created_by_id ? Number(values.created_by_id) : undefined,
}
}
function cleanFilterUrlParams(filters: SkillFilters): Record<string, unknown> {
return {
page: filters.page === 1 || filters.group_by_prefix ? undefined : filters.page,
search: filters.search || undefined,
order_by: filters.order_by === '-created_at' ? undefined : filters.order_by,
group_by_prefix: filters.group_by_prefix ? 'true' : undefined,
created_by_id: filters.created_by_id,
}
}
export interface SkillGroupNode {
/** Full prefix path joined by '-', e.g. "andy-signals". */
prefix: string
/** Last segment of the prefix, e.g. "signals". */
segment: string
/** Total skills under this group (including all descendants). */
count: number
/** Sub-groups nested below this one (only created when 2+ skills share a deeper prefix). */
children: SkillGroupNode[]
/** Skills that belong directly to this group with no further sub-grouping. */
leaves: LLMSkillListApi[]
}
export interface SkillGroupTree {
groups: SkillGroupNode[]
/** Top-level skills that didn't share a prefix with anything else. */
ungrouped: LLMSkillListApi[]
}
function buildGroupTree(skills: LLMSkillListApi[], depth: number, pathPrefix: string): SkillGroupTree {
const buckets = new Map<string, LLMSkillListApi[]>()
const directLeaves: LLMSkillListApi[] = []
for (const skill of skills) {
const segments = skill.name.split('-')
if (depth >= segments.length) {
directLeaves.push(skill)
continue
}
const key = segments[depth]
const list = buckets.get(key) ?? []
list.push(skill)
buckets.set(key, list)
}
const groups: SkillGroupNode[] = []
const leaves: LLMSkillListApi[] = [...directLeaves]
for (const [segment, items] of buckets) {
if (items.length < 2) {
// Lone matches at this depth roll up as leaves of the parent group.
leaves.push(...items)
continue
}
const newPrefix = pathPrefix ? `${pathPrefix}-${segment}` : segment
// At max depth, stop recursing — collapse all remaining items into leaves of this group.
const sub: SkillGroupTree =
depth + 1 >= SKILLS_GROUP_MAX_DEPTH
? { groups: [], ungrouped: [...items].sort((a, b) => a.name.localeCompare(b.name)) }
: buildGroupTree(items, depth + 1, newPrefix)
groups.push({
prefix: newPrefix,
segment,
count: items.length,
children: sub.groups,
leaves: sub.ungrouped,
})
}
groups.sort((a, b) => b.count - a.count || a.segment.localeCompare(b.segment))
leaves.sort((a, b) => a.name.localeCompare(b.name))
return { groups, ungrouped: leaves }
}
export function groupSkillsByPrefix(skills: LLMSkillListApi[]): SkillGroupTree {
return buildGroupTree(skills, 0, '')
}
export type LLMSkillsLogicProps = Record<string, never>
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface llmSkillsLogicValues {
activeCategory: string
activeTabDescription: string
activeTabKey: string
categoryCounts: Record<string, number>
categoryCountsLoading: boolean
codexCommand: string
connectModalOpen: boolean
count: number
filters: SkillFilters
groupedSkills: SkillGroupTree | null
importing: boolean
issuingCredential: boolean
marketplaceCommand: string
marketplaceLoading: boolean
marketplaceState: LLMSkillMarketplaceCommandApi | null
pagination: PaginationManual | undefined
rawFilters: Partial<SkillFilters> | null
skillCountLabel: string
skills: PaginatedLLMSkillListListApi
skillsLoading: boolean
sorting: Sorting | null
visibleCategoryTabs: SkillCategoryTab[]
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface llmSkillsLogicActions {
deleteSkill: (skillName: string) => {
skillName: string
}
downloadSkillZip: (skillName: string) => {
skillName: string
}
duplicateSkill: (
skillName: string,
newName: string
) => {
newName: string
skillName: string
}
importSkill: (file: File) => {
file: File
}
issueMarketplaceCommand: (rotate?: boolean) => {
rotate: boolean
}
loadCategoryCounts: () => {
value: true
}
loadCategoryCountsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadCategoryCountsSuccess: (
categoryCounts: {
[k: string]: number
},
payload?: {
value: true
}
) => {
categoryCounts: {
[k: string]: number
}
payload?: {
value: true
}
}
loadMarketplaceState: () => {
value: true
}
loadSkills: (debounce?: boolean) => {
debounce: boolean
}
loadSkillsFailure: (
error: string,
errorObject?: any
) => {
error: string
errorObject?: any
}
loadSkillsSuccess: (
skills: PaginatedLLMSkillListListApi,
payload?: {
debounce: boolean
}
) => {
skills: PaginatedLLMSkillListListApi
payload?: {
debounce: boolean
}
}
setActiveTab: (tabKey: string) => {
tabKey: string
}
setConnectModalOpen: (open: boolean) => {
open: boolean
}
setFilters: (
filters: Partial<SkillFilters>,
merge?: boolean,
debounce?: boolean
) => {
debounce: boolean
filters: Partial<SkillFilters>
merge: boolean
}
setImporting: (importing: boolean) => {
importing: boolean
}
setIssuingCredential: (issuing: boolean) => {
issuing: boolean
}
setMarketplaceLoading: (loading: boolean) => {
loading: boolean
}
setMarketplaceState: (state: LLMSkillMarketplaceCommandApi | null) => {
state: LLMSkillMarketplaceCommandApi | null
}
}
// Generated by kea-typegen. Update if you're an agent, ignore if you're human.
export interface llmSkillsLogicMeta {
__keaTypeGenInternalSelectorTypes: {
filters: (rawFilters: Partial<SkillFilters> | null) => SkillFilters
activeCategory: (activeTabKey: string) => string
activeTabDescription: (activeTabKey: string) => string
visibleCategoryTabs: (categoryCounts: Record<string, number>, activeTabKey: string) => SkillCategoryTab[]
count: (skills: PaginatedLLMSkillListListApi) => number
marketplaceCommand: (marketplaceState: LLMSkillMarketplaceCommandApi | null) => string
codexCommand: (marketplaceState: LLMSkillMarketplaceCommandApi | null) => string
sorting: (filters: SkillFilters) => Sorting | null
pagination: (filters: SkillFilters, count: number) => PaginationManual | undefined
groupedSkills: (skills: PaginatedLLMSkillListListApi, filters: SkillFilters) => SkillGroupTree | null
skillCountLabel: (filters: SkillFilters, count: number, skills: PaginatedLLMSkillListListApi) => string
}
}
export type llmSkillsLogicType = MakeLogicType<
llmSkillsLogicValues,
llmSkillsLogicActions,
LLMSkillsLogicProps,
llmSkillsLogicMeta
>
export const llmSkillsLogic = kea<llmSkillsLogicType>([
path(['scenes', 'skills', 'llmSkillsLogic']),
props({} as LLMSkillsLogicProps),
actions({
setFilters: (filters: Partial<SkillFilters>, merge: boolean = true, debounce: boolean = true) => ({
filters,
merge,
debounce,
}),
loadSkills: (debounce: boolean = true) => ({ debounce }),
loadCategoryCounts: true,
setActiveTab: (tabKey: string) => ({ tabKey }),
deleteSkill: (skillName: string) => ({ skillName }),
duplicateSkill: (skillName: string, newName: string) => ({ skillName, newName }),
importSkill: (file: File) => ({ file }),
setImporting: (importing: boolean) => ({ importing }),
downloadSkillZip: (skillName: string) => ({ skillName }),
setConnectModalOpen: (open: boolean) => ({ open }),
loadMarketplaceState: true,
// rotate=false mints when absent / returns the masked existing key; rotate=true rolls it.
issueMarketplaceCommand: (rotate: boolean = false) => ({ rotate }),
setMarketplaceState: (state: LLMSkillMarketplaceCommandApi | null) => ({ state }),
setMarketplaceLoading: (loading: boolean) => ({ loading }),
setIssuingCredential: (issuing: boolean) => ({ issuing }),
}),
reducers({
activeTabKey: [
DEFAULT_SKILLS_TAB_KEY,
{
setActiveTab: (_, { tabKey }) => tabKey,
},
],
rawFilters: [
null as Partial<SkillFilters> | null,
{
setFilters: (state, { filters, merge }) =>
cleanFilters({
...(merge ? state || {} : {}),
...filters,
...('page' in filters ? {} : { page: 1 }),
}),
},
],
importing: [
false,
{
setImporting: (_, { importing }) => importing,
},
],
connectModalOpen: [
false,
{
setConnectModalOpen: (_, { open }) => open,
},
],
// The full server response (status, command, command_template, token, mask). The live sk-
// token only ever lives here and only on create/rotate; clear it when the modal closes.
marketplaceState: [
null as LLMSkillMarketplaceCommandApi | null,
{
setMarketplaceState: (_, { state }) => state,
setConnectModalOpen: (state, { open }) => (open ? state : null),
},
],
marketplaceLoading: [
false,
{
setMarketplaceLoading: (_, { loading }) => loading,
},
],
issuingCredential: [
false,
{
setIssuingCredential: (_, { issuing }) => issuing,
},
],
}),
loaders(({ values }) => ({
skills: [
{ results: [], count: 0 } as PaginatedLLMSkillListListApi,
{
loadSkills: async ({ debounce }, breakpoint) => {
if (debounce && values.skills.results.length > 0) {
await breakpoint(300)
}
const { filters } = values
// Always send `category` (even as ""): the default tab shows uncategorized skills,
// each category tab shows its own category. Presence of the param is the filter.
const category = values.activeCategory
const params = filters.group_by_prefix
? {
search: filters.search,
order_by: filters.order_by,
offset: 0,
limit: SKILLS_GROUP_LIMIT,
created_by_id: filters.created_by_id,
category,
}
: {
search: filters.search,
order_by: filters.order_by,
offset: Math.max(0, (filters.page - 1) * SKILLS_PER_PAGE),
limit: SKILLS_PER_PAGE,
created_by_id: filters.created_by_id,
category,
}
if (
!filters.group_by_prefix &&
sceneLogic.findMounted()?.values.activeSceneId === 'AIObservability' &&
router.values.lastMethod !== 'POP' &&
values.skills.results.length > 0 &&
values.rawFilters?.page !== filters.page
) {
window.scrollTo(0, 0)
}
return await llmSkillsList(String(ApiConfig.getCurrentTeamId()), params)
},
},
],
// Per-category skill counts, used to decide which category tabs to show. Loaded once on
// mount (and after a delete) with a cheap limit=1 probe per category — we only read `count`.
categoryCounts: [
{} as Record<string, number>,
{
loadCategoryCounts: async () => {
const teamId = String(ApiConfig.getCurrentTeamId())
const entries = await Promise.all(
SKILL_CATEGORY_TABS.map(async (tab) => {
const { count } = await llmSkillsList(teamId, {
category: tab.category,
limit: 1,
offset: 0,
})
return [tab.category, count] as const
})
)
return Object.fromEntries(entries)
},
},
],
})),
selectors({
filters: [
(s) => [s.rawFilters],
(rawFilters: Partial<SkillFilters> | null): SkillFilters => cleanFilters(rawFilters || {}),
],
activeCategory: [
(s) => [s.activeTabKey],
(activeTabKey: string): string => skillCategoryForTabKey(activeTabKey),
],
activeTabDescription: [
(s) => [s.activeTabKey],
(activeTabKey: string): string => skillTabDescription(activeTabKey),
],
// A category tab is shown only once the team has at least one skill in that category.
// The active tab is always kept visible so direct navigation to /skills/<key> (or deleting
// the last skill while viewing the tab) doesn't strand the user on a tab that isn't listed.
visibleCategoryTabs: [
(s) => [s.categoryCounts, s.activeTabKey],
(categoryCounts: Record<string, number>, activeTabKey: string): SkillCategoryTab[] =>
SKILL_CATEGORY_TABS.filter(
(tab) => (categoryCounts[tab.category] ?? 0) > 0 || tab.key === activeTabKey
),
],
count: [(s) => [s.skills], (skills: PaginatedLLMSkillListListApi) => skills.count],
marketplaceCommand: [
(s) => [s.marketplaceState],
(marketplaceState: LLMSkillMarketplaceCommandApi | null): string =>
// Live command (token embedded) once issued, else the server placeholder template,
// else a locally-built placeholder before the state has loaded.
marketplaceState?.command ?? marketplaceState?.command_template ?? buildMarketplaceCommand(null),
],
codexCommand: [
(s) => [s.marketplaceState],
(marketplaceState: LLMSkillMarketplaceCommandApi | null): string =>
// Same per-user credential; the Codex two-line command. Only shown once state loads.
marketplaceState?.codex_command ?? marketplaceState?.codex_command_template ?? '',
],
sorting: [
(s) => [s.filters],
(filters: SkillFilters): Sorting | null => {
if (!filters.order_by) {
return { columnKey: 'created_at', order: -1 }
}
return filters.order_by.startsWith('-')
? { columnKey: filters.order_by.slice(1), order: -1 }
: { columnKey: filters.order_by, order: 1 }
},
],
pagination: [
(s) => [s.filters, s.count],
(filters: SkillFilters, count: number): PaginationManual | undefined => {
if (filters.group_by_prefix) {
return undefined
}
return {
controlled: true,
pageSize: SKILLS_PER_PAGE,
currentPage: filters.page,
entryCount: count,
}
},
],
groupedSkills: [
(s) => [s.skills, s.filters],
(skills: PaginatedLLMSkillListListApi, filters: SkillFilters): SkillGroupTree | null => {
if (!filters.group_by_prefix) {
return null
}
return groupSkillsByPrefix(skills.results)
},
],
skillCountLabel: [
(s) => [s.filters, s.count, s.skills],
(filters: SkillFilters, count: number, skills: PaginatedLLMSkillListListApi) => {
if (filters.group_by_prefix) {
const loaded = skills.results.length
if (count === 0) {
return '0 skills'
}
if (loaded < count) {
return `Showing ${loaded} of ${count} skill${count === 1 ? '' : 's'}`
}
return `${count} skill${count === 1 ? '' : 's'}`
}
const start = (filters.page - 1) * SKILLS_PER_PAGE + 1
const end = Math.min(filters.page * SKILLS_PER_PAGE, count)
return count === 0 ? '0 skills' : `${start}-${end} of ${count} skill${count === 1 ? '' : 's'}`
},
],
}),
listeners(({ actions, asyncActions, values, selectors }) => ({
setFilters: async ({ debounce }, _, __, previousState) => {
const oldFilters = selectors.filters(previousState)
const { filters } = values
if (!objectsEqual(oldFilters, filters)) {
await asyncActions.loadSkills(debounce)
}
},
deleteSkill: async ({ skillName }) => {
try {
await llmSkillsNameArchiveCreate(String(ApiConfig.getCurrentTeamId()), skillName)
toast.info(`${skillName || 'Skill'} has been archived.`)
await asyncActions.loadSkills(false)
// Archiving may have removed the last skill in a category — refresh tab visibility.
actions.loadCategoryCounts()
} catch (e) {
console.error('Failed to archive skill', e)
toast.error('Failed to archive skill')
}
},
duplicateSkill: async ({ skillName, newName }) => {
try {
await llmSkillsNameDuplicateCreate(String(ApiConfig.getCurrentTeamId()), skillName, {
new_name: newName,
})
toast.success(`Skill duplicated as "${newName}".`)
router.actions.push(urls.skill(newName))
} catch (e) {
console.error('Failed to duplicate skill', e)
toast.error('Failed to duplicate skill')
}
},
importSkill: async ({ file }) => {
actions.setImporting(true)
try {
// Orval types the FileField as string; the generated client appends the File to FormData.
const skill = await llmSkillsImportCreate(String(ApiConfig.getCurrentTeamId()), {
file: file as unknown as string,
})
toast.success(`Imported "${skill.name}".`)
actions.loadSkills(false)
router.actions.push(urls.skill(skill.name))
} catch (e) {
console.error('Failed to import skill', e)
toast.error(errorDetail(e) || 'Failed to import skill — is it a valid skill zip?')
} finally {
actions.setImporting(false)
}
},
downloadSkillZip: async ({ skillName }) => {
try {
await exportAndDownloadSkill(skillName)
} catch (e) {
console.error('Failed to export skill', e)
toast.error(errorDetail(e) || (e instanceof Error ? e.message : 'Failed to export skill'))
}
},
setConnectModalOpen: ({ open }) => {
// Reload the (token-less) connection state each time the modal opens so we never
// re-surface a previously minted token and always reflect the current credential.
if (open) {
actions.loadMarketplaceState()
}
},
loadMarketplaceState: async () => {
actions.setMarketplaceLoading(true)
try {
const state = await llmSkillsMarketplaceInstallCommandRetrieve(String(ApiConfig.getCurrentTeamId()))
actions.setMarketplaceState(state)
} catch (e) {
console.error('Failed to load marketplace connection state', e)
toast.error(errorDetail(e) || 'Failed to check your skill store connection.')
} finally {
actions.setMarketplaceLoading(false)
}
},
issueMarketplaceCommand: async ({ rotate }) => {
actions.setIssuingCredential(true)
try {
// Per-user read-only credential: mints if absent, rolls (rotate=true) only this user's own key.
const state = await llmSkillsMarketplaceInstallCommandCreate(String(ApiConfig.getCurrentTeamId()), {
rotate,
})
actions.setMarketplaceState(state)
} catch (e) {
console.error('Failed to issue marketplace credential', e)
toast.error(
errorDetail(e) || 'Failed to issue credential. Do you have permission to manage API keys?'
)
} finally {
actions.setIssuingCredential(false)
}
},
})),
trackedActionToUrl(({ values }) => {
const changeUrl = (): [string, Record<string, any>, Record<string, any>, { replace: boolean }] | void => {
const nextValues = cleanFilterUrlParams(values.filters)
const urlValues = cleanFilters(router.values.searchParams)
if (!objectsEqual(values.filters, urlValues)) {
// Keep filter changes on the active tab's path so editing search/sort on a category
// tab doesn't bounce the URL back to the default /skills.
return [skillTabUrl(values.activeTabKey), nextValues, {}, { replace: true }]
}
}
return { setFilters: changeUrl }
}),
urlToAction(({ actions, values }) => {
const handleTab =
(tabKey: string) =>
(_: any, searchParams: Record<string, any>, __: any, { method }: { method: string }): void => {
// Update the active tab first so the loader picks up the new category. Setting it is a
// plain reducer (no reload of its own) — handleTab owns the single reload below.
const tabChanged = values.activeTabKey !== tabKey
if (tabChanged) {
actions.setActiveTab(tabKey)
}
const newFilters = cleanFilters(searchParams)
const forceReload = typeof searchParams?.[LLM_SKILLS_FORCE_RELOAD_PARAM] === 'string'
if (!objectsEqual(values.filters, newFilters)) {
// setFilters reloads, reading the just-updated activeCategory.
actions.setFilters(newFilters, false)
} else if (tabChanged || forceReload || method !== 'REPLACE') {
actions.loadSkills(false)
}
if (forceReload) {
const nextSearchParams = { ...searchParams }
delete nextSearchParams[LLM_SKILLS_FORCE_RELOAD_PARAM]
router.actions.replace(skillTabUrl(tabKey), nextSearchParams)
}
}
return {
[urls.skills()]: handleTab(DEFAULT_SKILLS_TAB_KEY),
...Object.fromEntries(SKILL_CATEGORY_TABS.map((tab) => [skillTabUrl(tab.key), handleTab(tab.key)])),
}
}),
afterMount(({ actions }) => {
actions.loadSkills()
actions.loadCategoryCounts()
}),
])