kcg-code 0.1.0 → 0.2.1

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.
Files changed (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. package/src/components/projects/PanelSection.tsx +0 -102
@@ -11,6 +11,7 @@ import {
11
11
  buildProjectOverviews,
12
12
  filterProjectOverviews,
13
13
  formatRelativeTime,
14
+ RECENT_SESSION_LIMIT,
14
15
  totalRunningSessions,
15
16
  } from "../project-overview";
16
17
 
@@ -63,6 +64,20 @@ describe("buildProjectOverviews", () => {
63
64
  expect(only?.lastActivityAt).toBe(7_000);
64
65
  });
65
66
 
67
+ test("recentSessions: maksimal RECENT_SESSION_LIMIT, terbaru di depan", () => {
68
+ const sessions = [
69
+ session("s1", "p1", "stopped", 1_000),
70
+ session("s2", "p1", "running", 4_000),
71
+ session("s3", "p1", "stopped", 2_000),
72
+ session("s4", "p1", "stopped", 3_000),
73
+ ];
74
+ const [only] = buildProjectOverviews([project("p1", "alpha", 0)], sessions);
75
+ expect(only?.recentSessions.map((s) => s.id)).toEqual(
76
+ ["s2", "s4", "s3"].slice(0, RECENT_SESSION_LIMIT),
77
+ );
78
+ expect(only?.lastSession?.id).toBe("s2");
79
+ });
80
+
66
81
  test("urutan: aktivitas terbaru di atas", () => {
67
82
  const projects = [
68
83
  project("p1", "alpha", 1_000),
@@ -107,8 +107,8 @@ describe("rute skills", () => {
107
107
  describe("rute settings", () => {
108
108
  test("settingsPath & parseRoute", () => {
109
109
  expect(settingsPath()).toBe("/settings");
110
- expect(parseRoute("/settings")).toEqual({ name: "settings", section: "profile" });
111
- expect(parseRoute("/settings/")).toEqual({ name: "settings", section: "profile" });
110
+ expect(parseRoute("/settings")).toEqual({ name: "settings", section: "profile", root: true });
111
+ expect(parseRoute("/settings/")).toEqual({ name: "settings", section: "profile", root: true });
112
112
  expect(parseRoute("/settings/x")).toEqual({ name: "not-found" });
113
113
  expect(parseRoute("/settings/security/x")).toEqual({ name: "not-found" });
114
114
  });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Unit test selection mode daftar Session (src/lib/session-selection.ts).
3
+ */
4
+ import { describe, expect, test } from "bun:test";
5
+ import {
6
+ describeSelection,
7
+ pluralSessions,
8
+ pruneSelection,
9
+ toggleSelection,
10
+ } from "../session-selection";
11
+
12
+ describe("toggleSelection", () => {
13
+ test("menambah lalu membuang id tanpa memutasi input", () => {
14
+ const empty = new Set<string>();
15
+ const one = toggleSelection(empty, "a");
16
+ expect([...one]).toEqual(["a"]);
17
+ expect(empty.size).toBe(0);
18
+ expect([...toggleSelection(one, "a")]).toEqual([]);
19
+ });
20
+ });
21
+
22
+ describe("pruneSelection", () => {
23
+ test("membuang id yang Session-nya sudah tidak ada", () => {
24
+ const selected = new Set(["a", "b", "c"]);
25
+ expect([...pruneSelection(selected, [{ id: "a" }, { id: "c" }])]).toEqual(["a", "c"]);
26
+ });
27
+
28
+ test("tidak ada yang dibuang -> Set yang sama (hindari render ulang)", () => {
29
+ const selected = new Set(["a"]);
30
+ expect(pruneSelection(selected, [{ id: "a" }, { id: "b" }])).toBe(selected);
31
+ });
32
+ });
33
+
34
+ describe("describeSelection / pluralSessions", () => {
35
+ test("teks ringkas", () => {
36
+ expect(describeSelection(0, 5)).toBe("Tap sessions to select them");
37
+ expect(describeSelection(3, 7)).toBe("3 of 7 selected");
38
+ expect(pluralSessions(1)).toBe("1 session");
39
+ expect(pluralSessions(4)).toBe("4 sessions");
40
+ });
41
+ });
@@ -17,6 +17,7 @@ function job(over: Partial<InstallJob> = {}): InstallJob {
17
17
  projectName: "Proj",
18
18
  source: "a/b",
19
19
  skillId: "c",
20
+ agentTypes: ["opencode"],
20
21
  status: "running",
21
22
  error: null,
22
23
  refreshed: null,
@@ -8,8 +8,8 @@ import type { Project } from "@/types";
8
8
  import {
9
9
  formatInstalls,
10
10
  githubOwner,
11
- installedProjectSkillNames,
12
- isInstalled,
11
+ installedAgentsFor,
12
+ mergeInstalledSkills,
13
13
  resolveSkillsProject,
14
14
  visibleInstalledSkills,
15
15
  } from "../skills-catalog";
@@ -45,12 +45,40 @@ describe("deteksi terpasang", () => {
45
45
  url: "",
46
46
  });
47
47
 
48
- test("hanya skill sumber project yang dihitung, case-insensitive", () => {
49
- const names = installedProjectSkillNames(skills);
50
- expect([...names]).toEqual(["find-skills"]);
51
- expect(isInstalled(reg("FIND-SKILLS"), names)).toBe(true);
52
- expect(isInstalled(reg("other", "find-skills"), names)).toBe(true);
53
- expect(isInstalled(reg("global-one"), names)).toBe(false);
48
+ test("mergeInstalledSkills: gabung per source + nama, catat agent", () => {
49
+ const merged = mergeInstalledSkills({
50
+ opencode: skills,
51
+ "claude-code": [
52
+ {
53
+ name: "Find-Skills",
54
+ description: "From CC",
55
+ location: "/p/.claude/x",
56
+ source: "project",
57
+ },
58
+ { name: "cc-only", description: null, location: "/p/.claude/y", source: "project" },
59
+ ],
60
+ });
61
+ expect(merged.map((s) => [s.name, s.source, s.agents])).toEqual([
62
+ ["builtin", "builtin", ["opencode"]],
63
+ ["cc-only", "project", ["claude-code"]],
64
+ ["find-skills", "project", ["opencode", "claude-code"]],
65
+ ["Global-One", "global", ["opencode"]],
66
+ ]);
67
+ // Deskripsi kosong diisi dari agent lain.
68
+ expect(merged.find((s) => s.name === "find-skills")?.description).toBe("From CC");
69
+ });
70
+
71
+ test("installedAgentsFor: hanya sumber project, case-insensitive", () => {
72
+ const merged = mergeInstalledSkills({
73
+ opencode: skills,
74
+ "claude-code": [skills[0] as SkillInfo],
75
+ });
76
+ expect(installedAgentsFor(reg("FIND-SKILLS"), merged)).toEqual(["opencode", "claude-code"]);
77
+ expect(installedAgentsFor(reg("other", "find-skills"), merged)).toEqual([
78
+ "opencode",
79
+ "claude-code",
80
+ ]);
81
+ expect(installedAgentsFor(reg("global-one"), merged)).toEqual([]);
54
82
  });
55
83
  });
56
84
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Tipe CLI_Agent yang bisa dipilih di UI (backend Session).
3
+ *
4
+ * - `opencode` : server headless `opencode serve`.
5
+ * - `claude-code` : Claude Code lewat Claude Agent SDK (ikut settings user).
6
+ */
7
+ import type { AgentType } from "@/types";
8
+
9
+ export const AGENT_TYPES: readonly AgentType[] = ["opencode", "claude-code"];
10
+
11
+ export const AGENT_TYPE_LABELS: Record<AgentType, string> = {
12
+ opencode: "OpenCode",
13
+ "claude-code": "Claude Code",
14
+ };
15
+
16
+ /**
17
+ * Agent terakhir yang dipakai untuk membuat Session (composer homepage &
18
+ * dialog New Session), diingat per perangkat. Kunci lama dipertahankan agar
19
+ * pilihan yang sudah tersimpan tetap terbaca.
20
+ */
21
+ const PREFERRED_AGENT_KEY = "kcg-home-agent-type";
22
+
23
+ /** Agent pilihan terakhir; default `opencode` (juga saat storage tak tersedia). */
24
+ export function readPreferredAgentType(): AgentType {
25
+ try {
26
+ const saved =
27
+ typeof localStorage === "undefined" ? null : localStorage.getItem(PREFERRED_AGENT_KEY);
28
+ return saved === "claude-code" ? "claude-code" : "opencode";
29
+ } catch {
30
+ return "opencode";
31
+ }
32
+ }
33
+
34
+ /** Simpan agent pilihan; diam saja bila storage tak tersedia (mode privat). */
35
+ export function storePreferredAgentType(agentType: AgentType): void {
36
+ try {
37
+ localStorage.setItem(PREFERRED_AGENT_KEY, agentType);
38
+ } catch {
39
+ // Pilihan tetap berlaku untuk kunjungan ini.
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Query string `?agent=` untuk daftar model/agent Project. opencode tanpa
45
+ * query (default server) agar URL lama tidak berubah.
46
+ */
47
+ export function agentQuery(agentType: AgentType): string {
48
+ return agentType === "claude-code" ? "?agent=claude-code" : "";
49
+ }
@@ -2,12 +2,14 @@
2
2
  * Helper tampilan panel samping halaman Project (MCP & skill) — logika murni
3
3
  * tanpa DOM, dipisah agar dapat diuji `bun test`.
4
4
  */
5
+ import { AGENT_TYPES } from "@/lib/agent-types";
5
6
  import type {
6
7
  McpServerInfo,
7
8
  McpStatus,
8
9
  SkillInfo,
9
10
  SkillSource,
10
11
  } from "@/server/services/opencode-client";
12
+ import type { AgentType } from "@/types";
11
13
 
12
14
  /** Label status MCP untuk UI. */
13
15
  export const MCP_STATUS_LABEL: Record<McpStatus, string> = {
@@ -36,6 +38,66 @@ export function describeMcpSummary(servers: readonly McpServerInfo[]): string {
36
38
  return `${connected} of ${servers.length} connected`;
37
39
  }
38
40
 
41
+ /** Urutan perhatian status MCP: yang bermasalah lebih dulu. */
42
+ const MCP_ATTENTION: Record<McpStatus, number> = {
43
+ failed: 0,
44
+ needs_auth: 1,
45
+ needs_client_registration: 1,
46
+ connected: 2,
47
+ unknown: 3,
48
+ disabled: 4,
49
+ };
50
+
51
+ /** Satu MCP server (per nama) beserta statusnya di tiap agent yang memakainya. */
52
+ export interface McpServerEntry {
53
+ name: string;
54
+ /** Status per agent; hanya agent yang mengonfigurasi server ini. */
55
+ byAgent: Partial<Record<AgentType, McpServerInfo>>;
56
+ /** Agent pemakai, urut `AGENT_TYPES`. */
57
+ agents: AgentType[];
58
+ /** Status paling butuh perhatian di antara agent (untuk ikon & urutan). */
59
+ status: McpStatus;
60
+ }
61
+
62
+ /**
63
+ * Gabungkan daftar MCP per agent menjadi satu daftar per nama server,
64
+ * terurut: bermasalah dulu, lalu nama. Murni — diuji.
65
+ */
66
+ export function mergeMcpServers(
67
+ byAgent: Partial<Record<AgentType, readonly McpServerInfo[]>>,
68
+ ): McpServerEntry[] {
69
+ const merged = new Map<string, McpServerEntry>();
70
+ for (const agent of AGENT_TYPES) {
71
+ for (const server of byAgent[agent] ?? []) {
72
+ const entry = merged.get(server.name) ?? {
73
+ name: server.name,
74
+ byAgent: {},
75
+ agents: [],
76
+ status: server.status,
77
+ };
78
+ entry.byAgent[agent] = server;
79
+ entry.agents.push(agent);
80
+ if (MCP_ATTENTION[server.status] < MCP_ATTENTION[entry.status]) entry.status = server.status;
81
+ merged.set(server.name, entry);
82
+ }
83
+ }
84
+ return [...merged.values()].sort(
85
+ (a, b) => MCP_ATTENTION[a.status] - MCP_ATTENTION[b.status] || a.name.localeCompare(b.name),
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Ringkasan header seksi MCP gabungan: berapa server yang tersambung di
91
+ * setidaknya satu agent ("1 of 2 connected").
92
+ */
93
+ export function describeMcpEntries(entries: readonly McpServerEntry[]): string {
94
+ if (entries.length === 0) return "None configured";
95
+ const connected = entries.filter((e) =>
96
+ Object.values(e.byAgent).some((s) => s?.status === "connected"),
97
+ ).length;
98
+ return `${connected} of ${entries.length} connected`;
99
+ }
100
+
39
101
  /** Label asal skill. */
40
102
  export const SKILL_SOURCE_LABEL: Record<SkillSource, string> = {
41
103
  project: "This project",
@@ -43,17 +105,17 @@ export const SKILL_SOURCE_LABEL: Record<SkillSource, string> = {
43
105
  builtin: "Built-in",
44
106
  };
45
107
 
46
- export interface SkillGroup {
108
+ export interface SkillGroup<T extends SkillInfo = SkillInfo> {
47
109
  source: SkillSource;
48
110
  label: string;
49
- skills: SkillInfo[];
111
+ skills: T[];
50
112
  }
51
113
 
52
114
  /**
53
115
  * Kelompokkan skill per asal: Project dulu (paling relevan), lalu global,
54
116
  * lalu bawaan. Kelompok kosong dibuang; urutan dalam kelompok dipertahankan.
55
117
  */
56
- export function groupSkills(skills: readonly SkillInfo[]): SkillGroup[] {
118
+ export function groupSkills<T extends SkillInfo>(skills: readonly T[]): SkillGroup<T>[] {
57
119
  const order: SkillSource[] = ["project", "global", "builtin"];
58
120
  return order
59
121
  .map((source) => ({
@@ -65,7 +127,7 @@ export function groupSkills(skills: readonly SkillInfo[]): SkillGroup[] {
65
127
  }
66
128
 
67
129
  /** Filter skill berdasarkan kata kunci (nama atau deskripsi, case-insensitive). */
68
- export function filterSkills(skills: readonly SkillInfo[], query: string): SkillInfo[] {
130
+ export function filterSkills<T extends SkillInfo>(skills: readonly T[], query: string): T[] {
69
131
  const q = query.trim().toLowerCase();
70
132
  if (q === "") return [...skills];
71
133
  return skills.filter(
@@ -22,6 +22,11 @@ export interface ProjectOverview {
22
22
  * "Lanjutkan"; `null` bila Project belum punya Session.
23
23
  */
24
24
  lastSession: Session | null;
25
+ /**
26
+ * Hingga `RECENT_SESSION_LIMIT` Session terbaru (`updatedAt` menurun) —
27
+ * daftar "lanjutkan" di bawah composer homepage.
28
+ */
29
+ recentSessions: Session[];
25
30
  /**
26
31
  * Waktu aktivitas terakhir: `updatedAt` Session terbaru, atau `createdAt`
27
32
  * Project bila belum ada Session. Dipakai untuk urutan daftar.
@@ -29,6 +34,9 @@ export interface ProjectOverview {
29
34
  lastActivityAt: number;
30
35
  }
31
36
 
37
+ /** Jumlah Session terbaru yang dibawa tiap `ProjectOverview`. */
38
+ export const RECENT_SESSION_LIMIT = 3;
39
+
32
40
  /**
33
41
  * Gabungkan Project dengan Session-nya, terurut dari aktivitas terbaru.
34
42
  * Session dengan `projectId` yang tidak dikenal diabaikan.
@@ -46,19 +54,17 @@ export function buildProjectOverviews(
46
54
 
47
55
  const overviews = projects.map((project) => {
48
56
  const own = byProject.get(project.id) ?? [];
49
- let lastSession: Session | null = null;
50
- let runningCount = 0;
51
- for (const session of own) {
52
- if (session.status === "running") runningCount += 1;
53
- if (lastSession === null || session.updatedAt > lastSession.updatedAt) {
54
- lastSession = session;
55
- }
56
- }
57
+ const runningCount = own.filter((s) => s.status === "running").length;
58
+ const recentSessions = [...own]
59
+ .sort((a, b) => b.updatedAt - a.updatedAt)
60
+ .slice(0, RECENT_SESSION_LIMIT);
61
+ const lastSession = recentSessions[0] ?? null;
57
62
  return {
58
63
  project,
59
64
  sessionCount: own.length,
60
65
  runningCount,
61
66
  lastSession,
67
+ recentSessions,
62
68
  lastActivityAt: lastSession?.updatedAt ?? project.createdAt,
63
69
  };
64
70
  });
package/src/lib/routes.ts CHANGED
@@ -14,12 +14,21 @@ export type AppRoute =
14
14
  | { name: "project"; projectId: string }
15
15
  | { name: "session"; projectId: string; sessionId: string }
16
16
  | { name: "skills" }
17
- | { name: "settings"; section: SettingsSection; anchor?: string }
17
+ | {
18
+ name: "settings";
19
+ section: SettingsSection;
20
+ anchor?: string;
21
+ /**
22
+ * `/settings` tanpa sub-halaman. Layar lebar: tampil sebagai Profile
23
+ * (daftar navigasi ada di samping); layar sempit: daftar bagian.
24
+ */
25
+ root?: true;
26
+ }
18
27
  | { name: "not-found" };
19
28
 
20
29
  /**
21
- * Sub-halaman Settings (`/settings/:section`). Tab yang tampil hanya
22
- * profile/security/workspace/remote; `about` dibuka dari ikon di judul.
30
+ * Sub-halaman Settings (`/settings/:section`): empat bagian pengaturan +
31
+ * `about` (tampil terpisah di akhir navigasi, bukan pengaturan).
23
32
  */
24
33
  export const SETTINGS_SECTIONS = ["profile", "security", "workspace", "remote", "about"] as const;
25
34
  export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
@@ -84,7 +93,7 @@ export function parseRoute(pathname: string): AppRoute {
84
93
  if (segments.length === 0) return { name: "projects" };
85
94
  if (segments[0] === "skills" && segments.length === 1) return { name: "skills" };
86
95
  if (segments[0] === "settings") {
87
- if (segments.length === 1) return { name: "settings", section: "profile" };
96
+ if (segments.length === 1) return { name: "settings", section: "profile", root: true };
88
97
  const section = segments[1];
89
98
  if (segments.length === 2 && section !== undefined && isSettingsSection(section)) {
90
99
  return { name: "settings", section };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Selection mode daftar Session (bulk delete) — logika murni tanpa DOM,
3
+ * dipisah agar dapat diuji `bun test` (pola `lib/session-summary.ts`).
4
+ *
5
+ * Pilihan disimpan sebagai `Set` id; setiap perubahan menghasilkan Set baru
6
+ * (immutable) agar React me-render ulang.
7
+ */
8
+
9
+ /** Tambah / buang satu id dari pilihan. */
10
+ export function toggleSelection(selected: ReadonlySet<string>, id: string): Set<string> {
11
+ const next = new Set(selected);
12
+ if (next.has(id)) next.delete(id);
13
+ else next.add(id);
14
+ return next;
15
+ }
16
+
17
+ /**
18
+ * Buang id yang Session-nya sudah tidak ada (dihapus di tab/perangkat lain).
19
+ * Mengembalikan Set yang SAMA bila tidak ada yang dibuang, agar pemanggil
20
+ * bisa menghindari render ulang.
21
+ */
22
+ export function pruneSelection(
23
+ selected: ReadonlySet<string>,
24
+ sessions: readonly { id: string }[],
25
+ ): ReadonlySet<string> {
26
+ const alive = new Set(sessions.map((s) => s.id));
27
+ const next = new Set([...selected].filter((id) => alive.has(id)));
28
+ return next.size === selected.size ? selected : next;
29
+ }
30
+
31
+ /** Deskripsi grup saat selection mode ("3 of 7 selected"). */
32
+ export function describeSelection(count: number, total: number): string {
33
+ if (count === 0) return "Tap sessions to select them";
34
+ return `${count} of ${total} selected`;
35
+ }
36
+
37
+ /** "1 session" / "3 sessions". */
38
+ export function pluralSessions(count: number): string {
39
+ return `${count} session${count === 1 ? "" : "s"}`;
40
+ }
@@ -21,6 +21,7 @@
21
21
  import { useSyncExternalStore } from "react";
22
22
  import { ApiError, apiFetch } from "@/lib/api";
23
23
  import type { InstallJob, InstallJobLog } from "@/server/services/skill-install-jobs";
24
+ import type { AgentType } from "@/types";
24
25
 
25
26
  /** Interval polling saat job berjalan. */
26
27
  const POLL_MS = 700;
@@ -231,10 +232,11 @@ export async function startInstall(
231
232
  projectId: string,
232
233
  source: string,
233
234
  skill: string,
235
+ agentTypes: readonly AgentType[] = ["opencode"],
234
236
  ): Promise<InstallJob> {
235
237
  const res = await apiFetch(`/api/projects/${projectId}/skills/install`, {
236
238
  method: "POST",
237
- body: JSON.stringify({ source, skill }),
239
+ body: JSON.stringify({ source, skill, agents: agentTypes }),
238
240
  });
239
241
  const { job } = (await res.json()) as { job: InstallJob };
240
242
  upsert(newEntry(job, true));
@@ -2,9 +2,10 @@
2
2
  * Helper tampilan katalog skill (skills.sh) di halaman Skills — logika murni
3
3
  * tanpa DOM, dipisah agar dapat diuji `bun test`.
4
4
  */
5
+ import { AGENT_TYPES } from "@/lib/agent-types";
5
6
  import type { SkillInfo } from "@/server/services/opencode-client";
6
7
  import type { AuditStatus, RegistrySkill } from "@/server/services/skills-registry";
7
- import type { Project } from "@/types";
8
+ import type { AgentType, Project } from "@/types";
8
9
 
9
10
  /** Kunci localStorage Project terakhir yang dipilih di halaman Skills. */
10
11
  export const LAST_SKILLS_PROJECT_KEY = "kcg-skills-project";
@@ -46,13 +47,65 @@ export function githubOwner(source: string): string | null {
46
47
  return owner;
47
48
  }
48
49
 
50
+ /** Skill terpasang + agent yang memuatnya (gabungan daftar per agent). */
51
+ export interface InstalledSkill extends SkillInfo {
52
+ /** Urut sesuai `AGENT_TYPES`; minimal satu. */
53
+ agents: AgentType[];
54
+ }
55
+
56
+ /**
57
+ * Gabungkan daftar skill per agent menjadi satu daftar: skill dengan asal
58
+ * (`source`) dan nama (case-insensitive) sama dianggap satu skill yang
59
+ * dimuat beberapa agent. Deskripsi diambil dari daftar pertama yang punya.
60
+ */
61
+ export function mergeInstalledSkills(
62
+ byAgent: Partial<Record<AgentType, readonly SkillInfo[]>>,
63
+ ): InstalledSkill[] {
64
+ const merged = new Map<string, InstalledSkill>();
65
+ for (const agent of AGENT_TYPES) {
66
+ for (const skill of byAgent[agent] ?? []) {
67
+ const key = `${skill.source}:${skill.name.toLowerCase()}`;
68
+ const hit = merged.get(key);
69
+ if (!hit) {
70
+ merged.set(key, { ...skill, agents: [agent] });
71
+ continue;
72
+ }
73
+ if (!hit.agents.includes(agent)) hit.agents.push(agent);
74
+ if (hit.description === null) hit.description = skill.description;
75
+ }
76
+ }
77
+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
78
+ }
79
+
80
+ /**
81
+ * Agent yang sudah memuat skill katalog ini dari folder Project (cocokkan
82
+ * nama folder / nama). Skill global sengaja tidak dihitung: instalasi di
83
+ * halaman ini memasang ke folder Project.
84
+ */
85
+ export function installedAgentsFor(
86
+ skill: RegistrySkill,
87
+ installed: readonly InstalledSkill[],
88
+ ): AgentType[] {
89
+ const names = new Set([skill.skillId.toLowerCase(), skill.name.toLowerCase()]);
90
+ const agents = new Set<AgentType>();
91
+ for (const s of installed) {
92
+ if (s.source === "project" && names.has(s.name.toLowerCase())) {
93
+ for (const a of s.agents) agents.add(a);
94
+ }
95
+ }
96
+ return AGENT_TYPES.filter((a) => agents.has(a));
97
+ }
98
+
49
99
  /**
50
100
  * Skill terpasang yang relevan untuk halaman Skills: tanpa bawaan opencode,
51
101
  * Project dulu lalu global, difilter kata kunci (nama / deskripsi).
52
102
  */
53
- export function visibleInstalledSkills(skills: readonly SkillInfo[], query: string): SkillInfo[] {
103
+ export function visibleInstalledSkills<T extends SkillInfo>(
104
+ skills: readonly T[],
105
+ query: string,
106
+ ): T[] {
54
107
  const q = query.trim().toLowerCase();
55
- const match = (s: SkillInfo) =>
108
+ const match = (s: T) =>
56
109
  q === "" || s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
57
110
  return [
58
111
  ...skills.filter((s) => s.source === "project" && match(s)),
@@ -71,20 +124,6 @@ export function formatInstalls(n: number): string {
71
124
  return fmt(n / 1_000_000, "M");
72
125
  }
73
126
 
74
- /**
75
- * Nama skill yang sudah terpasang di Project (sumber `project`). Skill global
76
- * sengaja tidak dihitung: instalasi di halaman ini memasang ke folder Project,
77
- * jadi skill global bernama sama tetap boleh dipasang per Project.
78
- */
79
- export function installedProjectSkillNames(skills: readonly SkillInfo[]): Set<string> {
80
- return new Set(skills.filter((s) => s.source === "project").map((s) => s.name.toLowerCase()));
81
- }
82
-
83
- /** Apakah skill katalog sudah terpasang di Project (cocokkan nama folder / nama). */
84
- export function isInstalled(skill: RegistrySkill, installed: ReadonlySet<string>): boolean {
85
- return installed.has(skill.skillId.toLowerCase()) || installed.has(skill.name.toLowerCase());
86
- }
87
-
88
127
  /** Label & gaya ringkasan audit keamanan. */
89
128
  export const AUDIT_LABEL: Record<AuditStatus, string> = {
90
129
  pass: "Passed",
@@ -74,22 +74,23 @@ export function ProjectDetailPage({ projectId }: ProjectDetailPageProps) {
74
74
  </div>
75
75
  <SessionListSkeleton />
76
76
  </div>
77
- {/* Sama dengan ProjectSidePanel: tumpukan kartu seksi. */}
78
- <div className="hidden flex-col gap-3 self-start lg:flex">
79
- {[2, 1, 3].map((rows, i) => (
77
+ {/* Sama dengan ProjectSidePanel: grup (judul + deskripsi) berisi
78
+ boxed list. */}
79
+ <div className="hidden flex-col gap-6 self-start lg:flex">
80
+ {[1, 2, 3].map((rows, i) => (
80
81
  // biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
81
- <div key={i} className="overflow-hidden rounded-xl border bg-card">
82
- <div className="flex items-center gap-3 px-3.5 py-3">
83
- <Skeleton className="size-8 rounded-lg" />
84
- <div className="flex flex-1 flex-col gap-1.5">
85
- <Skeleton className="h-3.5 w-24" />
86
- <Skeleton className="h-3 w-16" />
87
- </div>
82
+ <div key={i} className="flex flex-col gap-2.5">
83
+ <div className="flex flex-col gap-1.5 px-1">
84
+ <Skeleton className="h-4 w-28" />
85
+ <Skeleton className="h-3 w-40" />
88
86
  </div>
89
- <div className="flex flex-col gap-2 border-t px-3.5 py-3">
87
+ <div className="flex flex-col divide-y overflow-hidden rounded-xl border bg-card">
90
88
  {Array.from({ length: rows }, (_, j) => (
91
89
  // biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
92
- <Skeleton key={j} className="h-3.5" style={{ width: `${80 - j * 15}%` }} />
90
+ <div key={j} className="flex min-h-14 flex-col justify-center gap-1.5 px-4">
91
+ <Skeleton className="h-4" style={{ width: `${60 - j * 12}%` }} />
92
+ <Skeleton className="h-3 w-1/3" />
93
+ </div>
93
94
  ))}
94
95
  </div>
95
96
  </div>