kcg-code 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +880 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/ink-react.ts +26 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
|
@@ -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
|
-
|
|
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={
|
|
144
|
-
|
|
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
|
-
<
|
|
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
|
-
{/*
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
</
|
|
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
|
-
* - `
|
|
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 {
|
|
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
|
-
/**
|
|
33
|
-
|
|
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-
|
|
63
|
-
<
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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-
|
|
77
|
-
<Spinner className="size-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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-
|
|
123
|
+
<div className="flex flex-col gap-5">
|
|
86
124
|
{showDefault && (
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
/**
|
|
132
|
-
|
|
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
|
-
<
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
<span className="
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
}
|