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
@@ -8,6 +8,14 @@
8
8
  *
9
9
  * Bahasa UI sengaja non-teknis (tanpa "tunnel", "secret", "frpc"): istilah
10
10
  * teknis & aksi jarang dipakai disimpan di bagian "Advanced" yang tertutup.
11
+ *
12
+ * Susunan halaman (satu hal per grup, ringkas di HP):
13
+ * 1. AdwSwitchRow "Remote access" + status satu frasa.
14
+ * 2. "Your link" (saat online): QR (desktop), baris link + salin/buka;
15
+ * di HP: "Share link" + QR dilipat di baris expander.
16
+ * 3. Akun Google tertaut.
17
+ * 4. "Connection method" (pilihan penyedia — jarang diganti, jadi di bawah).
18
+ * 5. "Advanced options" tertutup.
11
19
  */
12
20
  import {
13
21
  CheckIcon,
@@ -17,7 +25,6 @@ import {
17
25
  ExternalLinkIcon,
18
26
  GlobeIcon,
19
27
  LockIcon,
20
- PowerIcon,
21
28
  QrCodeIcon,
22
29
  RefreshCwIcon,
23
30
  Settings2Icon,
@@ -29,7 +36,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
29
36
  import { toast } from "sonner";
30
37
  import { encode } from "uqr";
31
38
  import { LhrRemoteAccess } from "@/components/settings/LhrRemoteAccess";
32
- import { ActionRow, PrefsGroup, RowBadge } from "@/components/settings/prefs";
39
+ import { ActionRow, ExpanderRow, PrefsGroup, RowBadge } from "@/components/settings/prefs";
33
40
  import {
34
41
  AlertDialog,
35
42
  AlertDialogAction,
@@ -43,6 +50,7 @@ import {
43
50
  import { Button } from "@/components/ui/button";
44
51
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
45
52
  import { Spinner } from "@/components/ui/spinner";
53
+ import { Switch } from "@/components/ui/switch";
46
54
  import { useRouter } from "@/hooks/useRouter";
47
55
  import { ApiError, apiErrorMessage, apiFetch } from "@/lib/api";
48
56
  import { settingsPath } from "@/lib/routes";
@@ -158,14 +166,19 @@ export function RemoteAccessGroup({ protectedApp }: { protectedApp: boolean }) {
158
166
  else void applySwitch(next);
159
167
  };
160
168
 
169
+ // Pilihan penyedia dirender panel penyedia di posisinya (sebelum Advanced).
170
+ const methodPicker = (
171
+ <ProviderPicker
172
+ value={provider}
173
+ live={live.provider}
174
+ disabled={switching}
175
+ onChange={(p) => void choose(p)}
176
+ />
177
+ );
178
+
161
179
  return (
162
- <div className="flex flex-col gap-4">
163
- <ProviderPicker
164
- value={provider}
165
- live={live.provider}
166
- disabled={switching}
167
- onChange={(p) => void choose(p)}
168
- />
180
+ <div className="flex flex-col gap-6">
181
+ <p className="px-1 text-[13px] leading-snug text-muted-foreground">{DESCRIPTION}</p>
169
182
  <SwitchDialog
170
183
  to={pendingSwitch}
171
184
  viaTunnel={live.url !== null && sameOrigin(live.url)}
@@ -186,10 +199,10 @@ export function RemoteAccessGroup({ protectedApp }: { protectedApp: boolean }) {
186
199
  LiveAddress={LiveAddress}
187
200
  LogPanel={LogPanel}
188
201
  onRequestStop={(run, via) => setLhrStop({ run, via })}
189
- onUseRecommended={() => void choose("kcg")}
202
+ methodPicker={methodPicker}
190
203
  />
191
204
  ) : (
192
- <KcgRemoteAccess protectedApp={protectedApp} />
205
+ <KcgRemoteAccess protectedApp={protectedApp} methodPicker={methodPicker} />
193
206
  )}
194
207
  <ConfirmDialog
195
208
  kind={lhrStop ? "stop" : null}
@@ -223,13 +236,13 @@ const PROVIDERS: ProviderInfo[] = [
223
236
  {
224
237
  id: "kcg",
225
238
  title: "KCG Code link",
226
- subtitle: "Same address every time · sign in with Google once",
239
+ subtitle: "Recommended · fixed address",
227
240
  recommended: true,
228
241
  },
229
242
  {
230
243
  id: "lhr",
231
244
  title: "localhost.run",
232
- subtitle: "No account needed · address changes every few hours",
245
+ subtitle: "No account · address changes",
233
246
  },
234
247
  ];
235
248
 
@@ -255,9 +268,8 @@ function ProviderPicker({
255
268
  <section aria-labelledby="remote-provider" className="flex flex-col gap-2.5">
256
269
  <div className="flex flex-col gap-0.5 px-1">
257
270
  <h2 id="remote-provider" className="text-[15px] font-semibold tracking-tight">
258
- Remote access
271
+ Connection method
259
272
  </h2>
260
- <p className="text-[13px] text-muted-foreground">{DESCRIPTION}</p>
261
273
  </div>
262
274
  {/* Boxed list ala AdwPreferencesGroup: satu baris radio per penyedia
263
275
  (AdwActionRow + GtkCheckButton grup). Radio native di dalam label:
@@ -299,14 +311,7 @@ function ProviderPicker({
299
311
  {selected && <span className="size-1.5 rounded-full bg-primary-foreground" />}
300
312
  </span>
301
313
  <span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
302
- <span className="flex flex-wrap items-center gap-x-2 gap-y-1">
303
- <span className="text-[15px] leading-5">{p.title}</span>
304
- {p.recommended && (
305
- <span className="rounded-full bg-primary/12 px-2 py-px text-[11.5px] font-medium leading-4 text-primary">
306
- Recommended
307
- </span>
308
- )}
309
- </span>
314
+ <span className="text-[15px] leading-5">{p.title}</span>
310
315
  <span className="text-[13px] leading-snug text-muted-foreground">
311
316
  {p.subtitle}
312
317
  </span>
@@ -378,7 +383,15 @@ function SwitchDialog({
378
383
  );
379
384
  }
380
385
 
381
- function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
386
+ function KcgRemoteAccess({
387
+ protectedApp,
388
+ methodPicker,
389
+ }: {
390
+ protectedApp: boolean;
391
+ /** Grup "Connection method" — dirender sebelum Advanced. */
392
+ methodPicker: ReactNode;
393
+ }) {
394
+ const { navigate } = useRouter();
382
395
  const [t, setT] = useState<TunnelStatus | null>(null);
383
396
  const [busy, setBusy] = useState<string | null>(null);
384
397
  const [confirm, setConfirm] = useState<ConfirmKind | null>(null);
@@ -426,40 +439,46 @@ function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
426
439
  // ---- API tunnel belum diatur (KCG_TUNNEL_API_URL / build) ----
427
440
  if (!t.configured) {
428
441
  return (
429
- <PrefsGroup id="remote">
430
- <ActionRow
431
- prefix={<StatusIcon tone="muted" />}
432
- title="Not available in this version"
433
- subtitle={
434
- <>
435
- Remote access hasn't been set up for this copy of KCG Code. If you manage it, set{" "}
436
- <code className="font-mono text-[12px]">KCG_TUNNEL_API_URL</code> and restart.
437
- </>
438
- }
439
- />
440
- </PrefsGroup>
442
+ <>
443
+ <PrefsGroup id="remote">
444
+ <ActionRow
445
+ prefix={<StatusIcon tone="muted" />}
446
+ title="Not available in this version"
447
+ subtitle={
448
+ <>
449
+ Remote access hasn't been set up for this copy of KCG Code. If you manage it, set{" "}
450
+ <code className="font-mono text-[12px]">KCG_TUNNEL_API_URL</code> and restart.
451
+ </>
452
+ }
453
+ />
454
+ </PrefsGroup>
455
+ {methodPicker}
456
+ </>
441
457
  );
442
458
  }
443
459
 
444
460
  // ---- belum tersambung ----
445
461
  if (!t.account) {
446
462
  return (
447
- <PrefsGroup id="remote">
448
- {t.pairing ? (
449
- <PairingRow
450
- pairing={t.pairing}
451
- busy={busy !== null}
452
- onCancel={() => void act("cancel", "/api/tunnel/connect/cancel")}
453
- />
454
- ) : (
455
- <SetupSteps
456
- protectedApp={protectedApp}
457
- error={t.error}
458
- busy={busy}
459
- onStart={() => void act("connect", "/api/tunnel/connect")}
460
- />
461
- )}
462
- </PrefsGroup>
463
+ <>
464
+ <PrefsGroup id="remote">
465
+ {t.pairing ? (
466
+ <PairingRow
467
+ pairing={t.pairing}
468
+ busy={busy !== null}
469
+ onCancel={() => void act("cancel", "/api/tunnel/connect/cancel")}
470
+ />
471
+ ) : (
472
+ <SetupSteps
473
+ protectedApp={protectedApp}
474
+ error={t.error}
475
+ busy={busy}
476
+ onStart={() => void act("connect", "/api/tunnel/connect")}
477
+ />
478
+ )}
479
+ </PrefsGroup>
480
+ {methodPicker}
481
+ </>
463
482
  );
464
483
  }
465
484
 
@@ -476,27 +495,87 @@ function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
476
495
  setConfirm(null);
477
496
  };
478
497
 
479
- // Selalu konfirmasi: mematikan dari HP tidak bisa dinyalakan lagi dari jauh.
480
- const turnOff = () => setConfirm("stop");
498
+ const online = running && t.phase === "online" && protectedApp;
481
499
  const account = t.account;
482
500
 
501
+ // Subjudul switch: satu frasa yang menjelaskan keadaan (pola penyedia
502
+ // localhost.run). Status lama yang memenuhi kartu (ikon besar, kotak QR
503
+ // kosong, tombol terpisah) dilebur ke sini.
504
+ let tone: Tone;
505
+ let subtitle: ReactNode;
506
+ if (!protectedApp) {
507
+ tone = "warning";
508
+ subtitle = "Needs a PIN or password first";
509
+ } else if (running && t.phase === "error") {
510
+ tone = "warning";
511
+ subtitle = (
512
+ <span className="text-destructive">
513
+ {t.error ? apiErrorMessage(t.error) : "Couldn't connect. Check your internet."}
514
+ </span>
515
+ );
516
+ } else if (online) {
517
+ tone = "success";
518
+ subtitle = "On · PIN still required";
519
+ } else if (running || busy === "start") {
520
+ tone = "busy";
521
+ subtitle = t.phase === "reconnecting" ? "Reconnecting…" : "Turning on…";
522
+ } else {
523
+ tone = "muted";
524
+ subtitle = "Off";
525
+ }
526
+
527
+ // Mematikan selalu dikonfirmasi: dari HP tidak bisa dinyalakan lagi.
528
+ const toggle = (next: boolean) => {
529
+ if (next) void act("start", "/api/tunnel/start");
530
+ else setConfirm("stop");
531
+ };
532
+
483
533
  return (
484
534
  <>
485
535
  <PrefsGroup id="remote">
486
- {running && <StatusHeader t={t} protectedApp={protectedApp} busy={busy} onStop={turnOff} />}
487
- <AddressPanel
488
- t={{ ...t, account }}
489
- running={running}
490
- protectedApp={protectedApp}
491
- busy={busy}
492
- onStart={() => void act("start", "/api/tunnel/start")}
536
+ <SwitchRow
537
+ id="kcg-remote-switch"
538
+ icon={<StatusIcon tone={tone} />}
539
+ title="Remote access"
540
+ subtitle={subtitle}
541
+ checked={running || busy === "start"}
542
+ disabled={!protectedApp || busy !== null}
543
+ busy={busy === "start" || busy === "stop"}
544
+ onChange={toggle}
493
545
  />
546
+ {!protectedApp && (
547
+ <ActionRow
548
+ prefix={<RowGlyph icon={<LockIcon className="size-[18px]" aria-hidden />} />}
549
+ title="Set up app lock"
550
+ subtitle="Add a PIN or password in Security"
551
+ onActivate={() => navigate(settingsPath("security"))}
552
+ />
553
+ )}
554
+ {protectedApp && running && t.phase === "error" && (
555
+ <ActionRow
556
+ prefix={<RowGlyph icon={<RefreshCwIcon className="size-[18px]" aria-hidden />} />}
557
+ title="Try again"
558
+ disabled={busy !== null}
559
+ onActivate={() => void act("start", "/api/tunnel/start")}
560
+ />
561
+ )}
562
+ </PrefsGroup>
563
+
564
+ {online && (
565
+ <PrefsGroup title="Your link">
566
+ <LiveAddress url={account.url} />
567
+ </PrefsGroup>
568
+ )}
569
+
570
+ <PrefsGroup title="Account">
494
571
  <ActionRow
495
- title="Linked Google account"
496
- subtitle={<span className="break-all">{t.account.email}</span>}
572
+ title="Google account"
573
+ subtitle={<span className="break-all">{account.email}</span>}
497
574
  />
498
575
  </PrefsGroup>
499
576
 
577
+ {methodPicker}
578
+
500
579
  <AdvancedSection
501
580
  busy={busy !== null}
502
581
  onRotate={() => setConfirm("rotate")}
@@ -514,6 +593,52 @@ function KcgRemoteAccess({ protectedApp }: { protectedApp: boolean }) {
514
593
  );
515
594
  }
516
595
 
596
+ /**
597
+ * AdwSwitchRow: ikon, judul + status di subjudul, switch di kanan. Seluruh
598
+ * baris adalah label switch (klik di mana saja mengubahnya).
599
+ */
600
+ function SwitchRow({
601
+ id,
602
+ icon,
603
+ title,
604
+ subtitle,
605
+ checked,
606
+ disabled,
607
+ busy,
608
+ onChange,
609
+ }: {
610
+ id: string;
611
+ icon: ReactNode;
612
+ title: string;
613
+ subtitle: ReactNode;
614
+ checked: boolean;
615
+ disabled: boolean;
616
+ busy: boolean;
617
+ onChange: (checked: boolean) => void;
618
+ }) {
619
+ return (
620
+ <li>
621
+ <label
622
+ htmlFor={id}
623
+ className={cn(
624
+ "flex min-h-14 items-center gap-3 px-4 py-2.5",
625
+ !disabled && "cursor-pointer",
626
+ )}
627
+ >
628
+ {icon}
629
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
630
+ <span className="text-[15px] leading-5">{title}</span>
631
+ <span role="status" className="text-[13px] leading-snug text-muted-foreground">
632
+ {subtitle}
633
+ </span>
634
+ </span>
635
+ {busy && <Spinner className="size-4 text-muted-foreground" aria-hidden />}
636
+ <Switch id={id} checked={checked} disabled={disabled} onCheckedChange={onChange} />
637
+ </label>
638
+ </li>
639
+ );
640
+ }
641
+
517
642
  /** `true` bila halaman ini sedang dibuka lewat alamat tunnel. */
518
643
  function sameOrigin(url: string): boolean {
519
644
  try {
@@ -550,66 +675,6 @@ function StatusIcon({ tone, className }: { tone: Tone; className?: string }) {
550
675
  );
551
676
  }
552
677
 
553
- /**
554
- * Baris status di atas kartu, hanya saat remote access menyala (atau sedang
555
- * menyala). Saat mati, status + tombol "Turn on" ada di `AddressPanel`.
556
- */
557
- function StatusHeader({
558
- t,
559
- protectedApp,
560
- busy,
561
- onStop,
562
- }: {
563
- t: TunnelStatus;
564
- protectedApp: boolean;
565
- busy: string | null;
566
- onStop: () => void;
567
- }) {
568
- let tone: Tone;
569
- let title: string;
570
- let body: string;
571
- if (!protectedApp || t.phase === "error") {
572
- tone = "warning";
573
- title = "Couldn't connect";
574
- body = !protectedApp
575
- ? "Set a PIN or password to keep using remote access."
576
- : "Something went wrong. See below for details.";
577
- } else if (t.phase === "online") {
578
- tone = "success";
579
- title = "Remote access is on";
580
- body = "Anyone opening your link still needs your PIN or password.";
581
- } else {
582
- tone = "busy";
583
- title = t.phase === "reconnecting" ? "Reconnecting…" : "Turning on…";
584
- body = "This usually takes a few seconds.";
585
- }
586
-
587
- return (
588
- <ActionRow
589
- prefix={<StatusIcon tone={tone} />}
590
- title={<span className="font-medium">{title}</span>}
591
- subtitle={<span role="status">{body}</span>}
592
- suffix={
593
- <Button
594
- type="button"
595
- variant="outline"
596
- size="sm"
597
- disabled={busy !== null}
598
- onClick={onStop}
599
- className="hover:border-destructive/40 hover:bg-destructive/10 hover:text-destructive dark:hover:bg-destructive/15"
600
- >
601
- {busy === "stop" ? (
602
- <Spinner data-icon="inline-start" />
603
- ) : (
604
- <PowerIcon data-icon="inline-start" />
605
- )}
606
- Turn off
607
- </Button>
608
- }
609
- />
610
- );
611
- }
612
-
613
678
  // --------------------------------------------------------- not linked ----
614
679
 
615
680
  function SetupSteps({
@@ -820,91 +885,15 @@ function QrCode({ value, label, className }: { value: string; label: string; cla
820
885
  * placeholder seukuran QR berisi ikon, keterangan, dan aksi utama di tengah
821
886
  * (Turn on / Try again / Set up lock) agar tata letak tidak "melompat".
822
887
  */
823
- function AddressPanel({
824
- t,
825
- running,
826
- protectedApp,
827
- busy,
828
- onStart,
829
- }: {
830
- t: TunnelStatus & { account: NonNullable<TunnelStatus["account"]> };
831
- running: boolean;
832
- protectedApp: boolean;
833
- busy: string | null;
834
- onStart: () => void;
835
- }) {
836
- const { navigate } = useRouter();
837
-
838
- if (t.phase === "online" && protectedApp) return <LiveAddress url={t.account.url} />;
839
-
840
- let icon: ReactNode;
841
- let title: string;
842
- let body: string;
843
- let action: ReactNode = null;
844
- if (!protectedApp) {
845
- icon = <LockIcon className="size-9" aria-hidden />;
846
- title = "Set a PIN or password first";
847
- body = "For your safety, remote access only works when KCG Code is locked.";
848
- action = (
849
- <Button type="button" onClick={() => navigate(settingsPath("security"))}>
850
- <LockIcon data-icon="inline-start" />
851
- Set up lock
852
- </Button>
853
- );
854
- } else if (running && t.phase === "error") {
855
- icon = <CircleAlertIcon className="size-9 text-amber-600 dark:text-amber-400" aria-hidden />;
856
- title = "Your link isn't working right now";
857
- body = t.error ? apiErrorMessage(t.error) : "Check your internet connection and try again.";
858
- action = (
859
- <Button type="button" disabled={busy !== null} onClick={onStart}>
860
- {busy === "start" ? (
861
- <Spinner data-icon="inline-start" />
862
- ) : (
863
- <RefreshCwIcon data-icon="inline-start" />
864
- )}
865
- Try again
866
- </Button>
867
- );
868
- } else if (running) {
869
- icon = <Spinner className="size-9" />;
870
- title = "Getting your link ready…";
871
- body = "Your QR code and link will appear here in a moment.";
872
- } else {
873
- icon = <QrCodeIcon className="size-9" aria-hidden />;
874
- title = "Remote access is off";
875
- body = "Turn it on to get a QR code and link for opening KCG Code on your phone.";
876
- action = (
877
- <Button type="button" disabled={busy !== null} onClick={onStart}>
878
- {busy === "start" ? (
879
- <Spinner data-icon="inline-start" />
880
- ) : (
881
- <PowerIcon data-icon="inline-start" />
882
- )}
883
- Turn on
884
- </Button>
885
- );
886
- }
887
-
888
- return (
889
- <li className="flex flex-col items-center gap-4 px-4 py-6 text-center">
890
- <div
891
- className="flex size-40 items-center justify-center rounded-xl border-2 border-dashed bg-muted/30 text-muted-foreground"
892
- aria-hidden
893
- >
894
- {icon}
895
- </div>
896
- <div className="flex max-w-sm flex-col gap-1" role="status">
897
- <p className="text-[15px] font-medium">{title}</p>
898
- <p className="text-[13px] leading-snug text-muted-foreground">{body}</p>
899
- </div>
900
- {action}
901
- </li>
902
- );
903
- }
904
-
888
+ /**
889
+ * Baris "Your link" (dipakai kedua penyedia, di dalam boxed list):
890
+ * - desktop: QR selalu tampil (cara tercepat pindah ke HP);
891
+ * - baris link (mono, terpotong) + tombol salin & buka;
892
+ * - HP: "Share link" (Web Share API) dan QR dilipat di baris expander —
893
+ * pengguna sudah di HP, QR jarang dibutuhkan.
894
+ */
905
895
  function LiveAddress({ url }: { url: string }) {
906
896
  const [copied, setCopied] = useState(false);
907
- const [showQr, setShowQr] = useState(false);
908
897
  const copy = async () => {
909
898
  try {
910
899
  await navigator.clipboard.writeText(url);
@@ -926,72 +915,58 @@ function LiveAddress({ url }: { url: string }) {
926
915
  /* dibatalkan pengguna */
927
916
  }
928
917
  };
918
+ const host = url.replace(/^https?:\/\//, "");
929
919
 
930
920
  return (
931
- <li className="flex flex-col gap-4 px-4 py-4">
932
- {/* Desktop: QR selalu tampil (cara tercepat pindah ke HP).
933
- Mobile: disembunyikan (pengguna sudah di HP) kecuali diminta. */}
934
- <div className={cn("justify-center sm:flex", showQr ? "flex" : "hidden")}>
921
+ <>
922
+ <li className="hidden justify-center py-5 sm:flex">
935
923
  <QrCode value={url} label="QR code of your remote access link" className="size-40" />
936
- </div>
937
-
938
- <div className="flex min-w-0 flex-col gap-2">
939
- <span className="text-[13px] font-medium">Your link</span>
940
- {/* Link + tombol salin menyatu, seperti kolom input read-only. */}
941
- <div className="flex items-center gap-1 rounded-lg border bg-muted/40 py-1 pr-1 pl-3">
942
- <a
943
- href={url}
944
- target="_blank"
945
- rel="noopener noreferrer"
946
- className="min-w-0 flex-1 truncate py-1 font-mono text-[13.5px] hover:underline"
947
- title={url}
948
- >
949
- {url.replace(/^https?:\/\//, "")}
924
+ </li>
925
+ <li className="flex min-h-14 items-center gap-2 py-2 pr-2 pl-4">
926
+ <a
927
+ href={url}
928
+ target="_blank"
929
+ rel="noopener noreferrer"
930
+ className="min-w-0 flex-1 truncate font-mono text-[14px] hover:underline"
931
+ title={url}
932
+ >
933
+ {host}
934
+ </a>
935
+ <Button
936
+ type="button"
937
+ variant="ghost"
938
+ size="icon-sm"
939
+ className="rounded-full"
940
+ aria-label={copied ? "Copied" : "Copy link"}
941
+ onClick={() => void copy()}
942
+ >
943
+ {copied ? <CheckIcon className="text-emerald-600 dark:text-emerald-400" /> : <CopyIcon />}
944
+ </Button>
945
+ <Button asChild variant="ghost" size="icon-sm" className="rounded-full">
946
+ <a href={url} target="_blank" rel="noopener noreferrer" aria-label="Open link in new tab">
947
+ <ExternalLinkIcon />
950
948
  </a>
951
- <Button
952
- type="button"
953
- variant="ghost"
954
- size="icon-sm"
955
- aria-label={copied ? "Copied" : "Copy link"}
956
- onClick={() => void copy()}
957
- >
958
- {copied ? (
959
- <CheckIcon className="text-emerald-600 dark:text-emerald-400" />
960
- ) : (
961
- <CopyIcon />
962
- )}
963
- </Button>
964
- <Button asChild variant="ghost" size="icon-sm">
965
- <a
966
- href={url}
967
- target="_blank"
968
- rel="noopener noreferrer"
969
- aria-label="Open link in new tab"
970
- >
971
- <ExternalLinkIcon />
972
- </a>
973
- </Button>
974
- </div>
975
- <div className="flex flex-wrap gap-2 sm:hidden">
976
- {canShare && (
977
- <Button type="button" size="sm" onClick={() => void share()}>
978
- <Share2Icon data-icon="inline-start" />
979
- Share link
980
- </Button>
981
- )}
982
- <Button
983
- type="button"
984
- variant="outline"
985
- size="sm"
986
- aria-expanded={showQr}
987
- onClick={() => setShowQr((v) => !v)}
988
- >
989
- <QrCodeIcon data-icon="inline-start" />
990
- {showQr ? "Hide QR code" : "Show QR code"}
991
- </Button>
949
+ </Button>
950
+ </li>
951
+ {canShare && (
952
+ <ActionRow
953
+ className="sm:hidden"
954
+ prefix={<RowGlyph icon={<Share2Icon className="size-[18px]" aria-hidden />} />}
955
+ title="Share link"
956
+ onActivate={() => void share()}
957
+ />
958
+ )}
959
+ <ExpanderRow
960
+ className="sm:hidden"
961
+ prefix={<RowGlyph icon={<QrCodeIcon className="size-[18px]" aria-hidden />} />}
962
+ title="QR code"
963
+ subtitle="Scan from another device"
964
+ >
965
+ <div className="flex justify-center py-2">
966
+ <QrCode value={url} label="QR code of your remote access link" className="size-44" />
992
967
  </div>
993
- </div>
994
- </li>
968
+ </ExpanderRow>
969
+ </>
995
970
  );
996
971
  }
997
972