kcg-code 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) 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 +876 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/keep-awake.ts +148 -0
  14. package/src/cli/port.ts +92 -0
  15. package/src/cli/theme.ts +6 -6
  16. package/src/components/layout/AppShell.tsx +8 -3
  17. package/src/components/projects/HomeChat.tsx +85 -48
  18. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  19. package/src/components/projects/ProjectInstructions.tsx +27 -38
  20. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  21. package/src/components/sessions/AgentLogo.tsx +42 -0
  22. package/src/components/sessions/AgentPicker.tsx +13 -4
  23. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  24. package/src/components/sessions/ContextPanel.tsx +159 -59
  25. package/src/components/sessions/ModelPicker.tsx +65 -27
  26. package/src/components/sessions/ModelSearchList.tsx +137 -57
  27. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  28. package/src/components/sessions/SessionComposer.tsx +2 -1
  29. package/src/components/sessions/SessionHeader.tsx +1 -0
  30. package/src/components/sessions/SessionList.tsx +173 -66
  31. package/src/components/sessions/SessionRow.tsx +176 -76
  32. package/src/components/sessions/SessionView.tsx +17 -13
  33. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  34. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  35. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  36. package/src/components/settings/prefs.tsx +69 -1
  37. package/src/components/skills/InstallNotifications.tsx +4 -3
  38. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  39. package/src/components/skills/SkillCards.tsx +52 -8
  40. package/src/components/ui/adw-dialog.tsx +114 -0
  41. package/src/components/ui/adw-toggle-group.tsx +75 -0
  42. package/src/components/ui/search-entry.tsx +70 -0
  43. package/src/config.ts +32 -3
  44. package/src/hooks/useAgentPicker.ts +6 -2
  45. package/src/hooks/useSessionList.ts +95 -2
  46. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  47. package/src/lib/__tests__/project-overview.test.ts +15 -0
  48. package/src/lib/__tests__/routes.test.ts +2 -2
  49. package/src/lib/__tests__/session-selection.test.ts +41 -0
  50. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  51. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  52. package/src/lib/agent-types.ts +49 -0
  53. package/src/lib/project-extensions.ts +66 -4
  54. package/src/lib/project-overview.ts +14 -8
  55. package/src/lib/routes.ts +13 -4
  56. package/src/lib/session-selection.ts +40 -0
  57. package/src/lib/skill-installs.ts +3 -1
  58. package/src/lib/skills-catalog.ts +56 -17
  59. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  60. package/src/pages/settings/SettingsPage.tsx +212 -145
  61. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  62. package/src/pages/skills/SkillsPage.tsx +89 -45
  63. package/src/server/__tests__/app.test.ts +15 -2
  64. package/src/server/app.ts +13 -1
  65. package/src/server/routes/projects.routes.ts +14 -4
  66. package/src/server/routes/sessions.routes.ts +1 -1
  67. package/src/server/routes/skills.routes.ts +24 -5
  68. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  69. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  70. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  71. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  72. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  73. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  74. package/src/server/services/claude-code-client.ts +1093 -0
  75. package/src/server/services/claude-code-translate.ts +370 -0
  76. package/src/server/services/claude-mcp.ts +122 -0
  77. package/src/server/services/claude-skills.ts +102 -0
  78. package/src/server/services/session-manager.ts +117 -52
  79. package/src/server/services/skill-install-jobs.ts +21 -8
  80. package/src/server/services/skills-registry.ts +18 -6
  81. package/src/types/message.ts +1 -1
  82. 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
- installed,
107
+ installedAgents,
69
108
  installing,
70
109
  onInstall,
71
110
  onShowProgress,
72
111
  }: {
73
112
  skill: RegistrySkill;
74
- installed: boolean;
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
- ) : installed ? (
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: SkillInfo }) {
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
- return { ok: true, data: { sandboxRoot: resolved, configPath: file } };
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), serta dialog konfirmasi hapus Project
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 { describeMcpSummary, filterSkills, groupSkills } from "../project-extensions";
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([