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
|
@@ -3,21 +3,25 @@
|
|
|
3
3
|
*
|
|
4
4
|
* - `RegistrySkillCard`: hasil skills.sh — nama (tautan), owner/repo, jumlah
|
|
5
5
|
* install, tombol Install / "Installing…" (klik = lihat progres) /
|
|
6
|
-
* status "Installed".
|
|
6
|
+
* status "Installed" (bila sudah terpasang untuk semua agent).
|
|
7
7
|
* - `InstalledSkillCard`: skill terpasang — nama, label asal, deskripsi.
|
|
8
|
+
* - Kedua kartu menampilkan logo agent tempat skill terpasang (`AgentBadges`)
|
|
9
|
+
* di pojok kanan atas.
|
|
8
10
|
* - `SkillCardSkeleton`: placeholder saat memuat.
|
|
9
11
|
* Semua kartu setinggi baris grid (`h-full`) agar grid rapi.
|
|
10
12
|
*/
|
|
11
13
|
import { CheckIcon, DownloadIcon, ExternalLinkIcon, PlusIcon, SparklesIcon } from "lucide-react";
|
|
12
14
|
import type { ReactNode } from "react";
|
|
15
|
+
import { AgentLogo } from "@/components/sessions/AgentLogo";
|
|
13
16
|
import { Button } from "@/components/ui/button";
|
|
14
17
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
15
18
|
import { Spinner } from "@/components/ui/spinner";
|
|
19
|
+
import { AGENT_TYPE_LABELS, AGENT_TYPES } from "@/lib/agent-types";
|
|
16
20
|
import { SKILL_SOURCE_LABEL } from "@/lib/project-extensions";
|
|
17
|
-
import { formatInstalls, githubOwner } from "@/lib/skills-catalog";
|
|
21
|
+
import { formatInstalls, githubOwner, type InstalledSkill } from "@/lib/skills-catalog";
|
|
18
22
|
import { cn } from "@/lib/utils";
|
|
19
|
-
import type { SkillInfo } from "@/server/services/opencode-client";
|
|
20
23
|
import type { RegistrySkill } from "@/server/services/skills-registry";
|
|
24
|
+
import type { AgentType } from "@/types";
|
|
21
25
|
|
|
22
26
|
/** Grid kartu: 1 kolom di HP, 2 kolom mulai `md`. */
|
|
23
27
|
export function SkillGrid({ label, children }: { label: string; children: ReactNode }) {
|
|
@@ -42,7 +46,7 @@ function CardShell({ className, children }: { className?: string; children: Reac
|
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
/** Avatar owner GitHub; jatuh ke ikon bila bukan sumber GitHub / gagal dimuat. */
|
|
45
|
-
function SourceAvatar({ source }: { source: string }) {
|
|
49
|
+
export function SourceAvatar({ source }: { source: string }) {
|
|
46
50
|
const owner = githubOwner(source);
|
|
47
51
|
return (
|
|
48
52
|
<span className="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground">
|
|
@@ -63,21 +67,59 @@ function SourceAvatar({ source }: { source: string }) {
|
|
|
63
67
|
);
|
|
64
68
|
}
|
|
65
69
|
|
|
70
|
+
/**
|
|
71
|
+
* Logo agent tempat skill terpasang; kosong -> tidak dirender.
|
|
72
|
+
* `sm` untuk baris daftar yang rapat (panel samping Project).
|
|
73
|
+
*/
|
|
74
|
+
export function AgentBadges({
|
|
75
|
+
agents,
|
|
76
|
+
size = "md",
|
|
77
|
+
}: {
|
|
78
|
+
agents: readonly AgentType[];
|
|
79
|
+
size?: "sm" | "md";
|
|
80
|
+
}) {
|
|
81
|
+
if (agents.length === 0) return null;
|
|
82
|
+
const label = `Installed for ${agents.map((a) => AGENT_TYPE_LABELS[a]).join(" and ")}`;
|
|
83
|
+
return (
|
|
84
|
+
<span
|
|
85
|
+
role="img"
|
|
86
|
+
aria-label={label}
|
|
87
|
+
title={label}
|
|
88
|
+
className={cn("flex shrink-0 items-center", size === "sm" ? "gap-0.5" : "gap-1")}
|
|
89
|
+
>
|
|
90
|
+
{agents.map((a) => (
|
|
91
|
+
<span
|
|
92
|
+
key={a}
|
|
93
|
+
className={cn(
|
|
94
|
+
"flex items-center justify-center border bg-background text-foreground",
|
|
95
|
+
size === "sm" ? "size-5 rounded" : "size-6 rounded-md",
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<AgentLogo agentType={a} className={size === "sm" ? "size-3" : "size-3.5"} />
|
|
99
|
+
</span>
|
|
100
|
+
))}
|
|
101
|
+
</span>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
export function RegistrySkillCard({
|
|
67
106
|
skill,
|
|
68
|
-
|
|
107
|
+
installedAgents,
|
|
69
108
|
installing,
|
|
70
109
|
onInstall,
|
|
71
110
|
onShowProgress,
|
|
72
111
|
}: {
|
|
73
112
|
skill: RegistrySkill;
|
|
74
|
-
|
|
113
|
+
/** Agent yang sudah memuat skill ini dari folder Project. */
|
|
114
|
+
installedAgents: readonly AgentType[];
|
|
75
115
|
/** Ada job instalasi berjalan untuk skill ini di Project terpilih. */
|
|
76
116
|
installing: boolean;
|
|
77
117
|
onInstall: (skill: RegistrySkill) => void;
|
|
78
118
|
/** Tampilkan lagi notifikasi progres instalasinya. */
|
|
79
119
|
onShowProgress: () => void;
|
|
80
120
|
}) {
|
|
121
|
+
// Masih bisa dipasang selama ada agent yang belum memuatnya.
|
|
122
|
+
const installedEverywhere = installedAgents.length === AGENT_TYPES.length;
|
|
81
123
|
return (
|
|
82
124
|
<CardShell className="hover:border-foreground/20">
|
|
83
125
|
<div className="flex min-w-0 items-start gap-3">
|
|
@@ -101,6 +143,7 @@ export function RegistrySkillCard({
|
|
|
101
143
|
{skill.source}
|
|
102
144
|
</span>
|
|
103
145
|
</div>
|
|
146
|
+
<AgentBadges agents={installedAgents} />
|
|
104
147
|
</div>
|
|
105
148
|
|
|
106
149
|
<div className="mt-auto flex items-center justify-between gap-2">
|
|
@@ -121,7 +164,7 @@ export function RegistrySkillCard({
|
|
|
121
164
|
<Spinner data-icon="inline-start" />
|
|
122
165
|
Installing…
|
|
123
166
|
</Button>
|
|
124
|
-
) :
|
|
167
|
+
) : installedEverywhere ? (
|
|
125
168
|
<span className="flex h-8 items-center gap-1 rounded-md bg-emerald-500/10 px-2.5 text-xs font-medium text-emerald-700 dark:text-emerald-400">
|
|
126
169
|
<CheckIcon className="size-3.5" aria-hidden />
|
|
127
170
|
Installed
|
|
@@ -143,7 +186,7 @@ export function RegistrySkillCard({
|
|
|
143
186
|
);
|
|
144
187
|
}
|
|
145
188
|
|
|
146
|
-
export function InstalledSkillCard({ skill }: { skill:
|
|
189
|
+
export function InstalledSkillCard({ skill }: { skill: InstalledSkill }) {
|
|
147
190
|
const isProject = skill.source === "project";
|
|
148
191
|
return (
|
|
149
192
|
<CardShell>
|
|
@@ -180,6 +223,7 @@ export function InstalledSkillCard({ skill }: { skill: SkillInfo }) {
|
|
|
180
223
|
{skill.description ?? "No description"}
|
|
181
224
|
</p>
|
|
182
225
|
</div>
|
|
226
|
+
<AgentBadges agents={skill.agents} />
|
|
183
227
|
</div>
|
|
184
228
|
</CardShell>
|
|
185
229
|
);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kerangka dialog ala AdwDialog (libadwaita) di atas `DialogContent`:
|
|
3
|
+
* - Header bar: slot kiri (`start`), judul + subjudul di tengah
|
|
4
|
+
* (AdwWindowTitle), slot kanan (`end`). Tanpa `end` -> tombol tutup bundar.
|
|
5
|
+
* - Tinggi dipatok (isi panjang men-scroll di dalam), sudut 2xl.
|
|
6
|
+
* - Layar sempit (< sm): tampil sebagai bottom sheet dengan pegangan, seperti
|
|
7
|
+
* AdwDialog di jendela sempit; padding bawah menghormati safe-area iOS.
|
|
8
|
+
* - Fokus awal: desktop -> kolom cari pertama (bila ada); HP -> dialog itu
|
|
9
|
+
* sendiri agar keyboard virtual tidak langsung muncul.
|
|
10
|
+
* - Esc saat kolom cari berisi: kosongkan cari dulu (lihat `SearchEntry`),
|
|
11
|
+
* tidak menutup dialog.
|
|
12
|
+
*/
|
|
13
|
+
import { XIcon } from "lucide-react";
|
|
14
|
+
import { type ReactNode, useRef } from "react";
|
|
15
|
+
import {
|
|
16
|
+
DialogClose,
|
|
17
|
+
DialogContent,
|
|
18
|
+
DialogDescription,
|
|
19
|
+
DialogHeader,
|
|
20
|
+
DialogTitle,
|
|
21
|
+
} from "@/components/ui/dialog";
|
|
22
|
+
import { cn } from "@/lib/utils";
|
|
23
|
+
|
|
24
|
+
export interface AdwDialogContentProps {
|
|
25
|
+
title: ReactNode;
|
|
26
|
+
subtitle?: ReactNode;
|
|
27
|
+
/** Aksi di kiri header bar (mis. "Cancel"). */
|
|
28
|
+
start?: ReactNode;
|
|
29
|
+
/** Aksi di kanan header bar (mis. tombol utama). Kosong -> tombol tutup. */
|
|
30
|
+
end?: ReactNode;
|
|
31
|
+
/** Kunci tombol tutup bawaan (mis. saat menyimpan). */
|
|
32
|
+
closeDisabled?: boolean;
|
|
33
|
+
/** Kelas area isi di bawah header bar. */
|
|
34
|
+
bodyClassName?: string;
|
|
35
|
+
children: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function AdwDialogContent({
|
|
39
|
+
title,
|
|
40
|
+
subtitle,
|
|
41
|
+
start,
|
|
42
|
+
end,
|
|
43
|
+
closeDisabled = false,
|
|
44
|
+
bodyClassName,
|
|
45
|
+
children,
|
|
46
|
+
}: AdwDialogContentProps) {
|
|
47
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
const hasSides = start !== undefined || end !== undefined;
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<DialogContent
|
|
52
|
+
ref={contentRef}
|
|
53
|
+
showCloseButton={false}
|
|
54
|
+
onOpenAutoFocus={(e) => {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
const wide = window.matchMedia("(min-width: 640px)").matches;
|
|
57
|
+
const search = contentRef.current?.querySelector<HTMLInputElement>('input[type="search"]');
|
|
58
|
+
(wide && search ? search : contentRef.current)?.focus();
|
|
59
|
+
}}
|
|
60
|
+
onEscapeKeyDown={(e) => {
|
|
61
|
+
const el = document.activeElement;
|
|
62
|
+
if (el instanceof HTMLInputElement && el.type === "search" && el.value !== "") {
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
}
|
|
65
|
+
}}
|
|
66
|
+
className={cn(
|
|
67
|
+
"flex h-[min(640px,85dvh)] flex-col gap-0 overflow-hidden rounded-2xl p-0 sm:max-w-md",
|
|
68
|
+
"max-sm:top-auto max-sm:bottom-0 max-sm:left-0 max-sm:h-[85dvh] max-sm:max-w-none max-sm:translate-x-0 max-sm:translate-y-0 max-sm:rounded-b-none max-sm:border-x-0 max-sm:border-b-0",
|
|
69
|
+
"max-sm:data-[state=closed]:slide-out-to-bottom-8 max-sm:data-[state=open]:slide-in-from-bottom-8",
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{/* Pegangan sheet (dekoratif) — hanya di HP. */}
|
|
73
|
+
<span
|
|
74
|
+
aria-hidden
|
|
75
|
+
className="mx-auto mt-2 h-1 w-9 shrink-0 rounded-full bg-muted-foreground/30 sm:hidden"
|
|
76
|
+
/>
|
|
77
|
+
<DialogHeader
|
|
78
|
+
className={cn(
|
|
79
|
+
"grid shrink-0 items-center gap-2 px-3 pt-2.5 pb-2 sm:pt-3",
|
|
80
|
+
// Kolom kiri/kanan selebar slot terlebar agar judul tetap di tengah.
|
|
81
|
+
hasSides ? "grid-cols-[1fr_auto_1fr]" : "grid-cols-[2rem_1fr_2rem]",
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
<div className="flex min-w-0 justify-start">{start}</div>
|
|
85
|
+
<div className="flex min-w-0 flex-col items-center gap-0.5 text-center">
|
|
86
|
+
<DialogTitle className="truncate text-[15px] leading-5 font-bold">{title}</DialogTitle>
|
|
87
|
+
{subtitle && (
|
|
88
|
+
<DialogDescription className="truncate text-xs leading-4">{subtitle}</DialogDescription>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
<div className="flex min-w-0 justify-end">
|
|
92
|
+
{end ?? (
|
|
93
|
+
<DialogClose
|
|
94
|
+
disabled={closeDisabled}
|
|
95
|
+
aria-label="Close"
|
|
96
|
+
className="flex size-7 items-center justify-center rounded-full bg-black/[0.06] text-foreground/80 transition-colors outline-none hover:bg-black/10 focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-50 dark:bg-white/[0.08] dark:hover:bg-white/15"
|
|
97
|
+
>
|
|
98
|
+
<XIcon className="size-3.5" strokeWidth={2.5} />
|
|
99
|
+
</DialogClose>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</DialogHeader>
|
|
103
|
+
|
|
104
|
+
<div
|
|
105
|
+
className={cn(
|
|
106
|
+
"flex min-h-0 flex-1 flex-col gap-3 px-4 pt-1 pb-[max(1rem,env(safe-area-inset-bottom))]",
|
|
107
|
+
bodyClassName,
|
|
108
|
+
)}
|
|
109
|
+
>
|
|
110
|
+
{children}
|
|
111
|
+
</div>
|
|
112
|
+
</DialogContent>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toggle group ala AdwToggleGroup (libadwaita 1.7): palung terisi berisi
|
|
3
|
+
* beberapa pilihan sejajar; pilihan aktif "terangkat" (latar terang +
|
|
4
|
+
* bayangan di mode terang, lebih terang dari palung di mode gelap).
|
|
5
|
+
*
|
|
6
|
+
* Satu pilihan selalu aktif (bukan multi-select). Tombol memakai
|
|
7
|
+
* `aria-pressed` di dalam `role="group"` berlabel.
|
|
8
|
+
*/
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
export interface AdwToggleOption<T extends string> {
|
|
13
|
+
value: T;
|
|
14
|
+
label: ReactNode;
|
|
15
|
+
/** Ikon / logo di kiri label. */
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
/** Label aksesibel bila `label` bukan teks biasa. */
|
|
18
|
+
ariaLabel?: string;
|
|
19
|
+
title?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface AdwToggleGroupProps<T extends string> {
|
|
23
|
+
/** Label grup untuk pembaca layar. */
|
|
24
|
+
label: string;
|
|
25
|
+
value: T;
|
|
26
|
+
options: readonly AdwToggleOption<T>[];
|
|
27
|
+
onChange: (value: T) => void;
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function AdwToggleGroup<T extends string>({
|
|
33
|
+
label,
|
|
34
|
+
value,
|
|
35
|
+
options,
|
|
36
|
+
onChange,
|
|
37
|
+
disabled = false,
|
|
38
|
+
className,
|
|
39
|
+
}: AdwToggleGroupProps<T>) {
|
|
40
|
+
return (
|
|
41
|
+
// biome-ignore lint/a11y/useSemanticElements: grup tombol segmented, bukan fieldset form.
|
|
42
|
+
<div
|
|
43
|
+
role="group"
|
|
44
|
+
aria-label={label}
|
|
45
|
+
className={cn(
|
|
46
|
+
"grid shrink-0 auto-cols-fr grid-flow-col gap-0.5 rounded-[10px] bg-black/[0.06] p-[3px] dark:bg-white/[0.07]",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{options.map((o) => {
|
|
51
|
+
const active = o.value === value;
|
|
52
|
+
return (
|
|
53
|
+
<button
|
|
54
|
+
key={o.value}
|
|
55
|
+
type="button"
|
|
56
|
+
aria-pressed={active}
|
|
57
|
+
aria-label={o.ariaLabel}
|
|
58
|
+
title={o.title}
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
onClick={() => onChange(o.value)}
|
|
61
|
+
className={cn(
|
|
62
|
+
"flex h-8 min-w-0 items-center justify-center gap-2 rounded-[7px] px-2 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:opacity-60",
|
|
63
|
+
active
|
|
64
|
+
? "bg-white text-foreground shadow-sm dark:bg-white/[0.15] dark:shadow-none"
|
|
65
|
+
: "text-foreground/70 hover:bg-black/[0.04] hover:text-foreground dark:hover:bg-white/[0.05]",
|
|
66
|
+
)}
|
|
67
|
+
>
|
|
68
|
+
{o.icon}
|
|
69
|
+
<span className="truncate">{o.label}</span>
|
|
70
|
+
</button>
|
|
71
|
+
);
|
|
72
|
+
})}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kolom cari ala GtkSearchEntry (libadwaita): latar terisi tanpa border,
|
|
3
|
+
* ikon kaca pembesar di kiri, tombol hapus bundar saat berisi, ring fokus
|
|
4
|
+
* aksen. Esc mengosongkan isi (bila ada) sebelum diteruskan ke pemanggil.
|
|
5
|
+
*
|
|
6
|
+
* Teks 16px di HP agar iOS Safari tidak men-zoom halaman saat fokus.
|
|
7
|
+
*/
|
|
8
|
+
import { SearchIcon, XIcon } from "lucide-react";
|
|
9
|
+
import { type ComponentProps, useRef } from "react";
|
|
10
|
+
import { cn } from "@/lib/utils";
|
|
11
|
+
|
|
12
|
+
export interface SearchEntryProps
|
|
13
|
+
extends Omit<ComponentProps<"input">, "value" | "onChange" | "type"> {
|
|
14
|
+
value: string;
|
|
15
|
+
onValueChange: (value: string) => void;
|
|
16
|
+
/** Kelas untuk pembungkus (lebar, margin). */
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function SearchEntry({
|
|
21
|
+
value,
|
|
22
|
+
onValueChange,
|
|
23
|
+
className,
|
|
24
|
+
onKeyDown,
|
|
25
|
+
ref,
|
|
26
|
+
...props
|
|
27
|
+
}: SearchEntryProps) {
|
|
28
|
+
const localRef = useRef<HTMLInputElement | null>(null);
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn("relative", className)}>
|
|
31
|
+
<SearchIcon
|
|
32
|
+
className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground"
|
|
33
|
+
aria-hidden
|
|
34
|
+
/>
|
|
35
|
+
<input
|
|
36
|
+
ref={(el) => {
|
|
37
|
+
localRef.current = el;
|
|
38
|
+
if (typeof ref === "function") ref(el);
|
|
39
|
+
else if (ref) ref.current = el;
|
|
40
|
+
}}
|
|
41
|
+
type="search"
|
|
42
|
+
value={value}
|
|
43
|
+
onChange={(e) => onValueChange(e.target.value)}
|
|
44
|
+
onKeyDown={(e) => {
|
|
45
|
+
onKeyDown?.(e);
|
|
46
|
+
if (e.defaultPrevented) return;
|
|
47
|
+
if (e.key === "Escape" && value !== "") {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
onValueChange("");
|
|
50
|
+
}
|
|
51
|
+
}}
|
|
52
|
+
className="h-9 w-full rounded-lg bg-black/[0.06] pr-9 pl-9 text-base outline-none transition-shadow placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring/60 sm:text-sm dark:bg-white/[0.08] [&::-webkit-search-cancel-button]:hidden"
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
{value !== "" && (
|
|
56
|
+
<button
|
|
57
|
+
type="button"
|
|
58
|
+
onClick={() => {
|
|
59
|
+
onValueChange("");
|
|
60
|
+
localRef.current?.focus();
|
|
61
|
+
}}
|
|
62
|
+
aria-label="Clear search"
|
|
63
|
+
className="absolute top-1/2 right-1.5 flex size-6 -translate-y-1/2 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-black/10 hover:text-foreground dark:hover:bg-white/10"
|
|
64
|
+
>
|
|
65
|
+
<XIcon className="size-3.5" />
|
|
66
|
+
</button>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Config_File & validasi Sandbox_Root saat startup.
|
|
3
3
|
* Sesuai `design.md` — `config.ts`.
|
|
4
4
|
*
|
|
5
|
-
* Skema minimal: `{ "sandboxRoot": "/abs/path" }`.
|
|
5
|
+
* Skema minimal: `{ "sandboxRoot": "/abs/path" }`, opsional `"port": 3000`.
|
|
6
6
|
* Path default mengikuti mode runtime (`src/runtime.ts`):
|
|
7
7
|
* - **cli** (`kcgcode`): `~/.kcgcode/config.json`
|
|
8
8
|
* - **dev** (`bun dev`): `./kcg-code.config.json` bila ada, else user config
|
|
9
9
|
* `loadConfig()` keluar dengan kode error bila `sandboxRoot` tidak diatur
|
|
10
10
|
* atau direktori yang ditunjuk tidak ditemukan (Requirement 10.1).
|
|
11
11
|
*/
|
|
12
|
-
import { readFileSync, realpathSync } from "node:fs";
|
|
12
|
+
import { readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { DEFAULT_CONFIG_PATH, resolveEffectiveConfigPath } from "./paths";
|
|
14
14
|
import type { Result } from "./server/result";
|
|
15
15
|
|
|
@@ -20,6 +20,12 @@ export interface AppConfig {
|
|
|
20
20
|
sandboxRoot: string;
|
|
21
21
|
/** Path berkas konfigurasi yang dibaca. */
|
|
22
22
|
configPath: string;
|
|
23
|
+
/** Port HTTP pilihan user (diatur lewat menu CLI); kosong = default. */
|
|
24
|
+
port?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isValidPort(n: unknown): n is number {
|
|
28
|
+
return typeof n === "number" && Number.isInteger(n) && n > 0 && n <= 65535;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
/**
|
|
@@ -68,7 +74,30 @@ export function resolveConfig(configPath?: string): Result<AppConfig> {
|
|
|
68
74
|
};
|
|
69
75
|
}
|
|
70
76
|
|
|
71
|
-
|
|
77
|
+
const port = obj.port;
|
|
78
|
+
if (port !== undefined && !isValidPort(port)) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: `[config] "port" must be an integer 1-65535 in "${file}" (got ${JSON.stringify(port)})`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
ok: true,
|
|
87
|
+
data: { sandboxRoot: resolved, configPath: file, ...(port !== undefined && { port }) },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Simpan `port` ke Config_File; key lain dipertahankan apa adanya. */
|
|
92
|
+
export function saveConfigPort(configPath: string, port: number): Result<void> {
|
|
93
|
+
try {
|
|
94
|
+
const obj = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
95
|
+
obj.port = port;
|
|
96
|
+
writeFileSync(configPath, `${JSON.stringify(obj, null, 2)}\n`, "utf8");
|
|
97
|
+
return { ok: true, data: undefined };
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return { ok: false, error: `cannot save port to "${configPath}": ${(err as Error).message}` };
|
|
100
|
+
}
|
|
72
101
|
}
|
|
73
102
|
|
|
74
103
|
/**
|
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
* di Sheet tidak perlu buka popover lagi di dalam popover.
|
|
8
8
|
*/
|
|
9
9
|
import { useCallback, useRef, useState } from "react";
|
|
10
|
+
import { agentQuery } from "@/lib/agent-types";
|
|
10
11
|
import { ApiError, apiFetch } from "@/lib/api";
|
|
11
12
|
import type { AgentOption } from "@/server/services/opencode-client";
|
|
13
|
+
import type { AgentType } from "@/types";
|
|
12
14
|
|
|
13
15
|
export interface UseAgentPickerResult {
|
|
14
16
|
agents: AgentOption[] | null;
|
|
@@ -26,6 +28,8 @@ export function useAgentPicker(
|
|
|
26
28
|
/** null = Session belum ada (composer homepage): pilihan tidak di-PUT. */
|
|
27
29
|
sessionId: string | null,
|
|
28
30
|
onChanged: (agent: string | null) => void,
|
|
31
|
+
/** Backend Session — Claude Code menawarkan mode permission sebagai agent. */
|
|
32
|
+
agentType: AgentType = "opencode",
|
|
29
33
|
): UseAgentPickerResult {
|
|
30
34
|
const [agents, setAgents] = useState<AgentOption[] | null>(null);
|
|
31
35
|
const [loading, setLoading] = useState(false);
|
|
@@ -55,7 +59,7 @@ export function useAgentPicker(
|
|
|
55
59
|
setError(null);
|
|
56
60
|
void (async () => {
|
|
57
61
|
try {
|
|
58
|
-
const res = await apiFetch(`/api/projects/${projectId}/agents`);
|
|
62
|
+
const res = await apiFetch(`/api/projects/${projectId}/agents${agentQuery(agentType)}`);
|
|
59
63
|
const body = (await res.json()) as { agents: AgentOption[] };
|
|
60
64
|
setAgents(body.agents);
|
|
61
65
|
} catch (e) {
|
|
@@ -66,7 +70,7 @@ export function useAgentPicker(
|
|
|
66
70
|
setLoading(false);
|
|
67
71
|
}
|
|
68
72
|
})();
|
|
69
|
-
}, [projectId]);
|
|
73
|
+
}, [projectId, agentType]);
|
|
70
74
|
|
|
71
75
|
const pick = useCallback(
|
|
72
76
|
async (name: string | null) => {
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Engine halaman daftar Session milik satu Project (`SessionList.tsx`).
|
|
3
3
|
*
|
|
4
4
|
* Mengelola pemuatan daftar (`GET /api/sessions` di-filter per Project),
|
|
5
|
-
* aksi per-Session (stop/start/hapus),
|
|
6
|
-
* (yang menghapus seluruh Session-nya). Sisa logika form pembuatan Session
|
|
5
|
+
* aksi per-Session (stop/start/hapus), selection mode untuk hapus massal,
|
|
6
|
+
* serta dialog konfirmasi hapus Project (yang menghapus seluruh Session-nya). Sisa logika form pembuatan Session
|
|
7
7
|
* baru hidup di `NewSessionDialog`.
|
|
8
8
|
*/
|
|
9
9
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
@@ -12,6 +12,7 @@ import { useResyncOnReconnect } from "@/hooks/useResyncOnReconnect";
|
|
|
12
12
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
|
13
13
|
import { ApiError, apiFetch } from "@/lib/api";
|
|
14
14
|
import { notifyDataChanged } from "@/lib/data-events";
|
|
15
|
+
import { pluralSessions, pruneSelection, toggleSelection } from "@/lib/session-selection";
|
|
15
16
|
import { type SessionSummary, sortSessions, summarizeSessions } from "@/lib/session-summary";
|
|
16
17
|
import type { Session } from "@/types";
|
|
17
18
|
|
|
@@ -42,6 +43,22 @@ export interface SessionListEngine {
|
|
|
42
43
|
cancelDelete: () => void;
|
|
43
44
|
confirmRemove: () => Promise<void>;
|
|
44
45
|
|
|
46
|
+
// Selection mode (hapus massal)
|
|
47
|
+
selecting: boolean;
|
|
48
|
+
startSelecting: () => void;
|
|
49
|
+
stopSelecting: () => void;
|
|
50
|
+
/** Id Session terpilih (hanya yang masih ada). */
|
|
51
|
+
selected: ReadonlySet<string>;
|
|
52
|
+
toggleSelect: (sessionId: string) => void;
|
|
53
|
+
/** Pilih semua; bila semua sudah terpilih -> kosongkan. */
|
|
54
|
+
toggleSelectAll: () => void;
|
|
55
|
+
bulkDeleteOpen: boolean;
|
|
56
|
+
requestBulkDelete: () => void;
|
|
57
|
+
cancelBulkDelete: () => void;
|
|
58
|
+
/** Progres hapus massal; null = tidak berjalan. */
|
|
59
|
+
bulkProgress: { done: number; total: number } | null;
|
|
60
|
+
confirmBulkRemove: () => Promise<void>;
|
|
61
|
+
|
|
45
62
|
// Hapus Project (beserta seluruh Session-nya)
|
|
46
63
|
projectDeleteOpen: boolean;
|
|
47
64
|
openProjectDelete: () => void;
|
|
@@ -62,6 +79,10 @@ export function useSessionList({ projectId, onDeleted }: UseSessionListOptions):
|
|
|
62
79
|
/** Konfirmasi hapus Project (beserta seluruh Session-nya). */
|
|
63
80
|
const [projectDeleteOpen, setProjectDeleteOpen] = useState(false);
|
|
64
81
|
const [deletingProject, setDeletingProject] = useState(false);
|
|
82
|
+
const [selecting, setSelecting] = useState(false);
|
|
83
|
+
const [rawSelected, setRawSelected] = useState<ReadonlySet<string>>(() => new Set());
|
|
84
|
+
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
|
85
|
+
const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null);
|
|
65
86
|
|
|
66
87
|
/** `silent`: muat ulang di latar (tanpa skeleton) — sinkron dari server. */
|
|
67
88
|
const refresh = useCallback(
|
|
@@ -125,6 +146,10 @@ export function useSessionList({ projectId, onDeleted }: UseSessionListOptions):
|
|
|
125
146
|
const ordered = useMemo(() => sortSessions(sessions), [sessions]);
|
|
126
147
|
const summary = useMemo(() => summarizeSessions(sessions), [sessions]);
|
|
127
148
|
|
|
149
|
+
// Session yang hilang (dihapus di tab/perangkat lain) otomatis tidak
|
|
150
|
+
// terpilih lagi.
|
|
151
|
+
const selected = useMemo(() => pruneSelection(rawSelected, sessions), [rawSelected, sessions]);
|
|
152
|
+
|
|
128
153
|
/** Stop ≠ hapus: POST /stop hanya mengubah status; data tetap ada dan
|
|
129
154
|
bisa dihidupkan kembali lewat aksi Hidupkan (resume). */
|
|
130
155
|
const stop = useCallback(
|
|
@@ -187,6 +212,63 @@ export function useSessionList({ projectId, onDeleted }: UseSessionListOptions):
|
|
|
187
212
|
}
|
|
188
213
|
}, [pendingDelete, refresh]);
|
|
189
214
|
|
|
215
|
+
const startSelecting = useCallback(() => {
|
|
216
|
+
setRawSelected(new Set());
|
|
217
|
+
setSelecting(true);
|
|
218
|
+
}, []);
|
|
219
|
+
const stopSelecting = useCallback(() => {
|
|
220
|
+
setSelecting(false);
|
|
221
|
+
setRawSelected(new Set());
|
|
222
|
+
}, []);
|
|
223
|
+
const toggleSelect = useCallback(
|
|
224
|
+
(sessionId: string) => setRawSelected((prev) => toggleSelection(prev, sessionId)),
|
|
225
|
+
[],
|
|
226
|
+
);
|
|
227
|
+
const toggleSelectAll = useCallback(() => {
|
|
228
|
+
setRawSelected((prev) =>
|
|
229
|
+
sessions.length > 0 && sessions.every((s) => prev.has(s.id))
|
|
230
|
+
? new Set()
|
|
231
|
+
: new Set(sessions.map((s) => s.id)),
|
|
232
|
+
);
|
|
233
|
+
}, [sessions]);
|
|
234
|
+
const requestBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
|
235
|
+
const cancelBulkDelete = useCallback(() => setBulkDeleteOpen(false), []);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Hapus massal Session terpilih — satu per satu (berurutan, agar server
|
|
239
|
+
* headless tidak dibanjiri), progres tampil di dialog konfirmasi. Yang
|
|
240
|
+
* gagal tetap terpilih supaya bisa dicoba lagi.
|
|
241
|
+
*/
|
|
242
|
+
const confirmBulkRemove = useCallback(async () => {
|
|
243
|
+
const ids = [...selected];
|
|
244
|
+
if (ids.length === 0) return;
|
|
245
|
+
const failed: string[] = [];
|
|
246
|
+
setBulkProgress({ done: 0, total: ids.length });
|
|
247
|
+
for (const [i, id] of ids.entries()) {
|
|
248
|
+
setDeleting(id);
|
|
249
|
+
try {
|
|
250
|
+
await apiFetch(`/api/sessions/${id}`, { method: "DELETE" });
|
|
251
|
+
} catch {
|
|
252
|
+
failed.push(id);
|
|
253
|
+
}
|
|
254
|
+
setBulkProgress({ done: i + 1, total: ids.length });
|
|
255
|
+
}
|
|
256
|
+
setDeleting(null);
|
|
257
|
+
setBulkProgress(null);
|
|
258
|
+
setBulkDeleteOpen(false);
|
|
259
|
+
const removed = ids.length - failed.length;
|
|
260
|
+
if (failed.length === 0) {
|
|
261
|
+
toast.success(`Deleted ${pluralSessions(removed)}.`);
|
|
262
|
+
setSelecting(false);
|
|
263
|
+
setRawSelected(new Set());
|
|
264
|
+
} else {
|
|
265
|
+
toast.error(`Couldn't delete ${failed.length} of ${pluralSessions(ids.length)}.`);
|
|
266
|
+
setRawSelected(new Set(failed));
|
|
267
|
+
}
|
|
268
|
+
await refresh({ silent: true });
|
|
269
|
+
notifyDataChanged();
|
|
270
|
+
}, [selected, refresh]);
|
|
271
|
+
|
|
190
272
|
const openProjectDelete = useCallback(() => setProjectDeleteOpen(true), []);
|
|
191
273
|
const closeProjectDelete = useCallback(() => setProjectDeleteOpen(false), []);
|
|
192
274
|
|
|
@@ -225,6 +307,17 @@ export function useSessionList({ projectId, onDeleted }: UseSessionListOptions):
|
|
|
225
307
|
requestDelete,
|
|
226
308
|
cancelDelete,
|
|
227
309
|
confirmRemove,
|
|
310
|
+
selecting,
|
|
311
|
+
startSelecting,
|
|
312
|
+
stopSelecting,
|
|
313
|
+
selected,
|
|
314
|
+
toggleSelect,
|
|
315
|
+
toggleSelectAll,
|
|
316
|
+
bulkDeleteOpen,
|
|
317
|
+
requestBulkDelete,
|
|
318
|
+
cancelBulkDelete,
|
|
319
|
+
bulkProgress,
|
|
320
|
+
confirmBulkRemove,
|
|
228
321
|
projectDeleteOpen,
|
|
229
322
|
openProjectDelete,
|
|
230
323
|
closeProjectDelete,
|
|
@@ -3,7 +3,13 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { describe, expect, test } from "bun:test";
|
|
5
5
|
import type { SkillInfo } from "@/server/services/opencode-client";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
describeMcpEntries,
|
|
8
|
+
describeMcpSummary,
|
|
9
|
+
filterSkills,
|
|
10
|
+
groupSkills,
|
|
11
|
+
mergeMcpServers,
|
|
12
|
+
} from "../project-extensions";
|
|
7
13
|
|
|
8
14
|
function skill(name: string, source: SkillInfo["source"], description: string | null = null) {
|
|
9
15
|
return { name, description, location: null, source } satisfies SkillInfo;
|
|
@@ -25,6 +31,31 @@ describe("describeMcpSummary", () => {
|
|
|
25
31
|
});
|
|
26
32
|
});
|
|
27
33
|
|
|
34
|
+
describe("mergeMcpServers", () => {
|
|
35
|
+
test("gabung per nama, catat agent, urut yang bermasalah dulu", () => {
|
|
36
|
+
const entries = mergeMcpServers({
|
|
37
|
+
opencode: [
|
|
38
|
+
{ name: "web", status: "connected", error: null },
|
|
39
|
+
{ name: "db", status: "connected", error: null },
|
|
40
|
+
],
|
|
41
|
+
"claude-code": [
|
|
42
|
+
{ name: "web", status: "unknown", error: null },
|
|
43
|
+
{ name: "db", status: "failed", error: "boom" },
|
|
44
|
+
{ name: "cc-only", status: "disabled", error: null },
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
expect(entries.map((e) => [e.name, e.status, e.agents])).toEqual([
|
|
48
|
+
["db", "failed", ["opencode", "claude-code"]],
|
|
49
|
+
["web", "connected", ["opencode", "claude-code"]],
|
|
50
|
+
["cc-only", "disabled", ["claude-code"]],
|
|
51
|
+
]);
|
|
52
|
+
expect(entries[0]?.byAgent["claude-code"]?.error).toBe("boom");
|
|
53
|
+
// Tersambung di salah satu agent sudah dihitung "connected".
|
|
54
|
+
expect(describeMcpEntries(entries)).toBe("2 of 3 connected");
|
|
55
|
+
expect(describeMcpEntries([])).toBe("None configured");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
28
59
|
describe("groupSkills", () => {
|
|
29
60
|
test("urutan project -> global -> builtin, kelompok kosong dibuang", () => {
|
|
30
61
|
const groups = groupSkills([
|