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
@@ -16,28 +16,31 @@
16
16
  * konfigurasi default-nya).
17
17
  * - `sessionId: null` (composer homepage, Session belum dibuat): pilihan
18
18
  * hanya diteruskan ke `onChanged` tanpa PUT — dipakai saat membuat Session.
19
+ * - `onAgentTypeChange` (hanya homepage): dialog menampilkan pengalih agent
20
+ * (OpenCode / Claude Code) di atas daftar, dan trigger membawa logo agent.
21
+ * Model bergantung pada agent, jadi keduanya dipilih di satu tempat.
19
22
  */
20
23
 
21
24
  import { ChevronDownIcon, ChevronsUpDownIcon, SparklesIcon } from "lucide-react";
22
25
  import { useEffect, useState } from "react";
26
+ import { AgentLogo } from "@/components/sessions/AgentLogo";
23
27
  import { ModelSearchList } from "@/components/sessions/ModelSearchList";
28
+ import { AdwDialogContent } from "@/components/ui/adw-dialog";
29
+ import { AdwToggleGroup } from "@/components/ui/adw-toggle-group";
24
30
  import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
25
31
  import { Button } from "@/components/ui/button";
26
- import {
27
- Dialog,
28
- DialogContent,
29
- DialogDescription,
30
- DialogHeader,
31
- DialogTitle,
32
- } from "@/components/ui/dialog";
32
+ import { Dialog } from "@/components/ui/dialog";
33
33
  import { Spinner } from "@/components/ui/spinner";
34
+ import { AGENT_TYPE_LABELS, AGENT_TYPES, agentQuery } from "@/lib/agent-types";
34
35
  import { ApiError, apiFetch } from "@/lib/api";
35
36
  import { activeModelLabel, keyOfSessionModel, shortModelName } from "@/lib/model-picker";
36
37
  import type { ModelOption } from "@/server/services/opencode-client";
37
- import type { SessionModel } from "@/types";
38
+ import type { AgentType, SessionModel } from "@/types";
38
39
 
39
40
  export interface ModelPickerProps {
40
41
  projectId: string;
42
+ /** Backend Session — daftar model opencode vs Claude Code berbeda. */
43
+ agentType?: AgentType;
41
44
  /** null = Session belum ada; pilihan tidak disimpan ke server. */
42
45
  sessionId: string | null;
43
46
  /** Model aktif saat ini; null = default opencode. */
@@ -53,14 +56,21 @@ export interface ModelPickerProps {
53
56
  * composer homepage).
54
57
  */
55
58
  variant?: "heading" | "chip" | "inline";
59
+ /**
60
+ * Bila diisi, agent boleh diganti dari dialog ini (Session belum dibuat).
61
+ * Pemanggil bertanggung jawab me-reset `model` saat agent berganti.
62
+ */
63
+ onAgentTypeChange?: (agentType: AgentType) => void;
56
64
  }
57
65
 
58
66
  export function ModelPicker({
59
67
  projectId,
68
+ agentType = "opencode",
60
69
  sessionId,
61
70
  model,
62
71
  onChanged,
63
72
  variant = "chip",
73
+ onAgentTypeChange,
64
74
  }: ModelPickerProps) {
65
75
  const [open, setOpen] = useState(false);
66
76
  const [models, setModels] = useState<ModelOption[] | null>(null);
@@ -71,27 +81,40 @@ export function ModelPicker({
71
81
  const label = activeModelLabel(model, models ?? []);
72
82
  /** Judul header: buang prefiks vendor & potong bila panjang. */
73
83
  const headingLabel = shortModelName(label);
84
+ /**
85
+ * Trigger `inline` di homepage: tanpa model pilihan, tampilkan nama agent
86
+ * ("Claude Code") — "Default model" terpotong di HP dan bertabrakan dengan
87
+ * label mode agent ("Default") di composer.
88
+ */
89
+ const inlineLabel =
90
+ onAgentTypeChange && model === null ? AGENT_TYPE_LABELS[agentType] : headingLabel;
74
91
 
75
92
  // Daftar model selalu di-fetch ulang saat dialog dibuka — list di server
76
93
  // headless bisa berubah bila user mengedit config opencode (`opencode serve`
77
94
  // tidak hot-reload). Lazy: tidak di-spawn saat halaman Session dibuka.
78
95
  useEffect(() => {
79
96
  if (!open) return;
97
+ // Agent bisa diganti saat dialog terbuka — abaikan respons fetch lama.
98
+ let stale = false;
80
99
  setLoading(true);
81
100
  setError(null);
82
- apiFetch(`/api/projects/${projectId}/models`)
101
+ apiFetch(`/api/projects/${projectId}/models${agentQuery(agentType)}`)
83
102
  .then(async (res) => {
84
103
  const body = (await res.json()) as { models: ModelOption[] };
85
- setModels(body.models);
104
+ if (!stale) setModels(body.models);
86
105
  })
87
106
  .catch((e: unknown) => {
107
+ if (stale) return;
88
108
  setError(e instanceof ApiError ? e.message : "Failed to load model list");
89
109
  setModels([]);
90
110
  })
91
111
  .finally(() => {
92
- setLoading(false);
112
+ if (!stale) setLoading(false);
93
113
  });
94
- }, [open, projectId]);
114
+ return () => {
115
+ stale = true;
116
+ };
117
+ }, [open, projectId, agentType]);
95
118
 
96
119
  const choose = async (next: SessionModel | null) => {
97
120
  setSaving(true);
@@ -140,10 +163,15 @@ export function ModelPicker({
140
163
  onClick={() => setOpen(true)}
141
164
  disabled={saving}
142
165
  className="h-9 max-w-full min-w-0 gap-1 px-2 font-normal text-muted-foreground hover:text-foreground"
143
- aria-label={`Model: ${label}. Tap to change`}
144
- title={label}
166
+ aria-label={
167
+ onAgentTypeChange
168
+ ? `${AGENT_TYPE_LABELS[agentType]}, model: ${label}. Tap to change`
169
+ : `Model: ${label}. Tap to change`
170
+ }
171
+ title={onAgentTypeChange ? `${AGENT_TYPE_LABELS[agentType]} · ${label}` : label}
145
172
  >
146
- <span className="min-w-0 truncate">{headingLabel}</span>
173
+ {onAgentTypeChange && <AgentLogo agentType={agentType} />}
174
+ <span className="min-w-0 truncate">{inlineLabel}</span>
147
175
  {saving ? <Spinner data-icon="inline-end" /> : <ChevronDownIcon data-icon="inline-end" />}
148
176
  </Button>
149
177
  ) : (
@@ -175,17 +203,27 @@ export function ModelPicker({
175
203
  if (!next) setError(null);
176
204
  }}
177
205
  >
178
- {/* Tinggi dipatok agar daftar panjang men-scroll di dalam dialog,
179
- bukan menumbuhkan dialog melewati layar HP. */}
180
- <DialogContent className="flex h-[70dvh] max-h-[560px] flex-col sm:max-w-md">
181
- <DialogHeader>
182
- <DialogTitle>Choose a model</DialogTitle>
183
- <DialogDescription>
184
- {sessionId === null
185
- ? "Used by the new session you start from here."
186
- : "Applies to the next messages — no session restart needed."}
187
- </DialogDescription>
188
- </DialogHeader>
206
+ {/* AdwDialog: header bar (judul + subjudul di tengah, tombol tutup
207
+ bundar), isi preferensi di bawahnya; bottom sheet di HP. */}
208
+ <AdwDialogContent
209
+ title={onAgentTypeChange ? "Agent & Model" : "Model"}
210
+ subtitle={sessionId === null ? "For the new session" : "Applies to the next message"}
211
+ closeDisabled={saving}
212
+ >
213
+ {onAgentTypeChange && (
214
+ <AdwToggleGroup
215
+ label="Agent"
216
+ value={agentType}
217
+ // Tetap bisa diganti saat daftar dimuat — respons usang dibuang.
218
+ disabled={saving}
219
+ onChange={onAgentTypeChange}
220
+ options={AGENT_TYPES.map((t) => ({
221
+ value: t,
222
+ label: AGENT_TYPE_LABELS[t],
223
+ icon: <AgentLogo agentType={t} />,
224
+ }))}
225
+ />
226
+ )}
189
227
 
190
228
  {error && (
191
229
  <Alert variant="destructive">
@@ -202,7 +240,7 @@ export function ModelPicker({
202
240
  disabled={saving}
203
241
  className="flex-1"
204
242
  />
205
- </DialogContent>
243
+ </AdwDialogContent>
206
244
  </Dialog>
207
245
  </>
208
246
  );
@@ -3,7 +3,13 @@
3
3
  *
4
4
  * Dipakai dua tempat dengan cara berbeda:
5
5
  * - `ModelPicker.tsx` (ganti model Session berjalan) membukanya sebagai dialog.
6
- * - `SessionList.tsx` (buat Session baru) menyematkannya di dalam form.
6
+ * - `NewSessionDialog.tsx` (buat Session baru) menyematkannya di dalam form.
7
+ *
8
+ * Gaya libadwaita (GNOME): kolom cari ala GtkSearchEntry (terisi, tanpa
9
+ * border, tombol hapus), lalu "boxed list" per provider seperti
10
+ * AdwPreferencesGroup — judul grup di atas kartu membulat berbaris
11
+ * pemisah; model aktif ditandai centang di kanan (bukan latar), hasil kosong
12
+ * memakai pola AdwStatusPage. Enter di kolom cari memilih hasil pertama.
7
13
  *
8
14
  * Komponen ini tidak melakukan fetch maupun simpan: pemanggil menyediakan
9
15
  * `models` dan menerima pilihan lewat `onSelect`. Aturan pencarian dan
@@ -11,8 +17,8 @@
11
17
  */
12
18
 
13
19
  import { CheckIcon, SearchIcon } from "lucide-react";
14
- import { useMemo, useState } from "react";
15
- import { Input } from "@/components/ui/input";
20
+ import { type ReactNode, useMemo, useState } from "react";
21
+ import { SearchEntry } from "@/components/ui/search-entry";
16
22
  import { Spinner } from "@/components/ui/spinner";
17
23
  import {
18
24
  DEFAULT_MODEL_KEY,
@@ -29,8 +35,13 @@ export interface ModelSearchListProps {
29
35
  /** Daftar model dari server; `null` = belum dimuat. */
30
36
  models: ModelOption[] | null;
31
37
  loading: boolean;
32
- /** Kunci model yang sedang aktif/terpilih (lihat `keyOfSessionModel`). */
33
- activeKey: string;
38
+ /**
39
+ * Kunci model aktif/terpilih (lihat `keyOfSessionModel`) — ditandai
40
+ * centang. Tanpa nilai: tidak ada centang (mode "klik untuk mulai").
41
+ */
42
+ activeKey?: string;
43
+ /** Baris yang pilihannya sedang diproses — spinner menggantikan centang. */
44
+ pendingKey?: string | null;
34
45
  /** Pilihan baru; `null` = pakai model default opencode. */
35
46
  onSelect: (model: SessionModel | null) => void;
36
47
  /** Kunci aksi sementara pilihan sedang disimpan. */
@@ -43,6 +54,7 @@ export function ModelSearchList({
43
54
  models,
44
55
  loading,
45
56
  activeKey,
57
+ pendingKey = null,
46
58
  onSelect,
47
59
  disabled = false,
48
60
  className,
@@ -57,95 +69,163 @@ export function ModelSearchList({
57
69
  return q === "" || "model default".includes(q);
58
70
  }, [query]);
59
71
  const empty = !loading && !showDefault && groups.length === 0;
72
+ const isClaude = models?.[0]?.providerID === "claude-code";
73
+
74
+ /** Enter di kolom cari: pilih hasil teratas (default bila masih tampil). */
75
+ const pickFirst = () => {
76
+ if (loading || disabled) return;
77
+ if (showDefault) return onSelect(null);
78
+ const first = groups[0]?.models[0];
79
+ if (first) onSelect({ providerID: first.providerID, modelID: first.modelID });
80
+ };
60
81
 
61
82
  return (
62
- <div className={cn("flex min-h-0 flex-col gap-2", className)}>
63
- <div className="relative shrink-0">
64
- <SearchIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
65
- <Input
66
- value={query}
67
- onChange={(e) => setQuery(e.target.value)}
68
- placeholder="Search models or providers…"
69
- aria-label="Search models"
70
- className="pl-9"
71
- />
72
- </div>
83
+ <div className={cn("flex min-h-0 flex-col gap-3", className)}>
84
+ <SearchEntry
85
+ className="shrink-0"
86
+ value={query}
87
+ onValueChange={setQuery}
88
+ onKeyDown={(e) => {
89
+ if (e.key === "Enter") {
90
+ e.preventDefault();
91
+ pickFirst();
92
+ }
93
+ // Esc: SearchEntry mengosongkan cari dulu (dialog pemanggil menahan
94
+ // tutupnya, lihat `ModelPicker`); Esc berikutnya menutup dialog.
95
+ }}
96
+ placeholder={
97
+ models && models.length > 0 ? `Search ${models.length} models…` : "Search models…"
98
+ }
99
+ aria-label="Search models or providers"
100
+ aria-describedby="model-search-count"
101
+ />
102
+ <span id="model-search-count" className="sr-only" aria-live="polite">
103
+ {loading ? "Loading models" : describeModelCount(filtered.length)}
104
+ </span>
73
105
 
74
- <div className="min-h-0 flex-1 overflow-y-auto">
106
+ {/* `-mx-1 px-1`: ruang untuk ring fokus baris agar tidak terpotong. */}
107
+ <div className="-mx-1 min-h-0 flex-1 overflow-y-auto overscroll-contain px-1 pb-1">
75
108
  {loading ? (
76
- <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
77
- <Spinner className="size-4" />
109
+ <div className="flex h-full min-h-40 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
110
+ <Spinner className="size-6" />
78
111
  Loading models…
79
112
  </div>
80
113
  ) : empty ? (
81
- <p className="py-8 text-center text-sm text-muted-foreground">
82
- No models match “{query.trim()}”.
83
- </p>
114
+ // AdwStatusPage
115
+ <div className="flex h-full min-h-40 flex-col items-center justify-center gap-2 px-6 text-center">
116
+ <SearchIcon className="mb-2 size-12 text-muted-foreground/40" aria-hidden />
117
+ <p className="text-lg font-semibold">No Results Found</p>
118
+ <p className="text-sm text-muted-foreground">
119
+ No model matches “{query.trim()}”. Try a different search.
120
+ </p>
121
+ </div>
84
122
  ) : (
85
- <div className="flex flex-col gap-1 pb-1">
123
+ <div className="flex flex-col gap-5">
86
124
  {showDefault && (
87
- <ModelRow
88
- name="Default model"
89
- hint="Follow opencode configuration"
90
- selected={activeKey === DEFAULT_MODEL_KEY}
91
- disabled={disabled}
92
- onSelect={() => onSelect(null)}
93
- />
125
+ <BoxedList label="Default">
126
+ <ModelRow
127
+ name="Default model"
128
+ hint={
129
+ isClaude
130
+ ? "Follows your Claude Code settings"
131
+ : "Follows opencode configuration"
132
+ }
133
+ selected={activeKey === DEFAULT_MODEL_KEY}
134
+ pending={pendingKey === DEFAULT_MODEL_KEY}
135
+ disabled={disabled}
136
+ onSelect={() => onSelect(null)}
137
+ />
138
+ </BoxedList>
94
139
  )}
95
140
  {groups.map((g) => (
96
- <div key={g.providerID} className="flex flex-col gap-1">
97
- <p className="px-2 pt-2 text-xs font-medium text-muted-foreground">
98
- {g.providerName}
99
- </p>
141
+ <PrefsGroup key={g.providerID} title={g.providerName}>
100
142
  {g.models.map((m) => (
101
143
  <ModelRow
102
144
  key={optionKey(m)}
103
145
  name={m.name}
104
146
  hint={m.modelID}
105
147
  selected={activeKey === optionKey(m)}
148
+ pending={pendingKey === optionKey(m)}
106
149
  disabled={disabled}
107
150
  onSelect={() => onSelect({ providerID: m.providerID, modelID: m.modelID })}
108
151
  />
109
152
  ))}
110
- </div>
153
+ </PrefsGroup>
111
154
  ))}
112
155
  </div>
113
156
  )}
114
157
  </div>
115
-
116
- <p className="shrink-0 text-xs text-muted-foreground">
117
- {loading ? "Loading…" : describeModelCount(filtered.length)}
118
- </p>
119
158
  </div>
120
159
  );
121
160
  }
122
161
 
162
+ /** Kartu membulat berisi baris dengan garis pemisah (libadwaita `.boxed-list`). */
163
+ function BoxedList({ label, children }: { label: string; children: ReactNode }) {
164
+ return (
165
+ <ul
166
+ aria-label={label}
167
+ className="flex flex-col divide-y overflow-hidden rounded-xl border bg-card shadow-xs"
168
+ >
169
+ {children}
170
+ </ul>
171
+ );
172
+ }
173
+
174
+ /** Judul grup + boxed list (AdwPreferencesGroup). */
175
+ function PrefsGroup({ title, children }: { title: string; children: ReactNode }) {
176
+ return (
177
+ <section className="flex flex-col gap-2">
178
+ <h3 className="px-1 text-[13px] font-semibold">{title}</h3>
179
+ <BoxedList label={title}>{children}</BoxedList>
180
+ </section>
181
+ );
182
+ }
183
+
123
184
  interface ModelRowProps {
124
185
  name: string;
125
186
  hint: string;
126
187
  selected: boolean;
188
+ /** Pilihan ini sedang diproses: spinner, tidak ikut diredupkan. */
189
+ pending: boolean;
127
190
  disabled: boolean;
128
191
  onSelect: () => void;
129
192
  }
130
193
 
131
- /** Satu baris model — `min-h-11` memberi target sentuh nyaman di HP. */
132
- function ModelRow({ name, hint, selected, disabled, onSelect }: ModelRowProps) {
194
+ /**
195
+ * Satu baris model (AdwActionRow yang bisa diaktifkan): judul + subjudul,
196
+ * centang aksen di kanan untuk pilihan aktif. `min-h-13` = target sentuh
197
+ * nyaman di HP.
198
+ */
199
+ function ModelRow({ name, hint, selected, pending, disabled, onSelect }: ModelRowProps) {
133
200
  return (
134
- <button
135
- type="button"
136
- onClick={onSelect}
137
- disabled={disabled}
138
- aria-current={selected}
139
- className={cn(
140
- "flex min-h-11 w-full items-center gap-2 rounded-md px-2 py-2 text-left transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none disabled:opacity-50",
141
- selected && "bg-accent",
142
- )}
143
- >
144
- <span className="flex min-w-0 flex-1 flex-col">
145
- <span className="truncate text-sm font-medium">{name}</span>
146
- <span className="truncate font-mono text-[11px] text-muted-foreground">{hint}</span>
147
- </span>
148
- {selected && <CheckIcon className="size-4 shrink-0 text-primary" />}
149
- </button>
201
+ <li>
202
+ <button
203
+ type="button"
204
+ onClick={onSelect}
205
+ disabled={disabled}
206
+ aria-current={selected}
207
+ aria-busy={pending || undefined}
208
+ className={cn(
209
+ "flex min-h-13 w-full items-center gap-3 px-4 py-2 text-left transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset active:bg-muted disabled:pointer-events-none",
210
+ pending ? "bg-muted/60" : "disabled:opacity-50",
211
+ )}
212
+ >
213
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
214
+ <span className="truncate text-[15px] leading-5">{name}</span>
215
+ <span className="truncate text-[13px] leading-snug text-muted-foreground">{hint}</span>
216
+ </span>
217
+ {pending ? (
218
+ <Spinner className="size-[18px] shrink-0 text-muted-foreground" />
219
+ ) : (
220
+ selected && (
221
+ <CheckIcon
222
+ className="size-[18px] shrink-0 text-primary"
223
+ strokeWidth={2.5}
224
+ aria-hidden
225
+ />
226
+ )
227
+ )}
228
+ </button>
229
+ </li>
150
230
  );
151
231
  }