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,67 +1,130 @@
1
1
  /**
2
- * Halaman Settings (`/settings/:section`) — tata letak ala GNOME Settings
3
- * (libadwaita): kolom sempit di tengah, grup berjudul berisi "boxed list".
4
- * Tiap bagian punya URL sendiri. EMPAT tab (muat di desktop & bottom nav HP):
2
+ * Halaman Settings (`/settings/:section`) — navigasi ala GNOME Settings
3
+ * (AdwNavigationSplitView, libadwaita). Isi tiap bagian memakai "boxed list"
4
+ * (`components/settings/prefs`). Tiap bagian punya URL sendiri:
5
5
  *
6
- * - Profile (`/settings`, `/settings/profile`): avatar, nickname, dan akun
7
- * KCG Code (username/email dari akun tunnel).
6
+ * - Profile (`/settings`, `/settings/profile`): avatar, nickname, akun.
8
7
  * - Security (`/settings/security`): kunci aplikasi, auto-lock, passkey, dan
9
8
  * perangkat yang login (`/settings/devices` lama -> grup ini).
10
9
  * - Workspace (`/settings/workspace`): folder tempat semua Project berada.
11
10
  * - Remote access (`/settings/remote`): tunnel publik lewat Google sign-in.
11
+ * - About KCG Code (`/settings/about`): versi & kredit — bukan pengaturan,
12
+ * jadi tampil terpisah di akhir navigasi (seperti "About" GNOME Settings).
12
13
  *
13
- * About (`/settings/about`) bukan pengaturan — dibuka dari tombol ⓘ di
14
- * samping judul, bukan tab.
14
+ * Tata letak:
15
+ * - Lebar (lg+): daftar navigasi di kiri (sticky), isi bagian di kanan.
16
+ * - Sempit: `/settings` = daftar bagian (baris dengan ikon, deskripsi,
17
+ * chevron); bagian dibuka sebagai halaman dengan tombol kembali
18
+ * "‹ Settings" + judul di tengah (AdwNavigationView).
15
19
  */
16
- import { ArrowLeftIcon, FolderIcon, GlobeIcon, InfoIcon, ShieldIcon, UserIcon } from "lucide-react";
17
- import { type ComponentType, useCallback, useEffect } from "react";
20
+ import { ChevronLeftIcon, FolderIcon, GlobeIcon, ShieldIcon, UserIcon } from "lucide-react";
21
+ import { type ComponentType, type ReactNode, useCallback, useEffect } from "react";
18
22
  import { toast } from "sonner";
19
23
  import { AvatarEditor } from "@/components/auth/AvatarEditor";
24
+ import { ProfileAvatar } from "@/components/auth/ProfileAvatar";
20
25
  import { AboutGroup } from "@/components/settings/AboutGroup";
21
26
  import { AccountGroup } from "@/components/settings/AccountGroup";
22
- import { EntryRow, PrefsGroup } from "@/components/settings/prefs";
27
+ import { ActionRow, EntryRow, PrefsGroup } from "@/components/settings/prefs";
23
28
  import { RemoteAccessGroup } from "@/components/settings/RemoteAccessGroup";
24
29
  import { SecurityGroup } from "@/components/settings/SecurityGroup";
25
30
  import { WorkspaceGroup } from "@/components/settings/WorkspaceGroup";
26
31
  import { Spinner } from "@/components/ui/spinner";
32
+ import { useMediaQuery } from "@/hooks/useMediaQuery";
27
33
  import { useRouter } from "@/hooks/useRouter";
28
34
  import { ApiError, apiFetch } from "@/lib/api";
29
35
  import { refreshAuth, useAuth } from "@/lib/auth";
30
36
  import { type SettingsSection, settingsPath } from "@/lib/routes";
31
37
  import { cn } from "@/lib/utils";
32
38
  import type { AuthStatus } from "@/server/services/auth";
39
+ import logo from "../../logo.svg";
33
40
 
34
- /** Tab Settings — sengaja dibatasi empat agar tidak sesak. */
35
- export const SETTINGS_TABS: {
36
- id: Exclude<SettingsSection, "about">;
41
+ /** Logo aplikasi sebagai ikon navigasi "About KCG Code". */
42
+ function AppIcon({ className }: { className?: string }) {
43
+ return <img src={logo} alt="" className={cn("rounded-[22%]", className)} />;
44
+ }
45
+
46
+ export interface SettingsPageInfo {
47
+ id: SettingsSection;
37
48
  label: string;
38
- /** Label ringkas untuk bottom nav mobile. */
39
- short?: string;
49
+ /** Subjudul baris di daftar bagian (layar sempit). */
50
+ description: string;
40
51
  icon: ComponentType<{ className?: string }>;
41
- }[] = [
42
- { id: "profile", label: "Profile", icon: UserIcon },
43
- { id: "security", label: "Security", icon: ShieldIcon },
44
- { id: "workspace", label: "Workspace", icon: FolderIcon },
45
- { id: "remote", label: "Remote access", short: "Remote", icon: GlobeIcon },
52
+ }
53
+
54
+ /** Bagian pengaturan (urutan navigasi). About sengaja terpisah. */
55
+ export const SETTINGS_PANELS: readonly SettingsPageInfo[] = [
56
+ {
57
+ id: "profile",
58
+ label: "Profile",
59
+ description: "Name, picture & account",
60
+ icon: UserIcon,
61
+ },
62
+ {
63
+ id: "security",
64
+ label: "Security",
65
+ description: "App lock, passkeys & devices",
66
+ icon: ShieldIcon,
67
+ },
68
+ {
69
+ id: "workspace",
70
+ label: "Workspace",
71
+ description: "Where your projects live",
72
+ icon: FolderIcon,
73
+ },
74
+ {
75
+ id: "remote",
76
+ label: "Remote access",
77
+ description: "Open KCG Code from anywhere",
78
+ icon: GlobeIcon,
79
+ },
46
80
  ];
47
81
 
82
+ /** Entri "About" — selalu terakhir, dipisah dari bagian pengaturan. */
83
+ export const SETTINGS_ABOUT: SettingsPageInfo = {
84
+ id: "about",
85
+ label: "About KCG Code",
86
+ description: "Version & credits",
87
+ icon: AppIcon,
88
+ };
89
+
90
+ function pageInfo(section: SettingsSection): SettingsPageInfo {
91
+ return SETTINGS_PANELS.find((p) => p.id === section) ?? SETTINGS_ABOUT;
92
+ }
93
+
94
+ /** Breakpoint `lg` — di atasnya navigasi & isi berdampingan. */
95
+ const WIDE_QUERY = "(min-width: 1024px)";
96
+
48
97
  function errorText(e: unknown, fallback: string): string {
49
98
  return e instanceof ApiError ? e.message : fallback;
50
99
  }
51
100
 
101
+ /** Tautan SPA (Ctrl/Cmd-klik tetap membuka tab baru). */
102
+ function useNavLink() {
103
+ const { navigate } = useRouter();
104
+ return (href: string) => (e: React.MouseEvent<HTMLAnchorElement>) => {
105
+ if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
106
+ e.preventDefault();
107
+ navigate(href);
108
+ };
109
+ }
110
+
52
111
  export function SettingsPage({
53
112
  section,
54
113
  anchor,
114
+ root = false,
55
115
  }: {
56
116
  section: SettingsSection;
57
117
  /** Grup yang dituju (mis. URL lama `/settings/devices` -> "devices"). */
58
118
  anchor?: string;
119
+ /** `/settings` tanpa sub-halaman (layar sempit: daftar bagian). */
120
+ root?: boolean;
59
121
  }) {
60
122
  const auth = useAuth();
61
123
  const status = auth.phase === "ready" ? auth.status : null;
62
124
  const ok = useCallback((text: string) => toast.success(text), []);
63
125
  const error = useCallback((text: string) => toast.error(text), []);
64
126
  const ready = status !== null;
127
+ const wide = useMediaQuery(WIDE_QUERY);
65
128
 
66
129
  // Gulir ke grup tujuan setelah kontennya dirender.
67
130
  useEffect(() => {
@@ -72,25 +135,42 @@ export function SettingsPage({
72
135
  return () => clearTimeout(t);
73
136
  }, [anchor, ready]);
74
137
 
75
- return (
76
- // Mobile: ruang ekstra di bawah agar konten tidak tertutup bottom nav.
77
- <div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[calc(5rem+env(safe-area-inset-bottom))] sm:pb-10">
78
- <SettingsNav current={section} />
138
+ const content = !status ? (
139
+ <div className="flex justify-center py-16" role="status">
140
+ <Spinner className="size-6 text-muted-foreground" />
141
+ <span className="sr-only">Loading settings…</span>
142
+ </div>
143
+ ) : (
144
+ <div className="flex flex-col gap-8">
145
+ {section === "profile" && <ProfileSection status={status} onOk={ok} onError={error} />}
146
+ {section === "security" && <SecurityGroup status={status} onOk={ok} />}
147
+ {section === "workspace" && <WorkspaceGroup status={status} />}
148
+ {section === "remote" && <RemoteAccessGroup protectedApp={status.protected} />}
149
+ {section === "about" && <AboutGroup />}
150
+ </div>
151
+ );
79
152
 
80
- {!status ? (
81
- <div className="flex justify-center py-16" role="status">
82
- <Spinner className="size-6 text-muted-foreground" />
83
- <span className="sr-only">Loading settings…</span>
84
- </div>
85
- ) : (
86
- <div className="flex flex-col gap-8">
87
- {section === "profile" && <ProfileSection status={status} onOk={ok} onError={error} />}
88
- {section === "security" && <SecurityGroup status={status} onOk={ok} />}
89
- {section === "workspace" && <WorkspaceGroup status={status} />}
90
- {section === "remote" && <RemoteAccessGroup protectedApp={status.protected} />}
91
- {section === "about" && <AboutGroup />}
153
+ // Lebar: AdwNavigationSplitView — sidebar navigasi + isi.
154
+ if (wide) {
155
+ return (
156
+ <div className="grid grid-cols-[14.5rem_minmax(0,1fr)] gap-10 pt-2 pb-10">
157
+ <SettingsSidebar current={section} />
158
+ <div className="flex min-w-0 max-w-[40rem] flex-col gap-6">
159
+ <h2 className="px-1 text-2xl font-semibold tracking-tight">{pageInfo(section).label}</h2>
160
+ {content}
92
161
  </div>
93
- )}
162
+ </div>
163
+ );
164
+ }
165
+
166
+ // Sempit, `/settings`: daftar bagian.
167
+ if (root) return <SettingsIndex status={status} />;
168
+
169
+ // Sempit, sub-halaman: tombol kembali + judul di tengah.
170
+ return (
171
+ <div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[max(2.5rem,env(safe-area-inset-bottom))]">
172
+ <SubpageHeader title={pageInfo(section).label} />
173
+ {content}
94
174
  </div>
95
175
  );
96
176
  }
@@ -98,120 +178,107 @@ export function SettingsPage({
98
178
  // ---------------------------------------------------------------- nav ----
99
179
 
100
180
  /**
101
- * Navigasi bagian Settings.
102
- * - Judul + tombol About (ⓘ) di kanannya. Di halaman About, judul berubah
103
- * jadi tombol kembali — About bukan tab.
104
- * - sm+: deretan pil (4) di bawah judul.
105
- * - Mobile: bottom navigation (4 kolom, ala Material 3 NavigationBar).
181
+ * Sidebar navigasi (layar lebar) — gaya `.navigation-sidebar` libadwaita:
182
+ * baris ikon + label, pilihan aktif berlatar membulat. About di bawah garis
183
+ * pemisah, dengan logo aplikasi sebagai ikon.
106
184
  */
107
- function SettingsNav({ current }: { current: SettingsSection }) {
108
- const { navigate } = useRouter();
109
- const go = (href: string) => (e: React.MouseEvent<HTMLAnchorElement>) => {
110
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;
111
- e.preventDefault();
112
- navigate(href);
185
+ function SettingsSidebar({ current }: { current: SettingsSection }) {
186
+ const link = useNavLink();
187
+ const item = ({ id, label, icon: Icon }: SettingsPageInfo) => {
188
+ const href = settingsPath(id);
189
+ const active = id === current;
190
+ return (
191
+ <li key={id}>
192
+ <a
193
+ href={href}
194
+ onClick={link(href)}
195
+ aria-current={active ? "page" : undefined}
196
+ className={cn(
197
+ "flex h-10 items-center gap-3 rounded-lg px-3 text-[15px] transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
198
+ active
199
+ ? "bg-black/[0.08] font-medium text-foreground dark:bg-white/[0.1]"
200
+ : "text-foreground/80 hover:bg-black/[0.04] hover:text-foreground dark:hover:bg-white/[0.05]",
201
+ )}
202
+ >
203
+ <Icon className="size-[18px] shrink-0" />
204
+ <span className="truncate">{label}</span>
205
+ </a>
206
+ </li>
207
+ );
113
208
  };
114
- const onAbout = current === "about";
115
- const aboutHref = settingsPath("about");
116
- const backHref = settingsPath("profile");
209
+ return (
210
+ <nav aria-label="Settings" className="sticky top-2 flex flex-col gap-3 self-start">
211
+ <h1 className="px-3 text-2xl font-semibold tracking-tight">Settings</h1>
212
+ <ul className="flex flex-col gap-0.5">{SETTINGS_PANELS.map(item)}</ul>
213
+ <div className="mx-3 h-px bg-border" aria-hidden />
214
+ <ul className="flex flex-col gap-0.5">{item(SETTINGS_ABOUT)}</ul>
215
+ </nav>
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Daftar bagian (layar sempit, `/settings`) — boxed list ala GNOME Settings
221
+ * mobile: profil di atas (avatar + nama), lalu bagian pengaturan, lalu
222
+ * About di grup sendiri.
223
+ */
224
+ function SettingsIndex({ status }: { status: AuthStatus | null }) {
225
+ const { navigate } = useRouter();
226
+ const profile = status?.profile;
227
+ const row = (p: SettingsPageInfo, overrides?: { title?: ReactNode; prefix?: ReactNode }) => (
228
+ <ActionRow
229
+ key={p.id}
230
+ prefix={overrides?.prefix ?? <p.icon className="size-[18px] text-muted-foreground" />}
231
+ title={overrides?.title ?? p.label}
232
+ subtitle={p.description}
233
+ onActivate={() => navigate(settingsPath(p.id))}
234
+ />
235
+ );
236
+ const [profilePanel, ...panels] = SETTINGS_PANELS;
117
237
 
118
238
  return (
119
- <>
120
- <div className="flex flex-col gap-3">
121
- <div className="flex items-center justify-between gap-3 px-1">
122
- {onAbout ? (
123
- <a
124
- href={backHref}
125
- onClick={go(backHref)}
126
- className="-ml-2 flex items-center gap-2 rounded-full py-1 pr-3 pl-2 text-2xl font-semibold tracking-tight outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50"
127
- >
128
- <ArrowLeftIcon className="size-5" aria-hidden />
129
- <span>Settings</span>
130
- </a>
131
- ) : (
132
- <h1 className="text-2xl font-semibold tracking-tight">Settings</h1>
133
- )}
134
- <a
135
- href={aboutHref}
136
- onClick={go(aboutHref)}
137
- aria-label="About KCG Code"
138
- aria-current={onAbout ? "page" : undefined}
139
- title="About KCG Code"
140
- className={cn(
141
- "flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50",
142
- onAbout && "bg-muted text-foreground",
143
- )}
144
- >
145
- <InfoIcon className="size-5" aria-hidden />
146
- </a>
147
- </div>
148
- <nav aria-label="Settings sections" className="hidden sm:block">
149
- <ul className="flex gap-1">
150
- {SETTINGS_TABS.map(({ id, label, icon: Icon }) => {
151
- const active = id === current;
152
- const href = settingsPath(id);
153
- return (
154
- <li key={id} className="shrink-0">
155
- <a
156
- href={href}
157
- aria-current={active ? "page" : undefined}
158
- onClick={go(href)}
159
- className={cn(
160
- "flex items-center gap-2 rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50",
161
- active
162
- ? "bg-primary text-primary-foreground"
163
- : "text-muted-foreground hover:bg-muted hover:text-foreground",
164
- )}
165
- >
166
- <Icon className="size-4 shrink-0" aria-hidden />
167
- {label}
168
- </a>
169
- </li>
170
- );
171
- })}
172
- </ul>
173
- </nav>
174
- </div>
239
+ <div className="mx-auto flex w-full max-w-[40rem] flex-col gap-6 pb-[max(2.5rem,env(safe-area-inset-bottom))]">
240
+ <h1 className="px-1 text-2xl font-semibold tracking-tight">Settings</h1>
241
+ {profilePanel && (
242
+ <PrefsGroup>
243
+ {row(profilePanel, {
244
+ title: profile?.nickname ?? "Profile",
245
+ prefix: (
246
+ <ProfileAvatar
247
+ nickname={profile?.nickname ?? null}
248
+ avatarUrl={profile?.avatarUrl ?? null}
249
+ avatarPreset={profile?.avatarPreset ?? null}
250
+ className="size-10"
251
+ textClassName="text-base"
252
+ />
253
+ ),
254
+ })}
255
+ </PrefsGroup>
256
+ )}
257
+ <PrefsGroup>{panels.map((p) => row(p))}</PrefsGroup>
258
+ <PrefsGroup>
259
+ {row(SETTINGS_ABOUT, { prefix: <AppIcon className="size-[22px]" /> })}
260
+ </PrefsGroup>
261
+ </div>
262
+ );
263
+ }
175
264
 
176
- <nav
177
- aria-label="Settings sections"
178
- className="fixed inset-x-0 bottom-0 z-30 border-t bg-background/95 pb-[env(safe-area-inset-bottom)] backdrop-blur supports-[backdrop-filter]:bg-background/80 sm:hidden"
265
+ /** Header sub-halaman (layar sempit): "‹ Settings" + judul di tengah. */
266
+ function SubpageHeader({ title }: { title: string }) {
267
+ const link = useNavLink();
268
+ const href = settingsPath();
269
+ return (
270
+ <header className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
271
+ <a
272
+ href={href}
273
+ onClick={link(href)}
274
+ className="-ml-2 flex w-fit items-center gap-0.5 rounded-full py-1.5 pr-3 pl-1.5 text-[15px] font-medium outline-none hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50 active:bg-muted"
179
275
  >
180
- <ul className="mx-auto grid max-w-md grid-cols-4">
181
- {SETTINGS_TABS.map(({ id, label, short, icon: Icon }) => {
182
- const active = id === current;
183
- const href = settingsPath(id);
184
- return (
185
- <li key={id} className="min-w-0">
186
- <a
187
- href={href}
188
- aria-current={active ? "page" : undefined}
189
- aria-label={short ? label : undefined}
190
- onClick={go(href)}
191
- className={cn(
192
- "group flex min-h-16 flex-col items-center justify-center gap-1 px-1 pt-2 pb-1.5 text-[11.5px] font-medium transition-colors outline-none",
193
- active ? "text-foreground" : "text-muted-foreground active:text-foreground",
194
- )}
195
- >
196
- {/* Pil indikator di belakang ikon untuk bagian aktif. */}
197
- <span
198
- className={cn(
199
- "flex h-8 w-14 items-center justify-center rounded-full transition-colors group-focus-visible:ring-[3px] group-focus-visible:ring-ring/50",
200
- active
201
- ? "bg-primary/15 text-primary dark:bg-primary/25"
202
- : "group-active:bg-muted",
203
- )}
204
- >
205
- <Icon className="size-5 shrink-0" aria-hidden />
206
- </span>
207
- <span className="max-w-full truncate">{short ?? label}</span>
208
- </a>
209
- </li>
210
- );
211
- })}
212
- </ul>
213
- </nav>
214
- </>
276
+ <ChevronLeftIcon className="size-5" aria-hidden />
277
+ Settings
278
+ </a>
279
+ <h1 className="truncate text-center text-[17px] font-bold">{title}</h1>
280
+ <span aria-hidden />
281
+ </header>
215
282
  );
216
283
  }
217
284
 
@@ -1,9 +1,16 @@
1
1
  /**
2
- * Navigasi Settings — jumlah tab dibatasi (tidak sesak).
2
+ * Navigasi Settings — bagian pengaturan dibatasi (tidak sesak), About
3
+ * terpisah di akhir navigasi.
3
4
  */
4
5
  import { expect, test } from "bun:test";
5
- import { SETTINGS_TABS } from "../SettingsPage";
6
+ import { SETTINGS_SECTIONS } from "@/lib/routes";
7
+ import { SETTINGS_ABOUT, SETTINGS_PANELS } from "../SettingsPage";
6
8
 
7
- test("hanya empat tab: Devices bagian dari Security, About bukan tab", () => {
8
- expect(SETTINGS_TABS.map((t) => t.id)).toEqual(["profile", "security", "workspace", "remote"]);
9
+ test("empat bagian pengaturan: Devices bagian dari Security", () => {
10
+ expect(SETTINGS_PANELS.map((p) => p.id)).toEqual(["profile", "security", "workspace", "remote"]);
11
+ });
12
+
13
+ test("About entri terpisah; navigasi mencakup semua sub-halaman", () => {
14
+ expect(SETTINGS_ABOUT.id).toBe("about");
15
+ expect([...SETTINGS_PANELS.map((p) => p.id), SETTINGS_ABOUT.id]).toEqual([...SETTINGS_SECTIONS]);
9
16
  });