kcg-code 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +228 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +83 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +142 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +220 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +85 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +97 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +234 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +360 -0
- package/src/lib/skills-catalog.ts +99 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +415 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halaman Skills (`/skills`) — jelajah & pasang skill dari skills.sh.
|
|
3
|
+
*
|
|
4
|
+
* - Header: judul + pemilih Project (pil "Install to"). Project terpilih =
|
|
5
|
+
* `?project=` (dari sidebar) > pilihan terakhir (localStorage) > satu-
|
|
6
|
+
* satunya Project. Pilihan memperbarui URL (`replaceState`).
|
|
7
|
+
* - Dua tab memakai kolom cari yang sama, tapi query disimpan per tab:
|
|
8
|
+
* - Browse: hasil skills.sh (debounce, min. 2 karakter); saat kosong
|
|
9
|
+
* tampil chip saran kata kunci.
|
|
10
|
+
* - Installed: skill terpasang di Project (+ global), difilter lokal.
|
|
11
|
+
* - Hasil berupa grid kartu 2 kolom (1 kolom di HP).
|
|
12
|
+
* - Pintasan `/` memfokuskan kolom cari; Esc mengosongkannya.
|
|
13
|
+
* - Install -> dialog konfirmasi (audit keamanan) -> job instalasi di latar
|
|
14
|
+
* (`lib/skill-installs`). Progres + output CLI tampil di notifikasi global;
|
|
15
|
+
* kartu menunjukkan "Installing…" dan daftar terpasang dimuat ulang saat
|
|
16
|
+
* job sukses.
|
|
17
|
+
*/
|
|
18
|
+
import { FolderPlusIcon, SearchIcon, SparklesIcon, XIcon } from "lucide-react";
|
|
19
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
20
|
+
import { InstallSkillDialog } from "@/components/skills/InstallSkillDialog";
|
|
21
|
+
import {
|
|
22
|
+
InstalledSkillCard,
|
|
23
|
+
RegistrySkillCard,
|
|
24
|
+
SkillCardSkeleton,
|
|
25
|
+
SkillGrid,
|
|
26
|
+
} from "@/components/skills/SkillCards";
|
|
27
|
+
import { SkillsProjectPicker } from "@/components/skills/SkillsProjectPicker";
|
|
28
|
+
import { Button } from "@/components/ui/button";
|
|
29
|
+
import {
|
|
30
|
+
Empty,
|
|
31
|
+
EmptyContent,
|
|
32
|
+
EmptyDescription,
|
|
33
|
+
EmptyHeader,
|
|
34
|
+
EmptyMedia,
|
|
35
|
+
EmptyTitle,
|
|
36
|
+
} from "@/components/ui/empty";
|
|
37
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
38
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
39
|
+
import { useRouter } from "@/hooks/useRouter";
|
|
40
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
41
|
+
import { parseSkillsProject, projectsPath, skillsPath } from "@/lib/routes";
|
|
42
|
+
import {
|
|
43
|
+
findRunningInstall,
|
|
44
|
+
onInstallFinished,
|
|
45
|
+
revealInstall,
|
|
46
|
+
startInstall,
|
|
47
|
+
useInstalls,
|
|
48
|
+
} from "@/lib/skill-installs";
|
|
49
|
+
import {
|
|
50
|
+
installedProjectSkillNames,
|
|
51
|
+
isInstalled,
|
|
52
|
+
LAST_SKILLS_PROJECT_KEY,
|
|
53
|
+
resolveSkillsProject,
|
|
54
|
+
SEARCH_SUGGESTIONS,
|
|
55
|
+
visibleInstalledSkills,
|
|
56
|
+
} from "@/lib/skills-catalog";
|
|
57
|
+
import { cn } from "@/lib/utils";
|
|
58
|
+
import type { SkillInfo } from "@/server/services/opencode-client";
|
|
59
|
+
import type { RegistrySkill } from "@/server/services/skills-registry";
|
|
60
|
+
import type { Project } from "@/types";
|
|
61
|
+
|
|
62
|
+
/** Jeda ketik sebelum memanggil skills.sh. */
|
|
63
|
+
const SEARCH_DEBOUNCE_MS = 350;
|
|
64
|
+
const MIN_QUERY = 2;
|
|
65
|
+
|
|
66
|
+
type Tab = "browse" | "installed";
|
|
67
|
+
|
|
68
|
+
type Load<T> =
|
|
69
|
+
| { phase: "idle" }
|
|
70
|
+
| { phase: "loading" }
|
|
71
|
+
| { phase: "error"; message: string }
|
|
72
|
+
| { phase: "ready"; data: T };
|
|
73
|
+
|
|
74
|
+
function errorMessage(e: unknown, fallback: string): string {
|
|
75
|
+
return e instanceof ApiError ? e.message : fallback;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readStoredProject(): string | null {
|
|
79
|
+
try {
|
|
80
|
+
return localStorage.getItem(LAST_SKILLS_PROJECT_KEY);
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function storeProject(id: string): void {
|
|
87
|
+
try {
|
|
88
|
+
localStorage.setItem(LAST_SKILLS_PROJECT_KEY, id);
|
|
89
|
+
} catch {
|
|
90
|
+
// Storage tidak tersedia (mode privat) — cukup URL.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function SkillsPage() {
|
|
95
|
+
const { pathname, navigate } = useRouter();
|
|
96
|
+
const [projects, setProjects] = useState<Project[] | null>(null);
|
|
97
|
+
const [projectsError, setProjectsError] = useState<string | null>(null);
|
|
98
|
+
const [urlProjectId, setUrlProjectId] = useState<string | null>(() =>
|
|
99
|
+
parseSkillsProject(window.location.search),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Sinkron dengan URL saat navigasi SPA ke /skills?project=... dari sidebar.
|
|
103
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname jadi trigger navigasi
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
setUrlProjectId(parseSkillsProject(window.location.search));
|
|
106
|
+
}, [pathname]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
let cancelled = false;
|
|
110
|
+
apiFetch("/api/projects")
|
|
111
|
+
.then((res) => res.json() as Promise<{ projects: Project[] }>)
|
|
112
|
+
.then(({ projects }) => {
|
|
113
|
+
if (!cancelled) setProjects(projects);
|
|
114
|
+
})
|
|
115
|
+
.catch((e) => {
|
|
116
|
+
if (!cancelled) {
|
|
117
|
+
setProjects([]);
|
|
118
|
+
setProjectsError(errorMessage(e, "Failed to load projects"));
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return () => {
|
|
122
|
+
cancelled = true;
|
|
123
|
+
};
|
|
124
|
+
}, []);
|
|
125
|
+
|
|
126
|
+
const selected = useMemo(
|
|
127
|
+
() => (projects ? resolveSkillsProject(projects, urlProjectId, readStoredProject()) : null),
|
|
128
|
+
[projects, urlProjectId],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Pilihan hasil fallback (tersimpan / satu-satunya) ikut ditulis ke URL.
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (selected && selected.id !== urlProjectId) {
|
|
134
|
+
window.history.replaceState({}, "", skillsPath(selected.id));
|
|
135
|
+
setUrlProjectId(selected.id);
|
|
136
|
+
}
|
|
137
|
+
}, [selected, urlProjectId]);
|
|
138
|
+
|
|
139
|
+
const choose = useCallback((id: string) => {
|
|
140
|
+
storeProject(id);
|
|
141
|
+
setUrlProjectId(id);
|
|
142
|
+
window.history.replaceState({}, "", skillsPath(id));
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div className="flex flex-col gap-6">
|
|
147
|
+
{/* Header: judul di kiri, tujuan instalasi (pil) di kanan — konteks
|
|
148
|
+
halaman, terpisah jelas dari kolom cari di bawah tab. */}
|
|
149
|
+
<header className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
150
|
+
<div className="flex min-w-0 flex-col gap-1">
|
|
151
|
+
<h1 className="text-2xl font-semibold tracking-tight">Skills</h1>
|
|
152
|
+
<p className="text-sm text-muted-foreground">
|
|
153
|
+
Discover skills on{" "}
|
|
154
|
+
<a
|
|
155
|
+
href="https://skills.sh"
|
|
156
|
+
target="_blank"
|
|
157
|
+
rel="noopener noreferrer"
|
|
158
|
+
className="font-medium text-foreground underline underline-offset-4"
|
|
159
|
+
>
|
|
160
|
+
skills.sh
|
|
161
|
+
<span className="sr-only"> (opens in a new tab)</span>
|
|
162
|
+
</a>{" "}
|
|
163
|
+
and add them to your project's agent.
|
|
164
|
+
</p>
|
|
165
|
+
</div>
|
|
166
|
+
{projects === null ? (
|
|
167
|
+
<Skeleton className="h-9 w-48 shrink-0 rounded-full" />
|
|
168
|
+
) : (
|
|
169
|
+
projects.length > 0 && (
|
|
170
|
+
<div className="min-w-0 shrink-0">
|
|
171
|
+
<SkillsProjectPicker projects={projects} selected={selected} onSelect={choose} />
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
)}
|
|
175
|
+
</header>
|
|
176
|
+
|
|
177
|
+
{projects === null ? (
|
|
178
|
+
<div className="flex flex-col gap-4">
|
|
179
|
+
<Skeleton className="h-10 w-48" />
|
|
180
|
+
<Skeleton className="h-11 w-full rounded-xl" />
|
|
181
|
+
</div>
|
|
182
|
+
) : projects.length === 0 ? (
|
|
183
|
+
<Empty className="border">
|
|
184
|
+
<EmptyHeader>
|
|
185
|
+
<EmptyMedia variant="icon">
|
|
186
|
+
<FolderPlusIcon />
|
|
187
|
+
</EmptyMedia>
|
|
188
|
+
<EmptyTitle>No projects yet</EmptyTitle>
|
|
189
|
+
<EmptyDescription>
|
|
190
|
+
{projectsError ?? "Skills are installed per project. Create a project first."}
|
|
191
|
+
</EmptyDescription>
|
|
192
|
+
</EmptyHeader>
|
|
193
|
+
<EmptyContent>
|
|
194
|
+
<Button type="button" onClick={() => navigate(projectsPath())}>
|
|
195
|
+
Go to projects
|
|
196
|
+
</Button>
|
|
197
|
+
</EmptyContent>
|
|
198
|
+
</Empty>
|
|
199
|
+
) : (
|
|
200
|
+
// Remount per Project: pencarian & daftar terpasang tidak bocor.
|
|
201
|
+
<SkillsWorkspace key={selected?.id ?? "none"} project={selected} />
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
interface SkillsWorkspaceProps {
|
|
210
|
+
project: Project | null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
|
|
214
|
+
const [tab, setTab] = useState<Tab>("browse");
|
|
215
|
+
/**
|
|
216
|
+
* Query per tab: Browse mencari skills.sh (remote), Installed memfilter
|
|
217
|
+
* lokal — memakai satu query bersama membuat tab lain ikut terfilter.
|
|
218
|
+
*/
|
|
219
|
+
const [queries, setQueries] = useState<Record<Tab, string>>({ browse: "", installed: "" });
|
|
220
|
+
const query = queries[tab];
|
|
221
|
+
const setQuery = useCallback((q: string) => setQueries((prev) => ({ ...prev, [tab]: q })), [tab]);
|
|
222
|
+
const [installed, setInstalled] = useState<Load<SkillInfo[]>>({ phase: "idle" });
|
|
223
|
+
const [reloadKey, setReloadKey] = useState(0);
|
|
224
|
+
const [results, setResults] = useState<Load<RegistrySkill[]>>({ phase: "idle" });
|
|
225
|
+
/** Permintaan pencarian sedang berjalan (hasil lama tetap tampil). */
|
|
226
|
+
const [searchBusy, setSearchBusy] = useState(false);
|
|
227
|
+
const [pending, setPending] = useState<RegistrySkill | null>(null);
|
|
228
|
+
/** Permintaan memulai instalasi sedang dikirim (job belum dibuat). */
|
|
229
|
+
const [starting, setStarting] = useState(false);
|
|
230
|
+
const [startError, setStartError] = useState<string | null>(null);
|
|
231
|
+
const installs = useInstalls();
|
|
232
|
+
const searchRef = useRef<HTMLInputElement>(null);
|
|
233
|
+
|
|
234
|
+
// Pintasan `/` memfokuskan kolom cari (kecuali sedang mengetik di input lain).
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
const onKey = (e: KeyboardEvent) => {
|
|
237
|
+
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
238
|
+
const t = e.target as HTMLElement | null;
|
|
239
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
searchRef.current?.focus();
|
|
242
|
+
};
|
|
243
|
+
window.addEventListener("keydown", onKey);
|
|
244
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
245
|
+
}, []);
|
|
246
|
+
|
|
247
|
+
// Skill terpasang (dibaca dari server headless opencode Project).
|
|
248
|
+
useEffect(() => {
|
|
249
|
+
void reloadKey;
|
|
250
|
+
if (!project) return;
|
|
251
|
+
let cancelled = false;
|
|
252
|
+
setInstalled((prev) => (prev.phase === "ready" ? prev : { phase: "loading" }));
|
|
253
|
+
apiFetch(`/api/projects/${project.id}/skills`)
|
|
254
|
+
.then((res) => res.json() as Promise<{ skills: SkillInfo[] }>)
|
|
255
|
+
.then(({ skills }) => {
|
|
256
|
+
if (!cancelled) setInstalled({ phase: "ready", data: skills });
|
|
257
|
+
})
|
|
258
|
+
.catch((e) => {
|
|
259
|
+
if (!cancelled) {
|
|
260
|
+
setInstalled({ phase: "error", message: errorMessage(e, "Failed to load skills") });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
return () => {
|
|
264
|
+
cancelled = true;
|
|
265
|
+
};
|
|
266
|
+
}, [project, reloadKey]);
|
|
267
|
+
|
|
268
|
+
// Pencarian skills.sh dengan debounce; respon usang dibuang. Hasil lama
|
|
269
|
+
// tetap tampil selama query baru dimuat (spinner di kolom cari).
|
|
270
|
+
const trimmed = query.trim();
|
|
271
|
+
const browseQuery = queries.browse.trim();
|
|
272
|
+
const canSearch = browseQuery.length >= MIN_QUERY;
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
if (!canSearch) {
|
|
275
|
+
setResults({ phase: "idle" });
|
|
276
|
+
setSearchBusy(false);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
let cancelled = false;
|
|
280
|
+
setResults((prev) => (prev.phase === "ready" ? prev : { phase: "loading" }));
|
|
281
|
+
setSearchBusy(true);
|
|
282
|
+
const timer = setTimeout(() => {
|
|
283
|
+
apiFetch(`/api/skills/search?${new URLSearchParams({ q: browseQuery })}`)
|
|
284
|
+
.then((res) => res.json() as Promise<{ skills: RegistrySkill[] }>)
|
|
285
|
+
.then(({ skills }) => {
|
|
286
|
+
if (!cancelled) setResults({ phase: "ready", data: skills });
|
|
287
|
+
})
|
|
288
|
+
.catch((e) => {
|
|
289
|
+
if (!cancelled) setResults({ phase: "error", message: errorMessage(e, "Search failed") });
|
|
290
|
+
})
|
|
291
|
+
.finally(() => {
|
|
292
|
+
if (!cancelled) setSearchBusy(false);
|
|
293
|
+
});
|
|
294
|
+
}, SEARCH_DEBOUNCE_MS);
|
|
295
|
+
return () => {
|
|
296
|
+
cancelled = true;
|
|
297
|
+
clearTimeout(timer);
|
|
298
|
+
};
|
|
299
|
+
}, [browseQuery, canSearch]);
|
|
300
|
+
|
|
301
|
+
const installedNames = useMemo(
|
|
302
|
+
() =>
|
|
303
|
+
installed.phase === "ready" ? installedProjectSkillNames(installed.data) : new Set<string>(),
|
|
304
|
+
[installed],
|
|
305
|
+
);
|
|
306
|
+
const installedAll = useMemo(
|
|
307
|
+
() => (installed.phase === "ready" ? visibleInstalledSkills(installed.data, "") : []),
|
|
308
|
+
[installed],
|
|
309
|
+
);
|
|
310
|
+
const installedFiltered = useMemo(
|
|
311
|
+
() =>
|
|
312
|
+
installed.phase === "ready" ? visibleInstalledSkills(installed.data, queries.installed) : [],
|
|
313
|
+
[installed, queries.installed],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Instalasi (di halaman mana pun) selesai untuk Project ini -> muat ulang
|
|
317
|
+
// daftar terpasang agar kartu berubah menjadi "Installed".
|
|
318
|
+
useEffect(() => {
|
|
319
|
+
if (!project) return;
|
|
320
|
+
return onInstallFinished((job) => {
|
|
321
|
+
if (job.projectId === project.id && job.status === "succeeded") {
|
|
322
|
+
setReloadKey((k) => k + 1);
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
}, [project]);
|
|
326
|
+
|
|
327
|
+
/** Mulai job instalasi; progres & output tampil di notifikasi global. */
|
|
328
|
+
const install = async (skill: RegistrySkill) => {
|
|
329
|
+
if (!project) return;
|
|
330
|
+
setStarting(true);
|
|
331
|
+
setStartError(null);
|
|
332
|
+
try {
|
|
333
|
+
await startInstall(project.id, skill.source, skill.skillId);
|
|
334
|
+
setPending(null);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
// Dialog tetap terbuka agar user melihat alasannya.
|
|
337
|
+
setStartError(errorMessage(e, "Failed to start the installation."));
|
|
338
|
+
} finally {
|
|
339
|
+
setStarting(false);
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const placeholder =
|
|
344
|
+
tab === "browse" ? "Search skills.sh — e.g. react, testing, docs" : "Filter installed skills";
|
|
345
|
+
|
|
346
|
+
return (
|
|
347
|
+
<div className="flex flex-col gap-5">
|
|
348
|
+
{!project ? (
|
|
349
|
+
<Empty className="border">
|
|
350
|
+
<EmptyHeader>
|
|
351
|
+
<EmptyMedia variant="icon">
|
|
352
|
+
<SparklesIcon />
|
|
353
|
+
</EmptyMedia>
|
|
354
|
+
<EmptyTitle>Choose where to install</EmptyTitle>
|
|
355
|
+
<EmptyDescription>
|
|
356
|
+
Skills are added per project. Pick a project in the top right to browse and install
|
|
357
|
+
skills.
|
|
358
|
+
</EmptyDescription>
|
|
359
|
+
</EmptyHeader>
|
|
360
|
+
</Empty>
|
|
361
|
+
) : (
|
|
362
|
+
<>
|
|
363
|
+
{/* Tabs, lalu kolom cari untuk tab aktif (Browse = skills.sh,
|
|
364
|
+
Installed = filter lokal). */}
|
|
365
|
+
<div className="flex flex-col gap-4">
|
|
366
|
+
<div
|
|
367
|
+
role="tablist"
|
|
368
|
+
aria-label="Skill views"
|
|
369
|
+
className="flex items-center gap-1 border-b"
|
|
370
|
+
>
|
|
371
|
+
<TabButton id="browse" current={tab} onSelect={setTab}>
|
|
372
|
+
Browse
|
|
373
|
+
</TabButton>
|
|
374
|
+
<TabButton id="installed" current={tab} onSelect={setTab}>
|
|
375
|
+
Installed
|
|
376
|
+
{installed.phase === "ready" && (
|
|
377
|
+
<span className="rounded-full bg-muted px-1.5 text-[11px] leading-4 tabular-nums text-muted-foreground">
|
|
378
|
+
{installedAll.length}
|
|
379
|
+
</span>
|
|
380
|
+
)}
|
|
381
|
+
</TabButton>
|
|
382
|
+
</div>
|
|
383
|
+
<div className="relative flex min-w-0 items-center">
|
|
384
|
+
<SearchIcon
|
|
385
|
+
className="pointer-events-none absolute left-3.5 size-4 text-muted-foreground"
|
|
386
|
+
aria-hidden
|
|
387
|
+
/>
|
|
388
|
+
<input
|
|
389
|
+
ref={searchRef}
|
|
390
|
+
type="search"
|
|
391
|
+
value={query}
|
|
392
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
393
|
+
onKeyDown={(e) => {
|
|
394
|
+
if (e.key === "Escape" && query !== "") {
|
|
395
|
+
e.preventDefault();
|
|
396
|
+
setQuery("");
|
|
397
|
+
}
|
|
398
|
+
}}
|
|
399
|
+
placeholder={placeholder}
|
|
400
|
+
aria-label={
|
|
401
|
+
tab === "browse" ? "Search skills on skills.sh" : "Filter installed skills"
|
|
402
|
+
}
|
|
403
|
+
aria-describedby="skills-search-hint"
|
|
404
|
+
className="h-11 w-full rounded-xl border bg-card pr-20 pl-10 text-sm shadow-xs outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-60 [&::-webkit-search-cancel-button]:hidden"
|
|
405
|
+
/>
|
|
406
|
+
<div className="absolute right-2 flex items-center gap-1">
|
|
407
|
+
{searchBusy && tab === "browse" && (
|
|
408
|
+
<Spinner className="size-4 text-muted-foreground" />
|
|
409
|
+
)}
|
|
410
|
+
{query !== "" ? (
|
|
411
|
+
<button
|
|
412
|
+
type="button"
|
|
413
|
+
onClick={() => {
|
|
414
|
+
setQuery("");
|
|
415
|
+
searchRef.current?.focus();
|
|
416
|
+
}}
|
|
417
|
+
aria-label="Clear search"
|
|
418
|
+
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
419
|
+
>
|
|
420
|
+
<XIcon className="size-4" />
|
|
421
|
+
</button>
|
|
422
|
+
) : (
|
|
423
|
+
<kbd
|
|
424
|
+
className="hidden rounded border bg-muted px-1.5 font-mono text-[11px] text-muted-foreground sm:inline"
|
|
425
|
+
aria-hidden
|
|
426
|
+
>
|
|
427
|
+
/
|
|
428
|
+
</kbd>
|
|
429
|
+
)}
|
|
430
|
+
</div>
|
|
431
|
+
<span id="skills-search-hint" className="sr-only">
|
|
432
|
+
Press slash to focus search, Escape to clear.
|
|
433
|
+
</span>
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
|
|
437
|
+
<div role="tabpanel" id={`skills-panel-${tab}`} aria-labelledby={`skills-tab-${tab}`}>
|
|
438
|
+
{tab === "browse" ? (
|
|
439
|
+
<BrowsePanel
|
|
440
|
+
query={trimmed}
|
|
441
|
+
canSearch={canSearch}
|
|
442
|
+
results={results}
|
|
443
|
+
installedNames={installedNames}
|
|
444
|
+
isInstalling={(skill) =>
|
|
445
|
+
findRunningInstall(installs, project.id, skill.source, skill.skillId)?.job.id ??
|
|
446
|
+
null
|
|
447
|
+
}
|
|
448
|
+
onSuggest={(q) => {
|
|
449
|
+
setQuery(q);
|
|
450
|
+
searchRef.current?.focus();
|
|
451
|
+
}}
|
|
452
|
+
onInstall={setPending}
|
|
453
|
+
/>
|
|
454
|
+
) : (
|
|
455
|
+
<InstalledPanel
|
|
456
|
+
state={installed}
|
|
457
|
+
skills={installedFiltered}
|
|
458
|
+
total={installedAll.length}
|
|
459
|
+
query={trimmed}
|
|
460
|
+
onBrowse={() => {
|
|
461
|
+
setTab("browse");
|
|
462
|
+
searchRef.current?.focus();
|
|
463
|
+
}}
|
|
464
|
+
/>
|
|
465
|
+
)}
|
|
466
|
+
</div>
|
|
467
|
+
|
|
468
|
+
<InstallSkillDialog
|
|
469
|
+
skill={pending}
|
|
470
|
+
projectName={project.name}
|
|
471
|
+
starting={starting}
|
|
472
|
+
startError={startError}
|
|
473
|
+
onOpenChange={(open) => {
|
|
474
|
+
if (!open) {
|
|
475
|
+
setPending(null);
|
|
476
|
+
setStartError(null);
|
|
477
|
+
}
|
|
478
|
+
}}
|
|
479
|
+
onConfirm={(s) => void install(s)}
|
|
480
|
+
/>
|
|
481
|
+
</>
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function TabButton({
|
|
488
|
+
id,
|
|
489
|
+
current,
|
|
490
|
+
onSelect,
|
|
491
|
+
children,
|
|
492
|
+
}: {
|
|
493
|
+
id: Tab;
|
|
494
|
+
current: Tab;
|
|
495
|
+
onSelect: (tab: Tab) => void;
|
|
496
|
+
children: React.ReactNode;
|
|
497
|
+
}) {
|
|
498
|
+
const active = id === current;
|
|
499
|
+
return (
|
|
500
|
+
<button
|
|
501
|
+
type="button"
|
|
502
|
+
role="tab"
|
|
503
|
+
id={`skills-tab-${id}`}
|
|
504
|
+
aria-selected={active}
|
|
505
|
+
aria-controls={`skills-panel-${id}`}
|
|
506
|
+
tabIndex={active ? 0 : -1}
|
|
507
|
+
onClick={() => onSelect(id)}
|
|
508
|
+
onKeyDown={(e) => {
|
|
509
|
+
if (e.key === "ArrowRight" || e.key === "ArrowLeft") {
|
|
510
|
+
e.preventDefault();
|
|
511
|
+
const next: Tab = id === "browse" ? "installed" : "browse";
|
|
512
|
+
onSelect(next);
|
|
513
|
+
document.getElementById(`skills-tab-${next}`)?.focus();
|
|
514
|
+
}
|
|
515
|
+
}}
|
|
516
|
+
className={cn(
|
|
517
|
+
"-mb-px flex h-10 items-center gap-1.5 border-b-2 px-3 text-sm font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
518
|
+
active
|
|
519
|
+
? "border-foreground text-foreground"
|
|
520
|
+
: "border-transparent text-muted-foreground hover:text-foreground",
|
|
521
|
+
)}
|
|
522
|
+
>
|
|
523
|
+
{children}
|
|
524
|
+
</button>
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// ---------------------------------------------------------------------------
|
|
529
|
+
|
|
530
|
+
function BrowsePanel({
|
|
531
|
+
query,
|
|
532
|
+
canSearch,
|
|
533
|
+
results,
|
|
534
|
+
installedNames,
|
|
535
|
+
isInstalling,
|
|
536
|
+
onSuggest,
|
|
537
|
+
onInstall,
|
|
538
|
+
}: {
|
|
539
|
+
query: string;
|
|
540
|
+
canSearch: boolean;
|
|
541
|
+
results: Load<RegistrySkill[]>;
|
|
542
|
+
installedNames: ReadonlySet<string>;
|
|
543
|
+
/** Id job instalasi berjalan untuk skill ini (null bila tidak ada). */
|
|
544
|
+
isInstalling: (skill: RegistrySkill) => string | null;
|
|
545
|
+
onSuggest: (q: string) => void;
|
|
546
|
+
onInstall: (skill: RegistrySkill) => void;
|
|
547
|
+
}) {
|
|
548
|
+
if (!canSearch) {
|
|
549
|
+
return (
|
|
550
|
+
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed px-6 py-10 text-center">
|
|
551
|
+
<span className="flex size-11 items-center justify-center rounded-xl bg-muted text-muted-foreground">
|
|
552
|
+
<SearchIcon className="size-5" aria-hidden />
|
|
553
|
+
</span>
|
|
554
|
+
<div className="flex flex-col gap-1">
|
|
555
|
+
<p className="text-sm font-medium">
|
|
556
|
+
{query.length === 1 ? "Keep typing…" : "Search the skills.sh directory"}
|
|
557
|
+
</p>
|
|
558
|
+
<p className="text-sm text-muted-foreground">
|
|
559
|
+
Type at least 2 characters, or start with a popular topic.
|
|
560
|
+
</p>
|
|
561
|
+
</div>
|
|
562
|
+
<div className="flex flex-wrap justify-center gap-2">
|
|
563
|
+
{SEARCH_SUGGESTIONS.map((s) => (
|
|
564
|
+
<button
|
|
565
|
+
key={s}
|
|
566
|
+
type="button"
|
|
567
|
+
onClick={() => onSuggest(s)}
|
|
568
|
+
className="rounded-full border bg-card px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:border-foreground/30 hover:text-foreground"
|
|
569
|
+
>
|
|
570
|
+
{s}
|
|
571
|
+
</button>
|
|
572
|
+
))}
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
if (results.phase === "idle" || results.phase === "loading") return <SkillCardSkeleton />;
|
|
579
|
+
if (results.phase === "error") {
|
|
580
|
+
return (
|
|
581
|
+
<p
|
|
582
|
+
role="alert"
|
|
583
|
+
className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
|
584
|
+
>
|
|
585
|
+
{results.message}
|
|
586
|
+
</p>
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
if (results.data.length === 0) {
|
|
590
|
+
return (
|
|
591
|
+
<Empty className="border">
|
|
592
|
+
<EmptyHeader>
|
|
593
|
+
<EmptyMedia variant="icon">
|
|
594
|
+
<SearchIcon />
|
|
595
|
+
</EmptyMedia>
|
|
596
|
+
<EmptyTitle>No skills found</EmptyTitle>
|
|
597
|
+
<EmptyDescription>
|
|
598
|
+
Nothing on skills.sh matches “{query}”. Try a broader keyword.
|
|
599
|
+
</EmptyDescription>
|
|
600
|
+
</EmptyHeader>
|
|
601
|
+
</Empty>
|
|
602
|
+
);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return (
|
|
606
|
+
<section aria-label="Search results" className="flex flex-col gap-3">
|
|
607
|
+
<p className="text-xs text-muted-foreground" aria-live="polite">
|
|
608
|
+
{results.data.length} {results.data.length === 1 ? "result" : "results"} for “{query}” ·
|
|
609
|
+
sorted by installs
|
|
610
|
+
</p>
|
|
611
|
+
<SkillGrid label="Skills on skills.sh">
|
|
612
|
+
{results.data.map((skill) => {
|
|
613
|
+
const jobId = isInstalling(skill);
|
|
614
|
+
return (
|
|
615
|
+
<RegistrySkillCard
|
|
616
|
+
key={skill.id}
|
|
617
|
+
skill={skill}
|
|
618
|
+
installed={isInstalled(skill, installedNames)}
|
|
619
|
+
installing={jobId !== null}
|
|
620
|
+
onInstall={onInstall}
|
|
621
|
+
onShowProgress={() => {
|
|
622
|
+
if (jobId) revealInstall(jobId);
|
|
623
|
+
}}
|
|
624
|
+
/>
|
|
625
|
+
);
|
|
626
|
+
})}
|
|
627
|
+
</SkillGrid>
|
|
628
|
+
</section>
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function InstalledPanel({
|
|
633
|
+
state,
|
|
634
|
+
skills,
|
|
635
|
+
total,
|
|
636
|
+
query,
|
|
637
|
+
onBrowse,
|
|
638
|
+
}: {
|
|
639
|
+
state: Load<SkillInfo[]>;
|
|
640
|
+
skills: SkillInfo[];
|
|
641
|
+
total: number;
|
|
642
|
+
query: string;
|
|
643
|
+
onBrowse: () => void;
|
|
644
|
+
}) {
|
|
645
|
+
if (state.phase === "idle" || state.phase === "loading") return <SkillCardSkeleton />;
|
|
646
|
+
if (state.phase === "error") {
|
|
647
|
+
return (
|
|
648
|
+
<p
|
|
649
|
+
role="alert"
|
|
650
|
+
className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive"
|
|
651
|
+
>
|
|
652
|
+
{state.message}
|
|
653
|
+
</p>
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (total === 0) {
|
|
658
|
+
return (
|
|
659
|
+
<Empty className="border">
|
|
660
|
+
<EmptyHeader>
|
|
661
|
+
<EmptyMedia variant="icon">
|
|
662
|
+
<SparklesIcon />
|
|
663
|
+
</EmptyMedia>
|
|
664
|
+
<EmptyTitle>No skills installed yet</EmptyTitle>
|
|
665
|
+
<EmptyDescription>
|
|
666
|
+
Browse skills.sh to add your first skill to this project.
|
|
667
|
+
</EmptyDescription>
|
|
668
|
+
</EmptyHeader>
|
|
669
|
+
<EmptyContent>
|
|
670
|
+
<Button type="button" variant="outline" onClick={onBrowse}>
|
|
671
|
+
<SearchIcon data-icon="inline-start" />
|
|
672
|
+
Browse skills
|
|
673
|
+
</Button>
|
|
674
|
+
</EmptyContent>
|
|
675
|
+
</Empty>
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (skills.length === 0) {
|
|
680
|
+
return (
|
|
681
|
+
<p className="py-10 text-center text-sm text-muted-foreground">
|
|
682
|
+
No installed skills match “{query}”.
|
|
683
|
+
</p>
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return (
|
|
688
|
+
<section aria-label="Installed skills" className="flex flex-col gap-3">
|
|
689
|
+
{query !== "" && (
|
|
690
|
+
<p className="text-xs text-muted-foreground" aria-live="polite">
|
|
691
|
+
{skills.length} of {total} installed skills
|
|
692
|
+
</p>
|
|
693
|
+
)}
|
|
694
|
+
<SkillGrid label="Installed skills">
|
|
695
|
+
{skills.map((skill) => (
|
|
696
|
+
<InstalledSkillCard key={`${skill.source}:${skill.name}`} skill={skill} />
|
|
697
|
+
))}
|
|
698
|
+
</SkillGrid>
|
|
699
|
+
</section>
|
|
700
|
+
);
|
|
701
|
+
}
|