kcg-code 0.1.0 → 0.2.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 +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +876 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
|
@@ -79,8 +79,11 @@ export const MAX_FREE_TEXT_LENGTH = 10000;
|
|
|
79
79
|
export const TURN_IDLE_TIMEOUT_MS = 300_000;
|
|
80
80
|
export const SHUTDOWN_BUDGET_MS = 5000;
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Tipe CLI_Agent yang dikenal. `claude-code` hanya aktif bila
|
|
84
|
+
* `claudeServers` diinjeksi (lihat `SessionManagerOptions`).
|
|
85
|
+
*/
|
|
86
|
+
export const SUPPORTED_AGENT_TYPES: readonly AgentType[] = ["opencode", "claude-code"];
|
|
84
87
|
|
|
85
88
|
export interface CreateSessionRequest {
|
|
86
89
|
agentType: AgentType;
|
|
@@ -101,6 +104,11 @@ const SKILL_REFRESH_DELAY_MS = 1500;
|
|
|
101
104
|
export interface SessionManagerOptions {
|
|
102
105
|
store: SessionStore;
|
|
103
106
|
servers: OpenCodeServerManager;
|
|
107
|
+
/**
|
|
108
|
+
* Backend Claude Code (Agent SDK) dengan kontrak yang sama dengan
|
|
109
|
+
* `servers`. Tidak diisi -> Session `claude-code` ditolak.
|
|
110
|
+
*/
|
|
111
|
+
claudeServers?: OpenCodeServerManager;
|
|
104
112
|
/**
|
|
105
113
|
* Penyimpanan lampiran gambar (upload dari perangkat). Bila tidak diisi,
|
|
106
114
|
* fitur gambar dinonaktifkan — pesan dengan `images` ditolak.
|
|
@@ -199,28 +207,28 @@ export interface SessionManager {
|
|
|
199
207
|
*/
|
|
200
208
|
releaseProject(projectId: string): Promise<SimpleResult>;
|
|
201
209
|
/** Daftar model yang tersedia pada server headless milik Project. */
|
|
202
|
-
listModels(projectId: string): Promise<Result<ModelOption[]>>;
|
|
210
|
+
listModels(projectId: string, agentType?: AgentType): Promise<Result<ModelOption[]>>;
|
|
203
211
|
/**
|
|
204
212
|
* Daftar agent (mode) opencode milik Project — primary/all saja, siap
|
|
205
213
|
* ditampilkan di pemilih mode composer.
|
|
206
214
|
*/
|
|
207
|
-
listAgents(projectId: string): Promise<Result<AgentOption[]>>;
|
|
215
|
+
listAgents(projectId: string, agentType?: AgentType): Promise<Result<AgentOption[]>>;
|
|
208
216
|
/** Status MCP server yang terkonfigurasi untuk Project. */
|
|
209
|
-
listMcp(projectId: string): Promise<Result<McpServerInfo[]>>;
|
|
217
|
+
listMcp(projectId: string, agentType?: AgentType): Promise<Result<McpServerInfo[]>>;
|
|
210
218
|
/** Skill yang terdaftar untuk Project (global, folder Project, bawaan). */
|
|
211
|
-
listSkills(projectId: string): Promise<Result<SkillInfo[]>>;
|
|
219
|
+
listSkills(projectId: string, agentType?: AgentType): Promise<Result<SkillInfo[]>>;
|
|
212
220
|
/**
|
|
213
221
|
* Muat ulang daftar skill server headless Project setelah skill dipasang.
|
|
214
222
|
* Bila ada turn aktif di Project, refresh ditunda (`refreshed: false`) agar
|
|
215
223
|
* chat yang berjalan tidak terganggu — lalu dijalankan OTOMATIS begitu
|
|
216
224
|
* seluruh turn di Project selesai (`onSkillsRefreshed`). Server yang belum
|
|
217
|
-
* hidup tidak perlu di-refresh.
|
|
225
|
+
* hidup tidak perlu di-refresh. Claude Code selalu langsung `refreshed`.
|
|
218
226
|
*/
|
|
219
|
-
refreshSkills(projectId: string): Promise<Result<{ refreshed: boolean }>>;
|
|
227
|
+
refreshSkills(projectId: string, agentType?: AgentType): Promise<Result<{ refreshed: boolean }>>;
|
|
220
228
|
/** Project dengan refresh skill tertunda (menunggu turn selesai). */
|
|
221
229
|
hasPendingSkillRefresh(projectId: string): boolean;
|
|
222
230
|
/** Cari file project untuk autocomplete `@file` di composer. */
|
|
223
|
-
findFiles(projectId: string, query: string): Promise<Result<string[]>>;
|
|
231
|
+
findFiles(projectId: string, query: string, agentType?: AgentType): Promise<Result<string[]>>;
|
|
224
232
|
/**
|
|
225
233
|
* Konteks kerja Session untuk panel kanan: perubahan file di worktree,
|
|
226
234
|
* cabang git, dan task list (`todowrite`) agent.
|
|
@@ -259,6 +267,17 @@ export interface SessionManager {
|
|
|
259
267
|
export function createSessionManager(opts: SessionManagerOptions): SessionManager {
|
|
260
268
|
const store = opts.store;
|
|
261
269
|
const servers = opts.servers;
|
|
270
|
+
const claudeServers = opts.claudeServers;
|
|
271
|
+
|
|
272
|
+
/** Server manager backend milik tipe CLI_Agent. */
|
|
273
|
+
function serversFor(agentType: AgentType): OpenCodeServerManager {
|
|
274
|
+
return agentType === "claude-code" && claudeServers ? claudeServers : servers;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Kunci subscription/exit per backend + Project (keduanya bisa hidup). */
|
|
278
|
+
function backendKey(agentType: AgentType, projectId: string): string {
|
|
279
|
+
return agentType === "claude-code" ? `claude-code:${projectId}` : projectId;
|
|
280
|
+
}
|
|
262
281
|
const attachments = opts.attachments;
|
|
263
282
|
const now = opts.now ?? Date.now;
|
|
264
283
|
const shutdownBudgetMs = opts.shutdownBudgetMs ?? SHUTDOWN_BUDGET_MS;
|
|
@@ -309,11 +328,11 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
309
328
|
const ocToSession = new Map<string, string>();
|
|
310
329
|
/** Kebalikan `ocToSession`: id Session lokal -> seluruh ocSessionId miliknya. */
|
|
311
330
|
const sessionOcIds = new Map<string, Set<string>>();
|
|
312
|
-
/** Project yang event
|
|
331
|
+
/** Backend+Project yang event-nya sudah disubscribe (lihat `backendKey`). */
|
|
313
332
|
const activeSubscriptions = new Map<string, { active: boolean; unsubscribe: () => void }>();
|
|
314
333
|
/** Serialisasi kirim pesan per Session agar turn tidak tumpang tindih. */
|
|
315
334
|
const inflight = new Map<string, Promise<void>>();
|
|
316
|
-
/** Project yang sudah diproses saat server-nya keluar (hindari duplikasi). */
|
|
335
|
+
/** Backend+Project yang sudah diproses saat server-nya keluar (hindari duplikasi). */
|
|
317
336
|
const exitNotified = new Set<string>();
|
|
318
337
|
const onMessagePart = opts.onMessagePart;
|
|
319
338
|
const onMessagePartDelta = opts.onMessagePartDelta;
|
|
@@ -557,31 +576,34 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
557
576
|
async function ensureServerFor(
|
|
558
577
|
projectId: string,
|
|
559
578
|
projectPath: string,
|
|
560
|
-
opts: { refreshConfig?: boolean } = {},
|
|
579
|
+
opts: { refreshConfig?: boolean; agentType?: AgentType } = {},
|
|
561
580
|
): Promise<Result<{ client: OpenCodeClient }>> {
|
|
562
|
-
const
|
|
581
|
+
const agentType = opts.agentType ?? "opencode";
|
|
582
|
+
const backend = serversFor(agentType);
|
|
583
|
+
const key = backendKey(agentType, projectId);
|
|
584
|
+
const prevClient = backend.getServer(projectId)?.client;
|
|
563
585
|
const ensure = opts.refreshConfig
|
|
564
|
-
?
|
|
565
|
-
:
|
|
586
|
+
? backend.ensureFreshServer(projectId, projectPath)
|
|
587
|
+
: backend.ensureServer(projectId, projectPath);
|
|
566
588
|
const res = await ensure;
|
|
567
589
|
if (!res.ok) return { ok: false, error: res.error };
|
|
568
590
|
const handle = res.data;
|
|
569
591
|
// Server headless dapat diganti (crash lalu ensure ulang, resume, atau
|
|
570
592
|
// restart config). Bila client berbeda dari sebelumnya, subscription
|
|
571
593
|
// lama tidak lagi menempel — buang dan subscribe ulang ke instance baru.
|
|
572
|
-
const sub = activeSubscriptions.get(
|
|
594
|
+
const sub = activeSubscriptions.get(key);
|
|
573
595
|
if (prevClient && handle.client !== prevClient) {
|
|
574
596
|
sub?.unsubscribe();
|
|
575
|
-
activeSubscriptions.delete(
|
|
597
|
+
activeSubscriptions.delete(key);
|
|
576
598
|
}
|
|
577
|
-
if (!activeSubscriptions.get(
|
|
578
|
-
const prev = activeSubscriptions.get(
|
|
599
|
+
if (!activeSubscriptions.get(key)?.active) {
|
|
600
|
+
const prev = activeSubscriptions.get(key);
|
|
579
601
|
prev?.unsubscribe();
|
|
580
602
|
const unsubscribe = handle.client.subscribeEvents((ev) => handleEvent(projectId, ev));
|
|
581
|
-
activeSubscriptions.set(
|
|
603
|
+
activeSubscriptions.set(key, { active: true, unsubscribe });
|
|
582
604
|
}
|
|
583
605
|
// Server baru (atau hasil restart) — izinkan event exit berikutnya diproses.
|
|
584
|
-
exitNotified.delete(
|
|
606
|
+
exitNotified.delete(key);
|
|
585
607
|
return { ok: true, data: { client: handle.client } };
|
|
586
608
|
}
|
|
587
609
|
|
|
@@ -595,7 +617,10 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
595
617
|
|
|
596
618
|
async function createSession(req: CreateSessionRequest): Promise<CreateSessionResult> {
|
|
597
619
|
// (1) tipe CLI_Agent didukung (Requirement 1.3)
|
|
598
|
-
if (
|
|
620
|
+
if (
|
|
621
|
+
!SUPPORTED_AGENT_TYPES.includes(req.agentType) ||
|
|
622
|
+
(req.agentType === "claude-code" && !claudeServers)
|
|
623
|
+
) {
|
|
599
624
|
return { ok: false, error: "UNSUPPORTED_AGENT_TYPE" };
|
|
600
625
|
}
|
|
601
626
|
// (2) Project ada di Session_Store (Requirement 10.10)
|
|
@@ -612,7 +637,9 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
612
637
|
if (!dirExists) return { ok: false, error: "PROJECT_DIR_NOT_FOUND" };
|
|
613
638
|
|
|
614
639
|
// (4) pastikan server headless untuk Project (spawn bila perlu)
|
|
615
|
-
const serverRes = await ensureServerFor(project.id, project.path
|
|
640
|
+
const serverRes = await ensureServerFor(project.id, project.path, {
|
|
641
|
+
agentType: req.agentType,
|
|
642
|
+
});
|
|
616
643
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
617
644
|
const client = serverRes.data.client;
|
|
618
645
|
|
|
@@ -680,7 +707,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
680
707
|
if (cur.data.status !== "running") return { ok: false, error: "SESSION_NOT_RUNNING" };
|
|
681
708
|
// Abort turn berjalan (best-effort, tidak menunggu).
|
|
682
709
|
if (cur.data.ocSessionId) {
|
|
683
|
-
const handle =
|
|
710
|
+
const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
|
|
684
711
|
void handle?.client.abortSession(cur.data.ocSessionId);
|
|
685
712
|
}
|
|
686
713
|
// Simpan parts yang sudah terkumpul sebelum Session ditutup.
|
|
@@ -705,7 +732,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
705
732
|
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
706
733
|
// Abort turn berjalan (best-effort, tidak menunggu).
|
|
707
734
|
if (cur.data.status === "running" && cur.data.ocSessionId) {
|
|
708
|
-
const handle =
|
|
735
|
+
const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
|
|
709
736
|
void handle?.client.abortSession(cur.data.ocSessionId);
|
|
710
737
|
}
|
|
711
738
|
// Buang turn parsial (tidak disimpan) + broadcast turn tidak aktif.
|
|
@@ -734,7 +761,9 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
734
761
|
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
735
762
|
|
|
736
763
|
// (1) Pastikan server headless Project hidup (spawn ulang bila perlu).
|
|
737
|
-
const serverRes = await ensureServerFor(project.data.id, project.data.path
|
|
764
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
765
|
+
agentType: cur.data.agentType,
|
|
766
|
+
});
|
|
738
767
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
739
768
|
|
|
740
769
|
// (2) Verifikasi ocSessionId lama; bila tidak dikenal -> sesi remote baru.
|
|
@@ -781,13 +810,17 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
781
810
|
if (!res.ok) return res;
|
|
782
811
|
}
|
|
783
812
|
await servers.stopServer(projectId);
|
|
813
|
+
await claudeServers?.stopServer(projectId);
|
|
784
814
|
// Subscription SSE ikut dilepas — server-nya sudah tidak ada.
|
|
785
|
-
const
|
|
786
|
-
|
|
787
|
-
sub.
|
|
788
|
-
|
|
815
|
+
for (const agentType of SUPPORTED_AGENT_TYPES) {
|
|
816
|
+
const key = backendKey(agentType, projectId);
|
|
817
|
+
const sub = activeSubscriptions.get(key);
|
|
818
|
+
if (sub) {
|
|
819
|
+
sub.unsubscribe();
|
|
820
|
+
activeSubscriptions.delete(key);
|
|
821
|
+
}
|
|
822
|
+
exitNotified.delete(key);
|
|
789
823
|
}
|
|
790
|
-
exitNotified.delete(projectId);
|
|
791
824
|
return { ok: true };
|
|
792
825
|
}
|
|
793
826
|
|
|
@@ -799,12 +832,16 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
799
832
|
* (opencode serve tidak hot-reload config). Restart ditahan bila ada
|
|
800
833
|
* Session `running` agar turn yang sedang berjalan tidak terputus.
|
|
801
834
|
*/
|
|
802
|
-
async function listModels(
|
|
835
|
+
async function listModels(
|
|
836
|
+
projectId: string,
|
|
837
|
+
agentType: AgentType = "opencode",
|
|
838
|
+
): Promise<Result<ModelOption[]>> {
|
|
803
839
|
const project = store.getProjectById(projectId);
|
|
804
840
|
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
805
841
|
const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
|
|
806
842
|
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
807
843
|
refreshConfig: !hasRunning,
|
|
844
|
+
agentType,
|
|
808
845
|
});
|
|
809
846
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
810
847
|
return serverRes.data.client.listModels();
|
|
@@ -817,12 +854,16 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
817
854
|
* memicu restart server agar daftar ikut terbaru (dengan guard yang sama
|
|
818
855
|
* seperti `listModels`).
|
|
819
856
|
*/
|
|
820
|
-
async function listAgents(
|
|
857
|
+
async function listAgents(
|
|
858
|
+
projectId: string,
|
|
859
|
+
agentType: AgentType = "opencode",
|
|
860
|
+
): Promise<Result<AgentOption[]>> {
|
|
821
861
|
const project = store.getProjectById(projectId);
|
|
822
862
|
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
823
863
|
const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
|
|
824
864
|
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
825
865
|
refreshConfig: !hasRunning,
|
|
866
|
+
agentType,
|
|
826
867
|
});
|
|
827
868
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
828
869
|
return serverRes.data.client.listAgents();
|
|
@@ -833,12 +874,16 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
833
874
|
* daftar MCP dibaca dari config opencode di direktori Project. Config yang
|
|
834
875
|
* berubah memicu restart (guard sama dengan `listModels`).
|
|
835
876
|
*/
|
|
836
|
-
async function listMcp(
|
|
877
|
+
async function listMcp(
|
|
878
|
+
projectId: string,
|
|
879
|
+
agentType: AgentType = "opencode",
|
|
880
|
+
): Promise<Result<McpServerInfo[]>> {
|
|
837
881
|
const project = store.getProjectById(projectId);
|
|
838
882
|
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
839
883
|
const hasRunning = store.listProjectSessions(projectId).some((s) => s.status === "running");
|
|
840
884
|
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
841
885
|
refreshConfig: !hasRunning,
|
|
886
|
+
agentType,
|
|
842
887
|
});
|
|
843
888
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
844
889
|
return serverRes.data.client.listMcp();
|
|
@@ -848,10 +893,13 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
848
893
|
* Skill yang terdaftar untuk Project. Skill di dalam folder Project
|
|
849
894
|
* ditandai `project` sehingga UI dapat membedakannya dari skill global.
|
|
850
895
|
*/
|
|
851
|
-
async function listSkills(
|
|
896
|
+
async function listSkills(
|
|
897
|
+
projectId: string,
|
|
898
|
+
agentType: AgentType = "opencode",
|
|
899
|
+
): Promise<Result<SkillInfo[]>> {
|
|
852
900
|
const project = store.getProjectById(projectId);
|
|
853
901
|
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
854
|
-
const serverRes = await ensureServerFor(project.data.id, project.data.path);
|
|
902
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path, { agentType });
|
|
855
903
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
856
904
|
return serverRes.data.client.listSkills(project.data.path);
|
|
857
905
|
}
|
|
@@ -900,9 +948,15 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
900
948
|
onSkillsRefreshed?.(projectId);
|
|
901
949
|
}
|
|
902
950
|
|
|
903
|
-
async function refreshSkills(
|
|
951
|
+
async function refreshSkills(
|
|
952
|
+
projectId: string,
|
|
953
|
+
agentType: AgentType = "opencode",
|
|
954
|
+
): Promise<Result<{ refreshed: boolean }>> {
|
|
904
955
|
const project = store.getProjectById(projectId);
|
|
905
956
|
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
957
|
+
// Claude Code: daftar skill dibaca langsung dari disk dan proses `claude`
|
|
958
|
+
// memuat skill sendiri — tidak ada cache server yang perlu dibuang.
|
|
959
|
+
if (agentType === "claude-code") return { ok: true, data: { refreshed: true } };
|
|
906
960
|
const handle = servers.getServer(projectId);
|
|
907
961
|
// Server belum hidup -> skill terbaca saat server di-spawn nanti.
|
|
908
962
|
if (!handle) {
|
|
@@ -945,11 +999,13 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
945
999
|
const cur = store.getSession(sessionId);
|
|
946
1000
|
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
947
1001
|
const cwd = cur.data.cwd;
|
|
948
|
-
const handle =
|
|
1002
|
+
const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
|
|
1003
|
+
// Task list disimpan per session REMOTE (opencode `ses_...` / UUID Claude).
|
|
1004
|
+
const remoteId = cur.data.ocSessionId ?? sessionId;
|
|
949
1005
|
|
|
950
1006
|
const [git, todos, changes, mcp] = await Promise.all([
|
|
951
1007
|
readGitInfo(cwd).catch(() => EMPTY_GIT_INFO),
|
|
952
|
-
handle ? handle.client.listTodos(
|
|
1008
|
+
handle ? handle.client.listTodos(remoteId) : Promise.resolve(null),
|
|
953
1009
|
handle ? handle.client.fileStatus() : Promise.resolve(null),
|
|
954
1010
|
handle ? handle.client.listMcp() : Promise.resolve(null),
|
|
955
1011
|
]);
|
|
@@ -967,10 +1023,14 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
967
1023
|
};
|
|
968
1024
|
}
|
|
969
1025
|
|
|
970
|
-
async function findFiles(
|
|
1026
|
+
async function findFiles(
|
|
1027
|
+
projectId: string,
|
|
1028
|
+
query: string,
|
|
1029
|
+
agentType: AgentType = "opencode",
|
|
1030
|
+
): Promise<Result<string[]>> {
|
|
971
1031
|
const project = store.getProjectById(projectId);
|
|
972
1032
|
if (!project.ok) return { ok: false, error: "PROJECT_NOT_FOUND" };
|
|
973
|
-
const serverRes = await ensureServerFor(project.data.id, project.data.path);
|
|
1033
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path, { agentType });
|
|
974
1034
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
975
1035
|
return serverRes.data.client.findFiles(query);
|
|
976
1036
|
}
|
|
@@ -1017,7 +1077,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
1017
1077
|
turns.finish(sessionId);
|
|
1018
1078
|
// Bila masih running, abort turn remote agar model berhenti dieksekusi.
|
|
1019
1079
|
if (cur.data.status === "running" && cur.data.ocSessionId) {
|
|
1020
|
-
const runningHandle =
|
|
1080
|
+
const runningHandle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
|
|
1021
1081
|
void runningHandle?.client.abortSession(cur.data.ocSessionId);
|
|
1022
1082
|
}
|
|
1023
1083
|
unmapOcSessions(sessionId);
|
|
@@ -1028,11 +1088,13 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
1028
1088
|
// dan hanya akan dihentikan saat shutdown.
|
|
1029
1089
|
const ocSessionId = cur.data.ocSessionId;
|
|
1030
1090
|
if (ocSessionId) {
|
|
1031
|
-
let client =
|
|
1091
|
+
let client = serversFor(cur.data.agentType).getServer(cur.data.projectId)?.client;
|
|
1032
1092
|
if (!client) {
|
|
1033
1093
|
const project = store.getProjectById(cur.data.projectId);
|
|
1034
1094
|
if (!project.ok) return { ok: false, error: project.error };
|
|
1035
|
-
const serverRes = await ensureServerFor(project.data.id, project.data.path
|
|
1095
|
+
const serverRes = await ensureServerFor(project.data.id, project.data.path, {
|
|
1096
|
+
agentType: cur.data.agentType,
|
|
1097
|
+
});
|
|
1036
1098
|
if (!serverRes.ok) return { ok: false, error: serverRes.error };
|
|
1037
1099
|
client = serverRes.data.client;
|
|
1038
1100
|
}
|
|
@@ -1073,7 +1135,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
1073
1135
|
if (!ocSessionId) return { ok: false, error: "SESSION_NOT_ACTIVE" };
|
|
1074
1136
|
|
|
1075
1137
|
// Pastikan server masih hidup SEBELUM menyimpan echo user.
|
|
1076
|
-
const handle =
|
|
1138
|
+
const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
|
|
1077
1139
|
if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
|
|
1078
1140
|
|
|
1079
1141
|
// Gambar upload dipetakan ke info + path absolut. Kegagalan resolve
|
|
@@ -1202,7 +1264,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
1202
1264
|
|
|
1203
1265
|
const cur = store.getSession(sessionId);
|
|
1204
1266
|
if (!cur.ok) return { ok: false, error: "SESSION_NOT_FOUND" };
|
|
1205
|
-
const handle =
|
|
1267
|
+
const handle = serversFor(cur.data.agentType).getServer(cur.data.projectId);
|
|
1206
1268
|
if (!handle) return { ok: false, error: "SESSION_NOT_ACTIVE" };
|
|
1207
1269
|
|
|
1208
1270
|
// Kumpulkan request pending yang identik (grup kartu yang sama di UI):
|
|
@@ -1316,7 +1378,7 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
1316
1378
|
const running = store.listSessions().filter((s) => s.status === "running");
|
|
1317
1379
|
const results = running.map((s) => store.insertStatusHistory(s.id, "running", now()));
|
|
1318
1380
|
await raceWithTimeout(Promise.resolve(results), shutdownBudgetMs);
|
|
1319
|
-
await servers.stopAll();
|
|
1381
|
+
await Promise.all([servers.stopAll(), claudeServers?.stopAll()]);
|
|
1320
1382
|
}
|
|
1321
1383
|
|
|
1322
1384
|
/** Balapan dengan timer budget; timer selalu dibersihkan setelah selesai. */
|
|
@@ -1333,19 +1395,22 @@ export function createSessionManager(opts: SessionManagerOptions): SessionManage
|
|
|
1333
1395
|
}
|
|
1334
1396
|
|
|
1335
1397
|
// Saat server keluar tak terduga, tandai Session project tsb `crashed`.
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
exitNotified.
|
|
1398
|
+
const onBackendExit = (agentType: AgentType) => (projectId: string) => {
|
|
1399
|
+
const key = backendKey(agentType, projectId);
|
|
1400
|
+
if (exitNotified.has(key)) return;
|
|
1401
|
+
exitNotified.add(key);
|
|
1339
1402
|
const crashedAt = now();
|
|
1340
1403
|
for (const s of store.listSessions()) {
|
|
1341
|
-
if (s.projectId === projectId && s.status === "running") {
|
|
1404
|
+
if (s.projectId === projectId && s.agentType === agentType && s.status === "running") {
|
|
1342
1405
|
// Server mati di tengah turn: selamatkan parts yang sudah ter-stream.
|
|
1343
1406
|
turns.finish(s.id);
|
|
1344
1407
|
updateStatus(s.id, "crashed", crashedAt);
|
|
1345
1408
|
unmapOcSessions(s.id);
|
|
1346
1409
|
}
|
|
1347
1410
|
}
|
|
1348
|
-
}
|
|
1411
|
+
};
|
|
1412
|
+
servers.onServerExit(onBackendExit("opencode"));
|
|
1413
|
+
claudeServers?.onServerExit(onBackendExit("claude-code"));
|
|
1349
1414
|
|
|
1350
1415
|
return {
|
|
1351
1416
|
createSession,
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* setelah `FINISHED_TTL_MS` agar memori tidak tumbuh terus.
|
|
13
13
|
*/
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
|
+
import type { AgentType } from "../../types";
|
|
15
16
|
import type { Result } from "../result";
|
|
16
17
|
import type { InstallResult, SkillsRegistry } from "./skills-registry";
|
|
17
18
|
|
|
@@ -28,6 +29,8 @@ export interface InstallJob {
|
|
|
28
29
|
projectName: string;
|
|
29
30
|
source: string;
|
|
30
31
|
skillId: string;
|
|
32
|
+
/** Agent tujuan instalasi (`.agents/skills` dan/atau `.claude/skills`). */
|
|
33
|
+
agentTypes: AgentType[];
|
|
31
34
|
status: InstallJobStatus;
|
|
32
35
|
/** Kode error domain bila `failed`. */
|
|
33
36
|
error: string | null;
|
|
@@ -58,6 +61,8 @@ export interface StartInstallInput {
|
|
|
58
61
|
projectPath: string;
|
|
59
62
|
source: string;
|
|
60
63
|
skillId: string;
|
|
64
|
+
/** Default `["opencode"]`. */
|
|
65
|
+
agentTypes?: readonly AgentType[];
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
export interface SkillInstallJobs {
|
|
@@ -84,8 +89,11 @@ export interface SkillInstallJobs {
|
|
|
84
89
|
|
|
85
90
|
export interface SkillInstallJobsOptions {
|
|
86
91
|
registry: SkillsRegistry;
|
|
87
|
-
/** Refresh skill
|
|
88
|
-
refreshSkills: (
|
|
92
|
+
/** Refresh daftar skill agent Project setelah terpasang. */
|
|
93
|
+
refreshSkills: (
|
|
94
|
+
projectId: string,
|
|
95
|
+
agentType: AgentType,
|
|
96
|
+
) => Promise<Result<{ refreshed: boolean }>>;
|
|
89
97
|
now?: () => number;
|
|
90
98
|
/** Validasi source/skill sebelum job dibuat. */
|
|
91
99
|
validate: (source: string, skillId: string) => boolean;
|
|
@@ -138,6 +146,7 @@ export function createSkillInstallJobs(opts: SkillInstallJobsOptions): SkillInst
|
|
|
138
146
|
job.skillId,
|
|
139
147
|
(l) => push(st, l),
|
|
140
148
|
signal,
|
|
149
|
+
job.agentTypes,
|
|
141
150
|
);
|
|
142
151
|
} catch (e) {
|
|
143
152
|
res = { ok: false, error: "SKILL_INSTALL_FAILED" };
|
|
@@ -154,12 +163,15 @@ export function createSkillInstallJobs(opts: SkillInstallJobsOptions): SkillInst
|
|
|
154
163
|
return;
|
|
155
164
|
}
|
|
156
165
|
push(st, "Reloading the agent's skill list…");
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
166
|
+
// Refresh tiap agent tujuan; "aktif" hanya bila semuanya ter-refresh.
|
|
167
|
+
let refreshed = true;
|
|
168
|
+
for (const agentType of job.agentTypes) {
|
|
169
|
+
try {
|
|
170
|
+
const r = await opts.refreshSkills(job.projectId, agentType);
|
|
171
|
+
if (!r.ok || !r.data.refreshed) refreshed = false;
|
|
172
|
+
} catch {
|
|
173
|
+
refreshed = false;
|
|
174
|
+
}
|
|
163
175
|
}
|
|
164
176
|
push(
|
|
165
177
|
st,
|
|
@@ -186,6 +198,7 @@ export function createSkillInstallJobs(opts: SkillInstallJobsOptions): SkillInst
|
|
|
186
198
|
projectName: input.projectName,
|
|
187
199
|
source: input.source,
|
|
188
200
|
skillId: input.skillId,
|
|
201
|
+
agentTypes: [...(input.agentTypes ?? ["opencode"])],
|
|
189
202
|
status: "running",
|
|
190
203
|
error: null,
|
|
191
204
|
refreshed: null,
|
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
* - Audit keamanan: `GET https://skills.sh/api/v1/skills/audit/{id}` (saat
|
|
8
8
|
* ini dapat diakses tanpa token; 404 = belum pernah diaudit).
|
|
9
9
|
* - Instalasi: `bunx skills@<ver> add <source> --skill <name> --agent
|
|
10
|
-
* opencode -y --json` di direktori Project
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* <opencode> <claude-code> -y --json` di direktori Project (satu atau
|
|
11
|
+
* kedua agent sekaligus). CLI memasang ke
|
|
12
|
+
* `.agents/skills/<name>/` (dibaca opencode; server headless Project perlu
|
|
13
|
+
* `POST /instance/dispose` agar daftar skill ter-refresh); untuk Claude
|
|
14
|
+
* Code ditambah symlink `.claude/skills/<name>/`.
|
|
13
15
|
*
|
|
14
16
|
* Argumen CLI dibangun sebagai array (tanpa shell) dan `source`/`skill`
|
|
15
17
|
* divalidasi ketat sebelum dipakai, sehingga tidak ada celah injeksi.
|
|
16
18
|
*/
|
|
19
|
+
import type { AgentType } from "../../types";
|
|
17
20
|
import type { Result } from "../result";
|
|
18
21
|
|
|
19
22
|
/** Versi CLI `skills` yang dipin (hindari perubahan perilaku mendadak). */
|
|
@@ -154,7 +157,11 @@ export function parseAuditResponse(payload: unknown): SkillAudit {
|
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
/** Argumen `bunx` untuk instalasi (array — tanpa shell). */
|
|
157
|
-
export function buildInstallArgs(
|
|
160
|
+
export function buildInstallArgs(
|
|
161
|
+
source: string,
|
|
162
|
+
skillId: string,
|
|
163
|
+
agentTypes: readonly AgentType[] = ["opencode"],
|
|
164
|
+
): string[] {
|
|
158
165
|
return [
|
|
159
166
|
"bunx",
|
|
160
167
|
`skills@${SKILLS_CLI_VERSION}`,
|
|
@@ -162,8 +169,10 @@ export function buildInstallArgs(source: string, skillId: string): string[] {
|
|
|
162
169
|
source,
|
|
163
170
|
"--skill",
|
|
164
171
|
skillId,
|
|
172
|
+
// Id agent CLI `skills` kebetulan sama dengan `AgentType`; beberapa
|
|
173
|
+
// nilai setelah satu `--agent` diterima CLI.
|
|
165
174
|
"--agent",
|
|
166
|
-
|
|
175
|
+
...agentTypes,
|
|
167
176
|
"-y",
|
|
168
177
|
"--json",
|
|
169
178
|
];
|
|
@@ -235,6 +244,8 @@ export interface SkillsRegistry {
|
|
|
235
244
|
skillId: string,
|
|
236
245
|
onLine?: (line: string) => void,
|
|
237
246
|
signal?: AbortSignal,
|
|
247
|
+
/** Agent tujuan (`--agent`); default `["opencode"]`. */
|
|
248
|
+
agentTypes?: readonly AgentType[],
|
|
238
249
|
): Promise<Result<InstallResult>>;
|
|
239
250
|
}
|
|
240
251
|
|
|
@@ -418,6 +429,7 @@ export function createSkillsRegistry(opts: SkillsRegistryOptions = {}): SkillsRe
|
|
|
418
429
|
skillId: string,
|
|
419
430
|
onLine?: (line: string) => void,
|
|
420
431
|
signal?: AbortSignal,
|
|
432
|
+
agentTypes: readonly AgentType[] = ["opencode"],
|
|
421
433
|
): Promise<Result<InstallResult>> {
|
|
422
434
|
if (!isValidSource(source) || !isValidSkillId(skillId)) {
|
|
423
435
|
return { ok: false, error: "INVALID_SKILL_ID" };
|
|
@@ -435,7 +447,7 @@ export function createSkillsRegistry(opts: SkillsRegistryOptions = {}): SkillsRe
|
|
|
435
447
|
onLine(line);
|
|
436
448
|
};
|
|
437
449
|
try {
|
|
438
|
-
const args = buildInstallArgs(source, skillId);
|
|
450
|
+
const args = buildInstallArgs(source, skillId, agentTypes);
|
|
439
451
|
emit(`$ ${args.join(" ")}`);
|
|
440
452
|
const res = await spawn(args, projectPath, INSTALL_TIMEOUT_MS, emit, signal);
|
|
441
453
|
if (signal?.aborted) {
|
package/src/types/message.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface MessagePart {
|
|
|
34
34
|
* `input` (argumen tool, mis. `filePath` untuk read/edit), dan `output`
|
|
35
35
|
* (hasil). Dipakai renderer untuk menampilkan target tiap tool call.
|
|
36
36
|
*/
|
|
37
|
-
state?: { input?: Record<string, unknown>; output?: unknown };
|
|
37
|
+
state?: { input?: Record<string, unknown>; output?: unknown; [k: string]: unknown };
|
|
38
38
|
/**
|
|
39
39
|
* Timing asli dari opencode (ReasoningPart, TextPart, ToolState).
|
|
40
40
|
* `start`/`end` adalah Unix ms. Hadir di part yang sudah selesai di-stream;
|
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Satu seksi di panel samping halaman Project (Instructions / MCP / Skills).
|
|
3
|
-
*
|
|
4
|
-
* Tiap seksi = satu kartu (gaya boxed list seperti Settings): header di dalam
|
|
5
|
-
* kartu (ikon, judul, ringkasan, aksi, chevron) dan isi di bawah garis
|
|
6
|
-
* pemisah. Status buka/tutup diingat per seksi di `localStorage`.
|
|
7
|
-
*
|
|
8
|
-
* Isi kartu tidak diberi padding otomatis: `PanelList` (baris berdivider),
|
|
9
|
-
* `PanelHint`, dan `PanelBody` mengatur jaraknya sendiri agar baris daftar
|
|
10
|
-
* bisa rata tepi ke tepi.
|
|
11
|
-
*/
|
|
12
|
-
import { ChevronDownIcon } from "lucide-react";
|
|
13
|
-
import type { ReactNode } from "react";
|
|
14
|
-
import { useEffect, useState } from "react";
|
|
15
|
-
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
16
|
-
import { cn } from "@/lib/utils";
|
|
17
|
-
|
|
18
|
-
const STORAGE_PREFIX = "kcg.projectPanel.";
|
|
19
|
-
|
|
20
|
-
/** Buka/tutup seksi yang diingat di localStorage (default terbuka). */
|
|
21
|
-
function usePersistedOpen(id: string): [boolean, (open: boolean) => void] {
|
|
22
|
-
const [open, setOpen] = useState(() => {
|
|
23
|
-
try {
|
|
24
|
-
return localStorage.getItem(STORAGE_PREFIX + id) !== "0";
|
|
25
|
-
} catch {
|
|
26
|
-
return true;
|
|
27
|
-
}
|
|
28
|
-
});
|
|
29
|
-
useEffect(() => {
|
|
30
|
-
try {
|
|
31
|
-
localStorage.setItem(STORAGE_PREFIX + id, open ? "1" : "0");
|
|
32
|
-
} catch {
|
|
33
|
-
// Storage tidak tersedia (mode privat) — cukup state di memori.
|
|
34
|
-
}
|
|
35
|
-
}, [id, open]);
|
|
36
|
-
return [open, setOpen];
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export interface PanelSectionProps {
|
|
40
|
-
/** Id stabil untuk mengingat status lipat (mis. "mcp"). */
|
|
41
|
-
id: string;
|
|
42
|
-
title: string;
|
|
43
|
-
/** Ikon seksi di kiri judul. */
|
|
44
|
-
icon: ReactNode;
|
|
45
|
-
/** Hitungan / ringkasan kecil di bawah judul. */
|
|
46
|
-
meta?: ReactNode;
|
|
47
|
-
/** Tombol aksi kecil di kanan header (tidak ikut melipat). */
|
|
48
|
-
action?: ReactNode;
|
|
49
|
-
children: ReactNode;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function PanelSection({ id, title, icon, meta, action, children }: PanelSectionProps) {
|
|
53
|
-
const [open, setOpen] = usePersistedOpen(id);
|
|
54
|
-
return (
|
|
55
|
-
<Collapsible open={open} onOpenChange={setOpen} asChild>
|
|
56
|
-
<section
|
|
57
|
-
aria-label={title}
|
|
58
|
-
className="overflow-hidden rounded-xl border bg-card text-card-foreground shadow-xs"
|
|
59
|
-
>
|
|
60
|
-
<div className="flex items-center gap-1 pr-2">
|
|
61
|
-
<CollapsibleTrigger className="group flex min-h-14 min-w-0 flex-1 items-center gap-3 py-2.5 pl-3.5 text-left outline-none transition-colors hover:bg-muted/40 focus-visible:bg-muted/40 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset">
|
|
62
|
-
<span
|
|
63
|
-
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground [&_svg]:size-4"
|
|
64
|
-
aria-hidden
|
|
65
|
-
>
|
|
66
|
-
{icon}
|
|
67
|
-
</span>
|
|
68
|
-
<span className="flex min-w-0 flex-1 flex-col">
|
|
69
|
-
<span className="text-sm font-semibold leading-5 tracking-tight">{title}</span>
|
|
70
|
-
{meta !== undefined && (
|
|
71
|
-
<span className="truncate text-xs tabular-nums text-muted-foreground">{meta}</span>
|
|
72
|
-
)}
|
|
73
|
-
</span>
|
|
74
|
-
<ChevronDownIcon
|
|
75
|
-
className="mr-1.5 size-4 shrink-0 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180"
|
|
76
|
-
aria-hidden
|
|
77
|
-
/>
|
|
78
|
-
</CollapsibleTrigger>
|
|
79
|
-
{action && <div className="flex shrink-0 items-center">{action}</div>}
|
|
80
|
-
</div>
|
|
81
|
-
<CollapsibleContent className="overflow-hidden data-[state=closed]:animate-collapsible-up data-[state=open]:animate-collapsible-down">
|
|
82
|
-
<div className="border-t">{children}</div>
|
|
83
|
-
</CollapsibleContent>
|
|
84
|
-
</section>
|
|
85
|
-
</Collapsible>
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Isi kartu dengan padding standar (form, pencarian, teks). */
|
|
90
|
-
export function PanelBody({ className, children }: { className?: string; children: ReactNode }) {
|
|
91
|
-
return <div className={cn("px-3.5 py-3", className)}>{children}</div>;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** Baris-baris berdivider, rata tepi ke tepi di dalam kartu. */
|
|
95
|
-
export function PanelList({ className, children }: { className?: string; children: ReactNode }) {
|
|
96
|
-
return <ul className={cn("flex flex-col divide-y", className)}>{children}</ul>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Keterangan saat daftar kosong — satu gaya untuk semua seksi. */
|
|
100
|
-
export function PanelHint({ children }: { children: ReactNode }) {
|
|
101
|
-
return <p className="px-3.5 py-3.5 text-xs leading-relaxed text-muted-foreground">{children}</p>;
|
|
102
|
-
}
|