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.
- 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 +880 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/ink-react.ts +26 -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
|
@@ -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
|
+
});
|
|
@@ -8,8 +8,8 @@ import type { Project } from "@/types";
|
|
|
8
8
|
import {
|
|
9
9
|
formatInstalls,
|
|
10
10
|
githubOwner,
|
|
11
|
-
|
|
12
|
-
|
|
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("
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
| {
|
|
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`)
|
|
22
|
-
*
|
|
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
|
|
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:
|
|
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:
|
|
78
|
-
|
|
79
|
-
|
|
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="
|
|
82
|
-
<div className="flex
|
|
83
|
-
<Skeleton className="
|
|
84
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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>
|