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
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Logo backend Session (OpenCode / Claude Code) untuk pemilih agent.
3
+ *
4
+ * - OpenCode : tanda "frame" dari favicon resmi opencode.ai, digambar ulang
5
+ * dengan `currentColor` agar mengikuti tema terang/gelap.
6
+ * - Claude : tanda Claude (simple-icons) dengan warna brand #D97757.
7
+ */
8
+ import { cn } from "@/lib/utils";
9
+ import type { AgentType } from "@/types";
10
+
11
+ const CLAUDE_PATH =
12
+ "m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z";
13
+
14
+ export function AgentLogo({ agentType, className }: { agentType: AgentType; className?: string }) {
15
+ if (agentType === "claude-code") {
16
+ return (
17
+ <svg
18
+ viewBox="0 0 24 24"
19
+ aria-hidden="true"
20
+ className={cn("size-4 shrink-0", className)}
21
+ fill="#D97757"
22
+ >
23
+ <path d={CLAUDE_PATH} />
24
+ </svg>
25
+ );
26
+ }
27
+ return (
28
+ <svg
29
+ viewBox="96 96 320 320"
30
+ aria-hidden="true"
31
+ className={cn("size-4 shrink-0", className)}
32
+ fill="currentColor"
33
+ >
34
+ <path d="M320 224V352H192V224H320Z" opacity={0.4} />
35
+ <path
36
+ fillRule="evenodd"
37
+ clipRule="evenodd"
38
+ d="M384 416H128V96H384V416ZM320 160H192V352H320V160Z"
39
+ />
40
+ </svg>
41
+ );
42
+ }
@@ -22,9 +22,12 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
22
22
  import { Spinner } from "@/components/ui/spinner";
23
23
  import { useAgentPicker } from "@/hooks/useAgentPicker";
24
24
  import { cn } from "@/lib/utils";
25
+ import type { AgentType } from "@/types";
25
26
 
26
27
  export interface AgentPickerProps {
27
28
  projectId: string;
29
+ /** Backend Session — Claude Code menawarkan mode permission. */
30
+ agentType?: AgentType;
28
31
  /** null = Session belum ada (composer homepage): pilihan tidak di-PUT. */
29
32
  sessionId: string | null;
30
33
  /** Agent aktif saat ini; null = default opencode (build). */
@@ -39,6 +42,7 @@ export interface AgentPickerProps {
39
42
 
40
43
  export function AgentPicker({
41
44
  projectId,
45
+ agentType = "opencode",
42
46
  sessionId,
43
47
  agent,
44
48
  onChanged,
@@ -53,6 +57,7 @@ export function AgentPicker({
53
57
  onChanged(name);
54
58
  setOpen(false);
55
59
  },
60
+ agentType,
56
61
  );
57
62
 
58
63
  const isPlan = agent?.toLowerCase() === "plan";
@@ -99,14 +104,18 @@ export function AgentPicker({
99
104
  side="top"
100
105
  sideOffset={8}
101
106
  collisionPadding={12}
102
- className="w-80 p-0"
107
+ // Tinggi SELURUH popover (header + daftar) dibatasi ruang yang
108
+ // tersedia (Radix). Bila hanya daftar yang dibatasi, header membuat
109
+ // popover selalu sedikit melebihi ruang -> floating-ui membalik sisi
110
+ // atas/bawah, tinggi daftar ikut berubah, dan ResizeObserver berputar
111
+ // ("ResizeObserver loop completed…") saat "More modes" dibuka.
112
+ className="flex max-h-[min(26rem,var(--radix-popover-content-available-height))] w-80 flex-col overflow-hidden p-0"
103
113
  >
104
- <div className="border-b px-3 pt-2.5 pb-2">
114
+ <div className="shrink-0 border-b px-3 pt-2.5 pb-2">
105
115
  <p className="text-sm font-medium">Mode</p>
106
116
  <p className="text-xs text-muted-foreground">How the agent handles your next message</p>
107
117
  </div>
108
- {/* Tinggi menyesuaikan ruang di atas trigger (Radix), dibatasi 22rem. */}
109
- <div className="max-h-[min(22rem,var(--radix-popover-content-available-height))] overflow-y-auto overscroll-contain p-1.5 [scrollbar-width:thin]">
118
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5 [scrollbar-width:thin]">
110
119
  <AgentModeList
111
120
  agents={agents}
112
121
  loading={loading}
@@ -1,9 +1,11 @@
1
1
  /**
2
- * Konfirmasi hapus Session permanen (dipakai header chat `SessionView` dan
3
- * menu baris `SessionList`). Menghapus juga riwayat percakapan di server
4
- * opencode — tidak bisa dibatalkan.
2
+ * Konfirmasi hapus Session permanen (dipakai header chat `SessionView`, menu
3
+ * baris `SessionList`, dan hapus massal selection mode). Menghapus juga
4
+ * riwayat percakapan di agent (opencode / Claude Code) — tidak bisa
5
+ * dibatalkan. `count` > 1 = hapus massal; `progress` menampilkan kemajuan.
5
6
  */
6
7
 
8
+ import { useState } from "react";
7
9
  import {
8
10
  AlertDialog,
9
11
  AlertDialogAction,
@@ -15,6 +17,7 @@ import {
15
17
  AlertDialogTitle,
16
18
  } from "@/components/ui/alert-dialog";
17
19
  import { Spinner } from "@/components/ui/spinner";
20
+ import { pluralSessions } from "@/lib/session-selection";
18
21
 
19
22
  export interface ConfirmSessionDeleteDialogProps {
20
23
  open: boolean;
@@ -23,6 +26,10 @@ export interface ConfirmSessionDeleteDialogProps {
23
26
  deleting: boolean;
24
27
  /** Jalankan penghapusan (engine). */
25
28
  onConfirm: () => void;
29
+ /** Jumlah Session yang dihapus (default 1). */
30
+ count?: number;
31
+ /** Kemajuan hapus massal. */
32
+ progress?: { done: number; total: number } | null;
26
33
  }
27
34
 
28
35
  export function ConfirmSessionDeleteDialog({
@@ -30,7 +37,14 @@ export function ConfirmSessionDeleteDialog({
30
37
  onOpenChange,
31
38
  deleting,
32
39
  onConfirm,
40
+ count = 1,
41
+ progress = null,
33
42
  }: ConfirmSessionDeleteDialogProps) {
43
+ // Jumlah dibekukan selama animasi tutup: setelah hapus massal pilihan
44
+ // sudah kosong, tapi teks dialog yang memudar tidak boleh berganti.
45
+ const [shownCount, setShownCount] = useState(count);
46
+ if (open && count !== shownCount) setShownCount(count);
47
+ const many = shownCount > 1;
34
48
  return (
35
49
  <AlertDialog
36
50
  open={open}
@@ -40,10 +54,12 @@ export function ConfirmSessionDeleteDialog({
40
54
  >
41
55
  <AlertDialogContent>
42
56
  <AlertDialogHeader>
43
- <AlertDialogTitle>Delete this session?</AlertDialogTitle>
57
+ <AlertDialogTitle>
58
+ {many ? `Delete ${pluralSessions(shownCount)}?` : "Delete this session?"}
59
+ </AlertDialogTitle>
44
60
  <AlertDialogDescription>
45
- The conversation history on the opencode server will also be permanently deleted. This
46
- action cannot be undone.
61
+ {many ? "Their conversation histories" : "The conversation history"} will also be
62
+ permanently deleted from the agent. This action cannot be undone.
47
63
  </AlertDialogDescription>
48
64
  </AlertDialogHeader>
49
65
  <AlertDialogFooter>
@@ -59,8 +75,12 @@ export function ConfirmSessionDeleteDialog({
59
75
  {deleting ? (
60
76
  <>
61
77
  <Spinner data-icon="inline-start" />
62
- Deleting…
78
+ {progress && progress.total > 1
79
+ ? `Deleting ${Math.min(progress.done + 1, progress.total)} of ${progress.total}…`
80
+ : "Deleting…"}
63
81
  </>
82
+ ) : many ? (
83
+ `Delete ${pluralSessions(shownCount)}`
64
84
  ) : (
65
85
  "Delete permanently"
66
86
  )}
@@ -1,21 +1,23 @@
1
1
  /**
2
- * Kartu konteks Session — keadaan kerja agent, MENGAPUNG di margin kanan.
3
- * Tidak mengambil ruang layout, jadi percakapan & input tidak bergeser saat
4
- * kartu dibuka/ditutup.
2
+ * Kartu konteks Session — keadaan kerja agent, sebagai kolom di kanan
3
+ * percakapan (desktop). Kolom ini ikut tata letak: chat menyempit saat kartu
4
+ * dibuka, sehingga pesan & input tidak pernah tertutup kartu.
5
5
  *
6
6
  * Susunan (dari yang paling sering dilirik ke yang paling jarang):
7
7
  * - **Environment** : perubahan file (+/-), cabang git, status push/pull.
8
- * - **MCP** : server MCP Project; ringkasan "x of y connected" di
9
- * judul supaya status terbaca tanpa membuka bagiannya.
10
8
  * - **Task list** : `todowrite` agent + progres "selesai / total".
9
+ * - **MCP** : server MCP; ringkasan "x/y connected" di judul. Yang
10
+ * gagal & tersambung tampil per baris; yang perlu login
11
+ * (mis. puluhan konektor claude.ai) dan yang tidak aktif
12
+ * DILIPAT jadi satu baris "N need sign-in" agar tidak
13
+ * menenggelamkan bagian lain.
11
14
  * - **Reference** : file project yang DIBACA agent (bukan yang ditulis),
12
15
  * terbaru di atas; 8 pertama tampil, sisanya "Show N more".
13
16
  *
14
- * Pola UX:
15
- * - Tiap bagian = judul yang bisa dilipat + ringkasan di kanan judul, jadi
16
- * kartu tetap informatif walau semua bagian dilipat.
17
- * - Tanpa judul kartu & tanpa kotak di dalam: judul tiap bagian (warna
18
- * solid) menjadi penanda, dipisah garis tipis antar bagian.
17
+ * Pola UX (gaya libadwaita):
18
+ * - Tiap bagian = judul yang bisa dilipat + ringkasan di kanan judul, isi
19
+ * berupa "boxed list" (kartu membulat, baris berpemisah) di atas latar
20
+ * kartu yang lebih gelap — seperti AdwPreferencesGroup.
19
21
  * - Status diwakili titik berwarna + label, bukan warna saja (a11y).
20
22
  * - Buka/tutup: `ContextPanelTrigger` (hamburger) HILANG saat kartu terbuka;
21
23
  * penutupnya tombol X di kartu. Esc juga menutup.
@@ -97,21 +99,16 @@ function ContextSections({
97
99
  }: Omit<ContextPanelProps, "onClose"> & { reserveClose?: boolean }) {
98
100
  if (loading && context === null) return <CardSkeleton />;
99
101
  return (
100
- <div
101
- className={cn(
102
- "flex flex-col divide-y [&>*]:py-2.5 [&>*:first-child]:pt-0 [&>*:last-child]:pb-0.5",
103
- reserveClose && "[&>*:first-child>button]:pe-7",
104
- )}
105
- >
102
+ <div className={cn("flex flex-col gap-4", reserveClose && "[&>*:first-child>button]:pe-7")}>
106
103
  <EnvironmentSection context={context} />
107
- <McpSection servers={context?.mcp ?? []} live={context?.live ?? false} />
108
104
  <TaskSection todos={context?.todos ?? []} live={context?.live ?? false} />
105
+ <McpSection servers={context?.mcp ?? []} live={context?.live ?? false} />
109
106
  <ReferenceSection references={references} />
110
107
  </div>
111
108
  );
112
109
  }
113
110
 
114
- /** Kartu desktop: mengapung di margin kanan. */
111
+ /** Kartu desktop: kolom kanan, berdampingan dengan percakapan. */
115
112
  export function ContextPanel({ context, references, loading, onClose }: ContextPanelProps) {
116
113
  // Esc menutup kartu (pola sama dengan popover/sheet aplikasi).
117
114
  useEffect(() => {
@@ -128,7 +125,9 @@ export function ContextPanel({ context, references, loading, onClose }: ContextP
128
125
  initial={{ opacity: 0, x: 10 }}
129
126
  animate={{ opacity: 1, x: 0 }}
130
127
  transition={{ type: "spring", visualDuration: 0.25, bounce: 0.1 }}
131
- className="pointer-events-auto relative flex max-h-full flex-col overflow-hidden rounded-2xl border bg-popover shadow-lg"
128
+ // Latar kartu = latar jendela (lebih gelap), boxed list di atasnya
129
+ // memakai `bg-card` — kontras ala AdwPreferencesPage.
130
+ className="relative flex max-h-full flex-col overflow-hidden rounded-2xl border bg-background shadow-xs"
132
131
  >
133
132
  {/* Tanpa judul kartu: judul tiap bagian sudah menjadi penanda. Tombol
134
133
  tutup mengapung di pojok kanan atas, sejajar judul bagian pertama. */}
@@ -144,7 +143,7 @@ export function ContextPanel({ context, references, loading, onClose }: ContextP
144
143
  <XIcon />
145
144
  </Button>
146
145
 
147
- <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 pt-1.5 pb-2 [scrollbar-width:thin]">
146
+ <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2.5 pt-2 pb-3 [scrollbar-width:thin]">
148
147
  <ContextSections context={context} references={references} loading={loading} reserveClose />
149
148
  </div>
150
149
  </m.aside>
@@ -224,29 +223,34 @@ function EnvironmentSection({ context }: { context: SessionContext | null }) {
224
223
  );
225
224
  }
226
225
 
227
- /** Urutan tampil MCP: yang bermasalah dulu agar langsung terlihat. */
228
- const MCP_ORDER: Record<McpServerState["status"], number> = {
229
- failed: 0,
230
- needs_auth: 1,
231
- needs_client_registration: 2,
232
- connected: 3,
233
- unknown: 4,
234
- disabled: 5,
235
- };
226
+ /** Status yang "perlu login" — dilipat jadi satu baris bila lebih dari satu. */
227
+ const NEEDS_SIGN_IN: McpServerState["status"][] = ["needs_auth", "needs_client_registration"];
236
228
 
237
229
  function McpSection({ servers, live }: { servers: McpServerState[]; live: boolean }) {
238
230
  const connected = servers.filter((s) => s.status === "connected").length;
239
- const hasProblem = servers.some((s) => MCP_ORDER[s.status] <= 2);
240
- const sorted = [...servers].sort(
241
- (a, b) => MCP_ORDER[a.status] - MCP_ORDER[b.status] || a.name.localeCompare(b.name),
242
- );
231
+ const failed = servers.filter((s) => s.status === "failed").length;
232
+ const byName = (a: McpServerState, b: McpServerState) => a.name.localeCompare(b.name);
233
+ const signIn = servers.filter((s) => NEEDS_SIGN_IN.includes(s.status)).sort(byName);
234
+ const inactive = servers
235
+ .filter((s) => s.status === "disabled" || s.status === "unknown")
236
+ .sort(byName);
237
+ // Baris individual: gagal (paling atas) lalu tersambung. Kelompok berisi
238
+ // satu server tetap tampil sebagai baris biasa (tidak perlu dilipat).
239
+ const individual = [
240
+ ...servers.filter((s) => s.status === "failed").sort(byName),
241
+ ...servers.filter((s) => s.status === "connected").sort(byName),
242
+ ...(signIn.length === 1 ? signIn : []),
243
+ ...(inactive.length === 1 ? inactive : []),
244
+ ];
243
245
 
244
246
  return (
245
247
  <Section
246
248
  title="MCP"
247
249
  summary={
248
250
  servers.length > 0 ? (
249
- <span className={cn(hasProblem && "text-amber-700 dark:text-amber-400")}>
251
+ // Hanya kegagalan yang diberi warna peringatan — konektor yang
252
+ // belum login itu normal, bukan masalah.
253
+ <span className={cn(failed > 0 && "text-amber-700 dark:text-amber-400")}>
250
254
  {connected}/{servers.length} connected
251
255
  </span>
252
256
  ) : undefined
@@ -256,30 +260,104 @@ function McpSection({ servers, live }: { servers: McpServerState[]; live: boolea
256
260
  <Empty>{live ? "No MCP servers configured" : "Start the session to see MCP servers"}</Empty>
257
261
  ) : (
258
262
  <Box>
259
- {sorted.map((server) => (
260
- <Row
261
- key={server.name}
262
- icon={<PlugIcon />}
263
- label={server.name}
264
- // Pesan error lengkap terbaca lewat `title` (tanpa memanjangkan baris).
265
- title={server.error ?? undefined}
266
- value={
267
- <span className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
268
- <span
269
- aria-hidden
270
- className={cn("size-1.5 rounded-full", MCP_STATUS_DOT[server.status])}
271
- />
272
- {MCP_STATUS_LABEL[server.status]}
273
- </span>
274
- }
275
- />
263
+ {individual.map((server) => (
264
+ <McpRow key={server.name} server={server} />
276
265
  ))}
266
+ {signIn.length > 1 && (
267
+ <McpGroupRow
268
+ label={`${signIn.length} need sign-in`}
269
+ dotClass={MCP_STATUS_DOT.needs_auth}
270
+ servers={signIn}
271
+ />
272
+ )}
273
+ {inactive.length > 1 && (
274
+ <McpGroupRow
275
+ label={`${inactive.length} inactive`}
276
+ dotClass={MCP_STATUS_DOT.disabled}
277
+ servers={inactive}
278
+ />
279
+ )}
277
280
  </Box>
278
281
  )}
279
282
  </Section>
280
283
  );
281
284
  }
282
285
 
286
+ /** Satu server MCP: nama + status (titik + label); error di baris kedua. */
287
+ function McpRow({ server }: { server: McpServerState }) {
288
+ return (
289
+ <Row
290
+ icon={<PlugIcon />}
291
+ label={server.name}
292
+ hint={server.error ?? undefined}
293
+ hintTone={server.error ? "error" : undefined}
294
+ // Pesan error lengkap terbaca lewat `title` (baris kedua terpotong).
295
+ title={server.error ?? undefined}
296
+ value={<StatusText status={server.status} />}
297
+ />
298
+ );
299
+ }
300
+
301
+ function StatusText({ status }: { status: McpServerState["status"] }) {
302
+ return (
303
+ <span className="flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
304
+ <span aria-hidden className={cn("size-1.5 rounded-full", MCP_STATUS_DOT[status])} />
305
+ {MCP_STATUS_LABEL[status]}
306
+ </span>
307
+ );
308
+ }
309
+
310
+ /**
311
+ * Baris kelompok yang bisa dibuka (AdwExpanderRow): "17 need sign-in" —
312
+ * isinya nama server, tanpa status berulang (sudah di judul baris).
313
+ */
314
+ function McpGroupRow({
315
+ label,
316
+ dotClass,
317
+ servers,
318
+ }: {
319
+ label: string;
320
+ dotClass: string;
321
+ servers: McpServerState[];
322
+ }) {
323
+ const [open, setOpen] = useState(false);
324
+ return (
325
+ <li>
326
+ <button
327
+ type="button"
328
+ onClick={() => setOpen((v) => !v)}
329
+ aria-expanded={open}
330
+ className="flex min-h-9 w-full min-w-0 items-center gap-2 px-2.5 py-1.5 text-left text-[12.5px] outline-none transition-colors hover:bg-muted/50 focus-visible:bg-muted/50"
331
+ >
332
+ <span aria-hidden className="flex w-3.5 shrink-0 justify-center">
333
+ <span className={cn("size-1.5 rounded-full", dotClass)} />
334
+ </span>
335
+ <span className="min-w-0 flex-1 truncate leading-5 text-muted-foreground">{label}</span>
336
+ <ChevronDownIcon
337
+ aria-hidden
338
+ className={cn(
339
+ "size-3.5 shrink-0 text-muted-foreground transition-transform duration-200",
340
+ open && "rotate-180",
341
+ )}
342
+ />
343
+ </button>
344
+ {open && (
345
+ <ul className="border-t bg-muted/30 py-1">
346
+ {servers.map((s) => (
347
+ <li
348
+ key={s.name}
349
+ title={s.error ?? s.name}
350
+ className="truncate px-2.5 py-1 ps-8 text-[12px] leading-5 text-muted-foreground"
351
+ >
352
+ {s.name}
353
+ </li>
354
+ ))}
355
+ </ul>
356
+ )}
357
+ </li>
358
+ );
359
+ }
360
+
283
361
  function TaskSection({ todos, live }: { todos: TodoItem[]; live: boolean }) {
284
362
  const done = todos.filter((t) => t.status.toLowerCase() === "completed").length;
285
363
  const active = todos.filter((t) => t.status.toLowerCase() !== "cancelled").length;
@@ -357,13 +435,13 @@ function ReferenceSection({ references }: { references: FileReference[] }) {
357
435
  type="button"
358
436
  onClick={() => setShowAll((v) => !v)}
359
437
  aria-expanded={showAll}
360
- className="mt-0.5 flex w-full items-center gap-1 rounded-md px-1 py-1 text-left text-[12px] text-muted-foreground outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40"
438
+ className="mt-1.5 flex w-full items-center justify-center gap-1 rounded-lg py-1.5 text-[12px] text-muted-foreground outline-none transition-colors hover:bg-muted/50 hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40"
361
439
  >
440
+ {showAll ? "Show less" : `Show ${hidden} more`}
362
441
  <ChevronDownIcon
363
442
  aria-hidden
364
443
  className={cn("size-3 transition-transform duration-200", showAll && "rotate-180")}
365
444
  />
366
- {showAll ? "Show less" : `Show ${hidden} more`}
367
445
  </button>
368
446
  )}
369
447
  </>
@@ -432,9 +510,13 @@ function Section({
432
510
  );
433
511
  }
434
512
 
435
- /** Daftar baris polos (tanpa kotak/garis — hemat ruang). */
513
+ /** Boxed list (libadwaita): kartu membulat, baris dipisah garis tipis. */
436
514
  function Box({ children }: { children: ReactNode }) {
437
- return <ul className="flex flex-col">{children}</ul>;
515
+ return (
516
+ <ul className="flex flex-col divide-y overflow-hidden rounded-xl border bg-card shadow-xs">
517
+ {children}
518
+ </ul>
519
+ );
438
520
  }
439
521
 
440
522
  /** Satu baris dalam `Box`: ikon + label (+ petunjuk kecil) + nilai di kanan. */
@@ -442,6 +524,7 @@ function Row({
442
524
  icon,
443
525
  label,
444
526
  hint,
527
+ hintTone,
445
528
  value,
446
529
  muted,
447
530
  mono,
@@ -450,13 +533,18 @@ function Row({
450
533
  icon: ReactNode;
451
534
  label: string;
452
535
  hint?: string;
536
+ /** Warna baris kedua (mis. pesan error). */
537
+ hintTone?: "error";
453
538
  value?: ReactNode;
454
539
  muted?: boolean;
455
540
  mono?: boolean;
456
541
  title?: string;
457
542
  }) {
458
543
  return (
459
- <li title={title} className="flex min-h-8 min-w-0 items-center gap-2 px-1 py-1 text-[12.5px]">
544
+ <li
545
+ title={title}
546
+ className="flex min-h-9 min-w-0 items-center gap-2 px-2.5 py-1.5 text-[12.5px]"
547
+ >
460
548
  <span
461
549
  aria-hidden
462
550
  className="flex shrink-0 items-center text-muted-foreground [&>svg]:size-3.5"
@@ -474,7 +562,14 @@ function Row({
474
562
  {label}
475
563
  </span>
476
564
  {hint && (
477
- <span className="truncate text-[11px] leading-4 text-muted-foreground">{hint}</span>
565
+ <span
566
+ className={cn(
567
+ "truncate text-[11px] leading-4",
568
+ hintTone === "error" ? "text-destructive/90" : "text-muted-foreground",
569
+ )}
570
+ >
571
+ {hint}
572
+ </span>
478
573
  )}
479
574
  </span>
480
575
  {value !== undefined && <span className="shrink-0">{value}</span>}
@@ -508,7 +603,7 @@ function TodoRow({ todo }: { todo: TodoItem }) {
508
603
  const running = status === "in_progress";
509
604
 
510
605
  return (
511
- <li className="flex min-w-0 items-start gap-2 px-1 py-1 text-[12.5px] leading-snug">
606
+ <li className="flex min-w-0 items-start gap-2 px-2.5 py-2 text-[12.5px] leading-snug">
512
607
  {/* Ikon status dekoratif; statusnya dibacakan lewat teks sr-only di
513
608
  bawah (aria-label pada span/svg biasa tidak dibaca pembaca layar). */}
514
609
  <span aria-hidden className="mt-0.5 flex shrink-0 items-center">
@@ -537,8 +632,13 @@ function TodoRow({ todo }: { todo: TodoItem }) {
537
632
  );
538
633
  }
539
634
 
635
+ /** Keadaan kosong — tetap di dalam boxed list agar bentuk bagian konsisten. */
540
636
  function Empty({ children }: { children: ReactNode }) {
541
- return <p className="px-1 py-0.5 text-[12px] leading-snug text-muted-foreground">{children}</p>;
637
+ return (
638
+ <p className="rounded-xl border border-dashed px-2.5 py-2.5 text-[12px] leading-snug text-muted-foreground">
639
+ {children}
640
+ </p>
641
+ );
542
642
  }
543
643
 
544
644
  function CardSkeleton() {