kcg-code 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. package/src/components/projects/PanelSection.tsx +0 -102
@@ -1,73 +1,107 @@
1
1
  /**
2
- * Panel samping halaman Project — gaya Claude Projects: Instructions, MCP
3
- * server, dan Skills dalam satu kolom di kanan daftar Session.
2
+ * Panel samping halaman Project — Instructions, MCP server, dan Skills dalam
3
+ * satu kolom di kanan daftar Session.
4
4
  *
5
- * Tiap seksi bisa dilipat (status diingat). Daftar berupa "grouped list"
6
- * (kotak berdivider) dengan padding simetris — tanpa margin negatif.
7
- * - MCP: `GET /api/projects/:id/mcp` — ikon, nama, pill status; error
8
- * `failed` tampil inline. Yang bermasalah diurutkan paling atas.
9
- * - Skills: `GET /api/projects/:id/skills` — pencarian (bila banyak) + chip
10
- * asal (All / This project / Global / Built-in); deskripsi 1 baris, klik
11
- * baris untuk membuka deskripsi lengkap.
12
- * Tiap seksi memuat datanya sendiri sehingga seksi yang lambat tidak menahan
13
- * seksi lain. Tombol reload memuat ulang keduanya.
5
+ * Gaya libadwaita (GNOME Settings), memakai primitif `settings/prefs`:
6
+ * tiap seksi = AdwPreferencesGroup (judul + deskripsi di luar kotak, aksi
7
+ * datar di kanan judul) berisi "boxed list". Baris = AdwActionRow /
8
+ * AdwExpanderRow; kolom cari & filter (GtkSearchEntry, AdwToggleGroup) ada di
9
+ * antara judul dan kotak.
10
+ *
11
+ * MCP & Skills dibaca dari KEDUA agent (OpenCode & Claude Code) lalu
12
+ * digabung per nama, sama seperti halaman Skills:
13
+ * - MCP: `GET /api/projects/:id/mcp?agent=` — status per agent (logo +
14
+ * label) di subjudul; yang bermasalah paling atas, error tampil inline.
15
+ * Server Claude Code dibaca dari config (`.mcp.json`, `~/.claude.json`);
16
+ * statusnya "Not checked" sampai ada Session Claude Code yang berjalan.
17
+ * - Skills: `GET /api/projects/:id/skills?agent=` — cari (bila banyak) +
18
+ * filter asal; logo agent yang memuat skill; baris dibuka untuk deskripsi
19
+ * lengkap & path. Tombol "+" membuka halaman Skills untuk Project ini.
20
+ * Satu agent gagal dibaca tidak menggagalkan yang lain (baris catatan).
21
+ * Tiap seksi memuat datanya sendiri; tombol reload memuat ulang keduanya.
14
22
  */
15
- import {
16
- ChevronDownIcon,
17
- PlugIcon,
18
- RefreshCwIcon,
19
- SearchIcon,
20
- SparklesIcon,
21
- XIcon,
22
- } from "lucide-react";
23
+ import { PlugIcon, PlusIcon, RefreshCwIcon, SparklesIcon, TriangleAlertIcon } from "lucide-react";
23
24
  import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
24
- import { PanelBody, PanelHint, PanelList, PanelSection } from "@/components/projects/PanelSection";
25
25
  import { ProjectInstructions } from "@/components/projects/ProjectInstructions";
26
+ import { AgentLogo } from "@/components/sessions/AgentLogo";
27
+ import { ActionRow, ExpanderRow, PrefsGroup, RowBadge } from "@/components/settings/prefs";
28
+ import { AgentBadges } from "@/components/skills/SkillCards";
29
+ import { AdwToggleGroup } from "@/components/ui/adw-toggle-group";
26
30
  import { Button } from "@/components/ui/button";
31
+ import { SearchEntry } from "@/components/ui/search-entry";
27
32
  import { Skeleton } from "@/components/ui/skeleton";
33
+ import { useRouter } from "@/hooks/useRouter";
34
+ import { AGENT_TYPE_LABELS, AGENT_TYPES, agentQuery } from "@/lib/agent-types";
28
35
  import { ApiError, apiFetch } from "@/lib/api";
29
36
  import {
30
- describeMcpSummary,
37
+ describeMcpEntries,
31
38
  filterSkills,
32
39
  groupSkills,
33
40
  MCP_STATUS_LABEL,
34
- SKILL_SOURCE_LABEL,
41
+ type McpServerEntry,
42
+ mergeMcpServers,
35
43
  } from "@/lib/project-extensions";
44
+ import { skillsPath } from "@/lib/routes";
45
+ import { type InstalledSkill, mergeInstalledSkills } from "@/lib/skills-catalog";
36
46
  import { cn } from "@/lib/utils";
37
47
  import type { McpServerInfo, SkillInfo, SkillSource } from "@/server/services/opencode-client";
38
- import type { Project } from "@/types";
48
+ import type { AgentType, McpServerState, Project } from "@/types";
39
49
 
40
50
  /** Skill ditampilkan terbatas dulu; sisanya lewat "Show more". */
41
51
  const SKILL_PREVIEW = 5;
42
- /** Kotak cari muncul hanya bila skill cukup banyak. */
52
+ /** Kolom cari muncul hanya bila skill cukup banyak. */
43
53
  const SKILL_SEARCH_THRESHOLD = 8;
44
54
 
55
+ /** Hasil per agent; `failed` = agent yang gagal dibaca (yang lain tetap tampil). */
56
+ interface AgentLists<T> {
57
+ byAgent: Partial<Record<AgentType, T[]>>;
58
+ failed: { agent: AgentType; message: string }[];
59
+ }
60
+
45
61
  type Load<T> =
46
62
  | { phase: "loading" }
47
63
  | { phase: "error"; message: string }
48
64
  | { phase: "ready"; data: T };
49
65
 
50
- /** Muat satu endpoint list; `reloadKey` berubah -> muat ulang. */
51
- function useProjectList<T>(url: string, key: string, reloadKey: number): Load<T> {
52
- const [state, setState] = useState<Load<T>>({ phase: "loading" });
66
+ /**
67
+ * Muat satu endpoint list untuk setiap agent (`?agent=`) bersamaan.
68
+ * Error hanya bila SEMUA agent gagal. `reloadKey` berubah -> muat ulang.
69
+ */
70
+ function useAgentLists<T>(path: string, key: string, reloadKey: number): Load<AgentLists<T>> {
71
+ const [state, setState] = useState<Load<AgentLists<T>>>({ phase: "loading" });
53
72
  useEffect(() => {
54
73
  // `reloadKey` hanya pemicu muat ulang.
55
74
  void reloadKey;
56
75
  let cancelled = false;
57
76
  setState({ phase: "loading" });
58
- apiFetch(url)
59
- .then(async (res) => {
60
- const body = (await res.json()) as Record<string, T>;
61
- if (!cancelled) setState({ phase: "ready", data: body[key] as T });
62
- })
63
- .catch((e: unknown) => {
64
- if (cancelled) return;
65
- setState({ phase: "error", message: e instanceof ApiError ? e.message : "Failed to load" });
77
+ void Promise.allSettled(
78
+ AGENT_TYPES.map((a) =>
79
+ apiFetch(`${path}${agentQuery(a)}`).then(async (res) => {
80
+ const body = (await res.json()) as Record<string, T[]>;
81
+ return body[key] ?? [];
82
+ }),
83
+ ),
84
+ ).then((results) => {
85
+ if (cancelled) return;
86
+ const data: AgentLists<T> = { byAgent: {}, failed: [] };
87
+ results.forEach((r, i) => {
88
+ const agent = AGENT_TYPES[i] as AgentType;
89
+ if (r.status === "fulfilled") data.byAgent[agent] = r.value;
90
+ else {
91
+ const message = r.reason instanceof ApiError ? r.reason.message : "Failed to load";
92
+ data.failed.push({ agent, message });
93
+ }
66
94
  });
95
+ if (data.failed.length === AGENT_TYPES.length) {
96
+ setState({ phase: "error", message: data.failed[0]?.message ?? "Failed to load" });
97
+ } else {
98
+ setState({ phase: "ready", data });
99
+ }
100
+ });
67
101
  return () => {
68
102
  cancelled = true;
69
103
  };
70
- }, [url, key, reloadKey]);
104
+ }, [path, key, reloadKey]);
71
105
  return state;
72
106
  }
73
107
 
@@ -79,168 +113,227 @@ export interface ProjectSidePanelProps {
79
113
  export function ProjectSidePanel({ project, onProjectChange }: ProjectSidePanelProps) {
80
114
  const [reloadKey, setReloadKey] = useState(0);
81
115
  const reload = useCallback(() => setReloadKey((k) => k + 1), []);
82
- const mcp = useProjectList<McpServerInfo[]>(`/api/projects/${project.id}/mcp`, "mcp", reloadKey);
83
- const skills = useProjectList<SkillInfo[]>(
116
+ const mcp = useAgentLists<McpServerInfo>(`/api/projects/${project.id}/mcp`, "mcp", reloadKey);
117
+ const skills = useAgentLists<SkillInfo>(
84
118
  `/api/projects/${project.id}/skills`,
85
119
  "skills",
86
120
  reloadKey,
87
121
  );
88
122
 
89
123
  return (
90
- <div className="flex flex-col gap-3">
124
+ <div className="flex flex-col gap-6">
91
125
  <ProjectInstructions project={project} onSaved={onProjectChange} />
92
- <McpSection state={mcp} onReload={reload} />
93
- <SkillsSection state={skills} />
126
+ <McpGroup state={mcp} onReload={reload} />
127
+ <SkillsGroup state={skills} projectId={project.id} />
94
128
  </div>
95
129
  );
96
130
  }
97
131
 
98
- function ListSkeleton({ rows = 3 }: { rows?: number }) {
132
+ // ------------------------------------------------------------- shared ----
133
+
134
+ /** Tombol datar bundar di kanan judul grup (aksi header AdwPreferencesGroup). */
135
+ function FlatIconButton({
136
+ label,
137
+ onClick,
138
+ disabled,
139
+ children,
140
+ }: {
141
+ label: string;
142
+ onClick: () => void;
143
+ disabled?: boolean;
144
+ children: ReactNode;
145
+ }) {
99
146
  return (
100
- <PanelList>
101
- {Array.from({ length: rows }, (_, i) => (
102
- // biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
103
- <li key={i} className="flex items-center gap-3 px-3.5 py-3" aria-hidden>
104
- <Skeleton className="size-7 shrink-0 rounded-md" />
105
- <div className="flex flex-1 flex-col gap-1.5">
106
- <Skeleton className="h-3.5" style={{ width: `${75 - i * 15}%` }} />
107
- <Skeleton className="h-3 w-1/3" />
108
- </div>
109
- </li>
110
- ))}
111
- </PanelList>
147
+ <Button
148
+ type="button"
149
+ variant="ghost"
150
+ size="icon"
151
+ onClick={onClick}
152
+ disabled={disabled}
153
+ aria-label={label}
154
+ title={label}
155
+ className="size-8 rounded-full text-foreground/80"
156
+ >
157
+ {children}
158
+ </Button>
112
159
  );
113
160
  }
114
161
 
115
- function ErrorBox({ message, onRetry }: { message: string; onRetry?: () => void }) {
162
+ /** Baris placeholder saat memuat — tinggi sama dengan ActionRow. */
163
+ function SkeletonRows({ rows }: { rows: number }) {
164
+ return Array.from({ length: rows }, (_, i) => (
165
+ // biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
166
+ <li key={i} className="flex min-h-14 flex-col justify-center gap-1.5 px-4 py-2.5" aria-hidden>
167
+ <Skeleton className="h-4" style={{ width: `${60 - i * 12}%` }} />
168
+ <Skeleton className="h-3 w-1/3" />
169
+ </li>
170
+ ));
171
+ }
172
+
173
+ /** Baris error dengan tombol coba lagi. */
174
+ function ErrorRow({ message, onRetry }: { message: string; onRetry?: () => void }) {
116
175
  return (
117
- <div className="flex items-center justify-between gap-3 bg-destructive/5 px-3.5 py-3">
118
- <p className="min-w-0 text-xs leading-relaxed text-destructive">{message}</p>
119
- {onRetry && (
120
- <Button type="button" variant="outline" size="xs" onClick={onRetry} className="shrink-0">
121
- Retry
122
- </Button>
123
- )}
124
- </div>
176
+ <ActionRow
177
+ prefix={<TriangleAlertIcon className="size-[18px] text-destructive" aria-hidden />}
178
+ title="Couldn't load"
179
+ subtitle={message}
180
+ suffix={
181
+ onRetry && (
182
+ <Button type="button" variant="outline" size="sm" onClick={onRetry}>
183
+ Retry
184
+ </Button>
185
+ )
186
+ }
187
+ />
125
188
  );
126
189
  }
127
190
 
128
- /** Ikon kotak kecil di awal baris daftar. */
129
- function RowIcon({ children, className }: { children: ReactNode; className?: string }) {
191
+ /** Baris catatan bila sebagian agent gagal dibaca. */
192
+ function PartialFailureRow({ failed }: { failed: AgentLists<unknown>["failed"] }) {
193
+ if (failed.length === 0) return null;
130
194
  return (
131
- <span
132
- className={cn(
133
- "flex size-7 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground [&_svg]:size-3.5",
134
- className,
135
- )}
136
- aria-hidden
137
- >
138
- {children}
139
- </span>
195
+ <ActionRow
196
+ prefix={
197
+ <TriangleAlertIcon className="size-[18px] text-amber-600 dark:text-amber-400" aria-hidden />
198
+ }
199
+ title={`Couldn't read ${failed.map((f) => AGENT_TYPE_LABELS[f.agent]).join(", ")}`}
200
+ subtitle={failed[0]?.message}
201
+ />
140
202
  );
141
203
  }
142
204
 
143
205
  // ---------------------------------------------------------------- MCP ----
144
206
 
145
- /** Yang butuh perhatian di atas: failed -> needs_* -> connected -> sisanya. */
146
- const MCP_ORDER: Record<McpServerInfo["status"], number> = {
147
- failed: 0,
148
- needs_auth: 1,
149
- needs_client_registration: 1,
150
- connected: 2,
151
- unknown: 3,
152
- disabled: 4,
207
+ /** Warna teks status MCP per agent. */
208
+ const MCP_TEXT: Record<McpServerState["status"], string> = {
209
+ connected: "text-emerald-700 dark:text-emerald-400",
210
+ failed: "text-destructive",
211
+ needs_auth: "text-amber-700 dark:text-amber-400",
212
+ needs_client_registration: "text-amber-700 dark:text-amber-400",
213
+ disabled: "text-muted-foreground",
214
+ unknown: "text-muted-foreground",
153
215
  };
154
216
 
155
- const MCP_PILL: Record<McpServerInfo["status"], string> = {
156
- connected: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
157
- failed: "bg-destructive/10 text-destructive",
158
- needs_auth: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
159
- needs_client_registration: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
160
- disabled: "bg-muted text-muted-foreground",
161
- unknown: "bg-muted text-muted-foreground",
217
+ /** Warna ikon baris = status paling bermasalah. */
218
+ const MCP_ICON: Record<McpServerState["status"], string> = {
219
+ connected: "text-emerald-600 dark:text-emerald-400",
220
+ failed: "text-destructive",
221
+ needs_auth: "text-amber-600 dark:text-amber-400",
222
+ needs_client_registration: "text-amber-600 dark:text-amber-400",
223
+ disabled: "text-muted-foreground/60",
224
+ unknown: "text-muted-foreground",
162
225
  };
163
226
 
164
- function McpSection({ state, onReload }: { state: Load<McpServerInfo[]>; onReload: () => void }) {
165
- const sorted = useMemo(
166
- () =>
167
- state.phase === "ready"
168
- ? [...state.data].sort(
169
- (a, b) => MCP_ORDER[a.status] - MCP_ORDER[b.status] || a.name.localeCompare(b.name),
170
- )
171
- : [],
227
+ /**
228
+ * Label status per agent. `unknown` di Claude Code berarti status belum
229
+ * pernah dilaporkan proses `claude` (server dibaca dari config).
230
+ */
231
+ function mcpAgentLabel(agent: AgentType, status: McpServerState["status"]): string {
232
+ if (agent === "claude-code" && status === "unknown") return "Not checked";
233
+ return MCP_STATUS_LABEL[status];
234
+ }
235
+
236
+ function McpGroup({
237
+ state,
238
+ onReload,
239
+ }: {
240
+ state: Load<AgentLists<McpServerInfo>>;
241
+ onReload: () => void;
242
+ }) {
243
+ const entries = useMemo(
244
+ () => (state.phase === "ready" ? mergeMcpServers(state.data.byAgent) : []),
172
245
  [state],
173
246
  );
174
247
 
175
248
  return (
176
- <PanelSection
177
- id="mcp"
249
+ <PrefsGroup
178
250
  title="MCP servers"
179
- icon={<PlugIcon />}
180
- meta={state.phase === "ready" ? describeMcpSummary(state.data) : undefined}
181
- action={
182
- <Button
183
- type="button"
184
- variant="ghost"
185
- size="icon-xs"
251
+ description={state.phase === "ready" ? describeMcpEntries(entries) : "Loading…"}
252
+ suffix={
253
+ <FlatIconButton
254
+ label="Reload MCP servers and skills"
186
255
  onClick={onReload}
187
256
  disabled={state.phase === "loading"}
188
- aria-label="Reload MCP servers and skills"
189
- title="Reload"
190
- className="size-8 text-muted-foreground"
191
257
  >
192
258
  <RefreshCwIcon className={cn(state.phase === "loading" && "animate-spin")} />
193
- </Button>
259
+ </FlatIconButton>
194
260
  }
195
261
  >
196
262
  {state.phase === "loading" ? (
197
- <ListSkeleton rows={2} />
263
+ <SkeletonRows rows={2} />
198
264
  ) : state.phase === "error" ? (
199
- <ErrorBox message={state.message} onRetry={onReload} />
200
- ) : sorted.length === 0 ? (
201
- <PanelHint>
202
- No MCP servers yet. Add them under <code className="font-mono">mcp</code> in
203
- opencode.json, then reload.
204
- </PanelHint>
265
+ <ErrorRow message={state.message} onRetry={onReload} />
205
266
  ) : (
206
- <PanelList>
207
- {sorted.map((server) => (
208
- <li key={server.name} className="flex items-start gap-3 px-3.5 py-3">
209
- <RowIcon
210
- className={cn(
211
- server.status === "connected" &&
212
- "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
213
- server.status === "failed" && "bg-destructive/10 text-destructive",
214
- )}
215
- >
216
- <PlugIcon />
217
- </RowIcon>
218
- <div className="flex min-w-0 flex-1 flex-col gap-1 pt-0.5">
219
- <div className="flex min-w-0 items-center justify-between gap-2">
220
- <span className="truncate text-sm font-medium leading-5">{server.name}</span>
221
- <span
222
- className={cn(
223
- "shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-4",
224
- MCP_PILL[server.status],
225
- )}
226
- >
227
- {MCP_STATUS_LABEL[server.status]}
228
- </span>
229
- </div>
230
- {server.error && (
231
- <p
232
- className="line-clamp-2 font-mono text-[11px] leading-relaxed break-all text-destructive/90"
233
- title={server.error}
234
- >
235
- {server.error}
236
- </p>
237
- )}
238
- </div>
239
- </li>
240
- ))}
241
- </PanelList>
267
+ <>
268
+ {entries.length === 0 ? (
269
+ <ActionRow
270
+ title="No MCP servers"
271
+ subtitle={
272
+ <>
273
+ Add them under <code className="font-mono">mcp</code> in opencode.json, or in{" "}
274
+ <code className="font-mono">.mcp.json</code> for Claude Code, then reload.
275
+ </>
276
+ }
277
+ />
278
+ ) : (
279
+ entries.map((entry) => <McpRow key={entry.name} entry={entry} />)
280
+ )}
281
+ <PartialFailureRow failed={state.data.failed} />
282
+ </>
242
283
  )}
243
- </PanelSection>
284
+ </PrefsGroup>
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Satu MCP server (AdwActionRow): ikon simbolik berwarna status, nama, lalu
290
+ * status per agent di subjudul — "logo Label" untuk tiap agent pemakai.
291
+ * Error tampil di bawahnya per agent.
292
+ */
293
+ function McpRow({ entry }: { entry: McpServerEntry }) {
294
+ const errors = entry.agents
295
+ .map((a) => ({ agent: a, error: entry.byAgent[a]?.error ?? null }))
296
+ .filter((e): e is { agent: AgentType; error: string } => e.error !== null);
297
+ return (
298
+ <ActionRow
299
+ prefix={<PlugIcon className={cn("size-[18px]", MCP_ICON[entry.status])} aria-hidden />}
300
+ title={<span className="block truncate">{entry.name}</span>}
301
+ subtitle={
302
+ <span className="flex flex-col gap-1">
303
+ <span className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
304
+ {entry.agents.map((a) => {
305
+ const status = entry.byAgent[a]?.status ?? "unknown";
306
+ const label = mcpAgentLabel(a, status);
307
+ return (
308
+ <span
309
+ key={a}
310
+ className={cn("inline-flex items-center gap-1.5", MCP_TEXT[status])}
311
+ title={
312
+ a === "claude-code" && status === "unknown"
313
+ ? "Configured for Claude Code. Status appears once a Claude Code session runs in this project."
314
+ : `${AGENT_TYPE_LABELS[a]}: ${label}`
315
+ }
316
+ >
317
+ <AgentLogo agentType={a} className="size-3" />
318
+ <span className="sr-only">{AGENT_TYPE_LABELS[a]}:</span>
319
+ {label}
320
+ </span>
321
+ );
322
+ })}
323
+ </span>
324
+ {errors.map((e) => (
325
+ <span
326
+ key={e.agent}
327
+ className="line-clamp-2 font-mono text-[11.5px] leading-relaxed break-all text-destructive/90"
328
+ title={e.error}
329
+ >
330
+ {entry.agents.length > 1 && `${AGENT_TYPE_LABELS[e.agent]}: `}
331
+ {e.error}
332
+ </span>
333
+ ))}
334
+ </span>
335
+ }
336
+ />
244
337
  );
245
338
  }
246
339
 
@@ -248,15 +341,34 @@ function McpSection({ state, onReload }: { state: Load<McpServerInfo[]>; onReloa
248
341
 
249
342
  type SkillFilter = "all" | SkillSource;
250
343
 
251
- function SkillsSection({ state }: { state: Load<SkillInfo[]> }) {
344
+ const SKILL_FILTER_LABEL: Record<SkillFilter, string> = {
345
+ all: "All",
346
+ project: "Project",
347
+ global: "Global",
348
+ builtin: "Built-in",
349
+ };
350
+
351
+ function SkillsGroup({
352
+ state,
353
+ projectId,
354
+ }: {
355
+ state: Load<AgentLists<SkillInfo>>;
356
+ projectId: string;
357
+ }) {
358
+ const { navigate } = useRouter();
252
359
  const [query, setQuery] = useState("");
253
360
  const [filter, setFilter] = useState<SkillFilter>("all");
254
361
  const [expanded, setExpanded] = useState(false);
255
362
 
256
- const all = state.phase === "ready" ? state.data : [];
363
+ // Skill yang sama di kedua agent jadi satu baris dengan dua logo.
364
+ const all = useMemo(
365
+ () => (state.phase === "ready" ? mergeInstalledSkills(state.data.byAgent) : []),
366
+ [state],
367
+ );
257
368
  // Urutan tampil: Project -> global -> bawaan (paling relevan dulu).
258
- const ordered = useMemo(() => groupSkills(all).flatMap((g) => g.skills), [all]);
259
- const sources = useMemo(() => groupSkills(all).map((g) => g.source), [all]);
369
+ const groups = useMemo(() => groupSkills(all), [all]);
370
+ const ordered = useMemo(() => groups.flatMap((g) => g.skills), [groups]);
371
+ const sources = useMemo(() => groups.map((g) => g.source), [groups]);
260
372
  const bySource = useMemo(
261
373
  () => (filter === "all" ? ordered : ordered.filter((s) => s.source === filter)),
262
374
  [ordered, filter],
@@ -266,183 +378,137 @@ function SkillsSection({ state }: { state: Load<SkillInfo[]> }) {
266
378
  // Saat mencari, tampilkan semua hasil; selain itu batasi hingga "Show more".
267
379
  const visible = searching || expanded ? filtered : filtered.slice(0, SKILL_PREVIEW);
268
380
  const hidden = filtered.length - visible.length;
381
+ const browse = () => navigate(skillsPath(projectId));
382
+
383
+ const showSearch = all.length >= SKILL_SEARCH_THRESHOLD;
384
+ const showFilter = sources.length > 1;
385
+ const toolbar =
386
+ state.phase === "ready" && (showSearch || showFilter) ? (
387
+ <div className="flex flex-col gap-2">
388
+ {showSearch && (
389
+ <SearchEntry
390
+ value={query}
391
+ onValueChange={setQuery}
392
+ placeholder={`Search ${all.length} skills`}
393
+ aria-label="Search skills"
394
+ />
395
+ )}
396
+ {showFilter && (
397
+ <AdwToggleGroup
398
+ label="Filter skills by source"
399
+ value={filter}
400
+ onChange={(f) => {
401
+ setFilter(f);
402
+ setExpanded(false);
403
+ }}
404
+ options={(["all", ...sources] as SkillFilter[]).map((f) => {
405
+ const count = f === "all" ? all.length : all.filter((s) => s.source === f).length;
406
+ return {
407
+ value: f,
408
+ label: SKILL_FILTER_LABEL[f],
409
+ title: `${SKILL_FILTER_LABEL[f]}: ${count}`,
410
+ ariaLabel: `${SKILL_FILTER_LABEL[f]}, ${count} skills`,
411
+ };
412
+ })}
413
+ className="[&_button]:text-[13px]"
414
+ />
415
+ )}
416
+ </div>
417
+ ) : undefined;
269
418
 
270
419
  return (
271
- <PanelSection
272
- id="skills"
420
+ <PrefsGroup
273
421
  title="Skills"
274
- icon={<SparklesIcon />}
275
- meta={
422
+ description={
276
423
  state.phase === "ready"
277
424
  ? all.length === 0
278
- ? "None found"
279
- : `${all.length} available`
280
- : undefined
425
+ ? "None installed"
426
+ : `${all.length} available${filter !== "all" || searching ? ` · ${filtered.length} shown` : ""}`
427
+ : "Loading…"
428
+ }
429
+ suffix={
430
+ <FlatIconButton label="Browse and install skills" onClick={browse}>
431
+ <PlusIcon />
432
+ </FlatIconButton>
281
433
  }
434
+ toolbar={toolbar}
282
435
  >
283
436
  {state.phase === "loading" ? (
284
- <ListSkeleton rows={3} />
437
+ <SkeletonRows rows={3} />
285
438
  ) : state.phase === "error" ? (
286
- <ErrorBox message={state.message} />
287
- ) : all.length === 0 ? (
288
- <PanelHint>
289
- No skills found. Add a <code className="font-mono">SKILL.md</code> under{" "}
290
- <code className="font-mono">.opencode/skills/</code>.
291
- </PanelHint>
439
+ <ErrorRow message={state.message} />
292
440
  ) : (
293
- <div className="flex flex-col">
294
- {(all.length >= SKILL_SEARCH_THRESHOLD || sources.length > 1) && (
295
- <PanelBody className="flex flex-col gap-2.5 border-b">
296
- {all.length >= SKILL_SEARCH_THRESHOLD && (
297
- <div className="relative">
298
- <SearchIcon
299
- className="pointer-events-none absolute top-1/2 left-3 size-3.5 -translate-y-1/2 text-muted-foreground"
300
- aria-hidden
301
- />
302
- <input
303
- type="search"
304
- value={query}
305
- onChange={(e) => setQuery(e.target.value)}
306
- onKeyDown={(e) => e.key === "Escape" && setQuery("")}
307
- placeholder={`Search ${all.length} skills`}
308
- aria-label="Search skills"
309
- className="h-9 w-full rounded-lg border bg-background pr-8 pl-8.5 text-sm outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-webkit-search-cancel-button]:hidden"
310
- />
311
- {query && (
312
- <button
313
- type="button"
314
- onClick={() => setQuery("")}
315
- aria-label="Clear search"
316
- className="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-foreground"
317
- >
318
- <XIcon className="size-3.5" />
319
- </button>
320
- )}
321
- </div>
322
- )}
323
-
324
- {sources.length > 1 && (
325
- <fieldset
326
- aria-label="Filter skills by source"
327
- className="m-0 flex flex-wrap gap-1 border-0 p-0"
328
- >
329
- {(["all", ...sources] as SkillFilter[]).map((f) => {
330
- const count =
331
- f === "all" ? all.length : all.filter((s) => s.source === f).length;
332
- const active = filter === f;
333
- return (
334
- <button
335
- key={f}
336
- type="button"
337
- aria-pressed={active}
338
- onClick={() => {
339
- setFilter(f);
340
- setExpanded(false);
341
- }}
342
- className={cn(
343
- "inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
344
- active
345
- ? "bg-foreground text-background"
346
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
347
- )}
348
- >
349
- {f === "all" ? "All" : SKILL_SOURCE_LABEL[f]}
350
- <span className={cn("tabular-nums", active ? "opacity-70" : "opacity-60")}>
351
- {count}
352
- </span>
353
- </button>
354
- );
355
- })}
356
- </fieldset>
357
- )}
358
- </PanelBody>
359
- )}
360
-
361
- {visible.length === 0 ? (
362
- <PanelHint>No skills match "{query.trim()}".</PanelHint>
441
+ <>
442
+ {all.length === 0 ? (
443
+ <ActionRow
444
+ prefix={<SparklesIcon className="size-[18px] text-muted-foreground" aria-hidden />}
445
+ title="Browse skills"
446
+ subtitle="Install one from skills.sh for OpenCode, Claude Code, or both"
447
+ onActivate={browse}
448
+ />
449
+ ) : visible.length === 0 ? (
450
+ <li className="px-4 py-4 text-center text-[13px] text-muted-foreground">
451
+ No skills match “{query.trim()}”.
452
+ </li>
363
453
  ) : (
364
- <PanelList>
365
- {visible.map((s) => (
366
- <SkillRow key={`${s.source}:${s.name}`} skill={s} showSource={filter === "all"} />
367
- ))}
368
- {!searching && filtered.length > SKILL_PREVIEW && (
369
- <li>
370
- <button
371
- type="button"
372
- onClick={() => setExpanded((v) => !v)}
373
- aria-expanded={expanded}
374
- className="flex w-full items-center justify-center gap-1.5 px-3.5 py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground focus-visible:bg-muted/50 focus-visible:outline-none"
375
- >
376
- {expanded ? "Show less" : `Show ${hidden} more`}
377
- <ChevronDownIcon
378
- className={cn("size-3.5 transition-transform", expanded && "rotate-180")}
379
- aria-hidden
380
- />
381
- </button>
382
- </li>
383
- )}
384
- </PanelList>
454
+ visible.map((s) => (
455
+ <SkillRow key={`${s.source}:${s.name}`} skill={s} showSource={filter === "all"} />
456
+ ))
385
457
  )}
386
- </div>
458
+ {!searching && filtered.length > SKILL_PREVIEW && (
459
+ <li>
460
+ <button
461
+ type="button"
462
+ onClick={() => setExpanded((v) => !v)}
463
+ aria-expanded={expanded}
464
+ className="flex min-h-12 w-full items-center justify-center px-4 text-[15px] transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset active:bg-muted"
465
+ >
466
+ {expanded ? "Show less" : `Show ${hidden} more`}
467
+ </button>
468
+ </li>
469
+ )}
470
+ <PartialFailureRow failed={state.data.failed} />
471
+ </>
387
472
  )}
388
- </PanelSection>
473
+ </PrefsGroup>
389
474
  );
390
475
  }
391
476
 
392
477
  /**
393
- * Satu skill: ikon, nama (+ label asal), deskripsi 1 baris. Klik baris untuk
394
- * membuka deskripsi lengkap dan path SKILL.md.
478
+ * Satu skill (AdwExpanderRow): nama (+ label asal), deskripsi 1 baris, logo
479
+ * agent pemakai. Dibuka -> deskripsi lengkap & path SKILL.md.
395
480
  */
396
- function SkillRow({ skill, showSource }: { skill: SkillInfo; showSource: boolean }) {
397
- const [open, setOpen] = useState(false);
398
- const expandable = Boolean(skill.description || skill.location);
481
+ function SkillRow({ skill, showSource }: { skill: InstalledSkill; showSource: boolean }) {
482
+ const details =
483
+ skill.description || skill.location ? (
484
+ <div className="flex flex-col gap-2">
485
+ {skill.description && (
486
+ <p className="text-[13px] leading-relaxed whitespace-pre-line">{skill.description}</p>
487
+ )}
488
+ {skill.location && (
489
+ <p
490
+ className="truncate font-mono text-[11.5px] text-muted-foreground"
491
+ title={skill.location}
492
+ >
493
+ {skill.location}
494
+ </p>
495
+ )}
496
+ </div>
497
+ ) : undefined;
399
498
  return (
400
- <li>
401
- <button
402
- type="button"
403
- onClick={() => expandable && setOpen((v) => !v)}
404
- aria-expanded={expandable ? open : undefined}
405
- disabled={!expandable}
406
- className="flex w-full items-start gap-3 px-3.5 py-3 text-left transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none disabled:cursor-default disabled:hover:bg-transparent"
407
- >
408
- <RowIcon className={cn(skill.source === "project" && "bg-primary/10 text-primary")}>
409
- <SparklesIcon />
410
- </RowIcon>
411
- <span className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
412
- <span className="flex min-w-0 items-center gap-1.5">
413
- <span className="truncate text-sm font-medium leading-5">{skill.name}</span>
414
- {showSource && skill.source !== "global" && (
415
- <span className="shrink-0 rounded bg-muted px-1.5 text-[10px] font-medium leading-4 text-muted-foreground">
416
- {skill.source === "project" ? "Project" : "Built-in"}
417
- </span>
418
- )}
419
- </span>
420
- {skill.description && (
421
- <span
422
- className={cn(
423
- "text-xs leading-relaxed text-muted-foreground",
424
- open ? "whitespace-pre-line" : "line-clamp-1",
425
- )}
426
- >
427
- {skill.description}
428
- </span>
429
- )}
430
- {open && skill.location && (
431
- <span className="mt-1 truncate font-mono text-[11px] text-muted-foreground/80">
432
- {skill.location}
433
- </span>
499
+ <ExpanderRow
500
+ title={
501
+ <>
502
+ <span className="truncate">{skill.name}</span>
503
+ {showSource && skill.source !== "global" && (
504
+ <RowBadge>{skill.source === "project" ? "Project" : "Built-in"}</RowBadge>
434
505
  )}
435
- </span>
436
- {expandable && (
437
- <ChevronDownIcon
438
- className={cn(
439
- "mt-1.5 size-3.5 shrink-0 text-muted-foreground/60 transition-transform",
440
- open && "rotate-180",
441
- )}
442
- aria-hidden
443
- />
444
- )}
445
- </button>
446
- </li>
506
+ </>
507
+ }
508
+ subtitle={skill.description ?? undefined}
509
+ suffix={<AgentBadges agents={skill.agents} size="sm" />}
510
+ >
511
+ {details}
512
+ </ExpanderRow>
447
513
  );
448
514
  }