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
@@ -1,19 +1,19 @@
1
1
  /**
2
2
  * Daftar & pembuatan Session milik sebuah Project (Requirement 1.1, 1.3, 1.5).
3
3
  *
4
- * Halaman detail Project — sengaja minimal: tipografi & ruang kosong yang
5
- * bekerja, bukan kartu/tile.
6
- * - Header: nama Project besar, path (salin saat hover), satu baris ringkasan
7
- * ("3 sessions · 1 running"). Aksi: "New session" + menu "..." Reload/Delete
8
- * (`ProjectMenu`; hapus tetap lewat dialog konfirmasi). Desktop: di
9
- * kanan judul. Layar sempit: "New session" jadi tombol melayang kanan-bawah
10
- * (area jempol); toolbar di bawah judul berisi "Details" (panel samping)
11
- * dan grup Reload/Delete.
4
+ * Halaman detail Project — gaya libadwaita (GNOME Settings), sama dengan
5
+ * panel samping: grup berjudul berisi "boxed list" (primitif `settings/prefs`).
6
+ * - Header: nama Project besar, path (salin saat hover). Aksi: tombol pil
7
+ * "New session" + tombol bundar "..." Reload/Delete (`ProjectMenu`; hapus
8
+ * tetap lewat dialog konfirmasi). Desktop: di kanan judul. Layar sempit:
9
+ * "New session" jadi tombol melayang kanan-bawah (area jempol); toolbar di
10
+ * bawah judul berisi "Details" (panel samping) dan menu "...".
12
11
  * - Panel samping (`ProjectSidePanel`, gaya Claude Projects): custom
13
12
  * instruction, MCP server, dan skill. Layar `lg`+ = kolom kanan sticky;
14
13
  * lebih sempit = Sheet dari kanan lewat tombol di header.
15
14
  * Tanpa tombol back — navigasi lewat sidebar.
16
- * - Daftar Session: baris polos dipisah hairline, status lewat titik warna.
15
+ * - Daftar Session: grup "Sessions" (ringkasan "3 sessions · 1 running"
16
+ * sebagai deskripsi) berisi baris AdwActionRow; kosong -> AdwStatusPage.
17
17
  * Urutan dari `sortSessions` (running -> crashed -> stopped, terbaru dulu).
18
18
  * - `GET /api/sessions` di-filter per `projectId` (Requirement 1.5).
19
19
  *
@@ -26,12 +26,14 @@
26
26
  import {
27
27
  CheckIcon,
28
28
  CopyIcon,
29
+ ListChecksIcon,
29
30
  MessagesSquareIcon,
30
31
  MoreHorizontalIcon,
31
32
  PanelRightIcon,
32
33
  PlusIcon,
33
34
  RefreshCwIcon,
34
35
  Trash2Icon,
36
+ TriangleAlertIcon,
35
37
  } from "lucide-react";
36
38
  import { useEffect, useState } from "react";
37
39
  import { toast } from "sonner";
@@ -40,7 +42,7 @@ import { ConfirmDeleteProjectDialog } from "@/components/sessions/ConfirmDeleteP
40
42
  import { ConfirmSessionDeleteDialog } from "@/components/sessions/ConfirmSessionDeleteDialog";
41
43
  import { NewSessionDialog } from "@/components/sessions/NewSessionDialog";
42
44
  import { SessionListSkeleton, SessionRow } from "@/components/sessions/SessionRow";
43
- import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
45
+ import { ActionRow, PrefsGroup } from "@/components/settings/prefs";
44
46
  import { Button } from "@/components/ui/button";
45
47
  import {
46
48
  DropdownMenu,
@@ -60,6 +62,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
60
62
  import { useMediaQuery } from "@/hooks/useMediaQuery";
61
63
  import { useSessionList } from "@/hooks/useSessionList";
62
64
  import { notifyDataChanged } from "@/lib/data-events";
65
+ import { describeSelection, pluralSessions } from "@/lib/session-selection";
63
66
  import { describeSessionSummary } from "@/lib/session-summary";
64
67
  import { cn } from "@/lib/utils";
65
68
  import type { Project, Session } from "@/types";
@@ -137,12 +140,13 @@ function ProjectMenu({
137
140
  <Tooltip>
138
141
  <TooltipTrigger asChild>
139
142
  <DropdownMenuTrigger asChild>
143
+ {/* Tombol bundar `.circular` Adwaita: latar terisi, tanpa border. */}
140
144
  <Button
141
145
  type="button"
142
- variant="outline"
146
+ variant="secondary"
143
147
  size="icon"
144
148
  aria-label="More project actions"
145
- className={cn("rounded-full text-muted-foreground", className)}
149
+ className={cn("rounded-full", className)}
146
150
  >
147
151
  <MoreHorizontalIcon />
148
152
  </Button>
@@ -166,29 +170,28 @@ function ProjectMenu({
166
170
  }
167
171
 
168
172
  /**
169
- * Empty state saat Project belum punya Session: kartu bergaris putus-putus
170
- * (ikon, judul, penjelasan singkat, satu aksi utama).
173
+ * Empty state saat Project belum punya Session — pola AdwStatusPage: ikon
174
+ * simbolik besar redup, judul, deskripsi, satu tombol pil aksi utama.
171
175
  */
172
176
  function EmptySessions({ projectName, onStart }: { projectName: string; onStart: () => void }) {
173
177
  return (
174
- <div className="flex flex-col items-center gap-6 rounded-2xl border border-dashed bg-muted/20 px-6 py-12 text-center sm:py-16">
175
- <span
176
- className="flex size-14 items-center justify-center rounded-2xl bg-primary/10 text-primary"
178
+ <div className="flex flex-col items-center gap-6 px-6 py-12 text-center sm:py-20">
179
+ <MessagesSquareIcon
180
+ className="size-16 text-muted-foreground/50"
181
+ strokeWidth={1.5}
177
182
  aria-hidden
178
- >
179
- <MessagesSquareIcon className="size-7" />
180
- </span>
183
+ />
181
184
 
182
- <div className="flex max-w-sm flex-col gap-1.5">
183
- <h2 className="text-lg font-semibold tracking-tight">Start your first session</h2>
184
- <p className="text-sm leading-relaxed text-muted-foreground">
185
+ <div className="flex max-w-sm flex-col gap-2">
186
+ <h2 className="text-2xl font-bold tracking-tight">No Sessions Yet</h2>
187
+ <p className="text-[15px] leading-relaxed text-muted-foreground">
185
188
  A session is a conversation with the agent inside{" "}
186
189
  <span className="font-medium text-foreground">{projectName}</span>. It can read and edit
187
190
  files here for you.
188
191
  </p>
189
192
  </div>
190
193
 
191
- <Button type="button" onClick={onStart} className="rounded-full px-5">
194
+ <Button type="button" onClick={onStart} className="h-10 rounded-full px-6">
192
195
  <PlusIcon data-icon="inline-start" />
193
196
  New session
194
197
  </Button>
@@ -210,6 +213,25 @@ export function SessionList({
210
213
  const openForm = () => setFormOpen(true);
211
214
 
212
215
  const hasSessions = !list.loading && !list.loadError && list.ordered.length > 0;
216
+ const allSelected =
217
+ list.sessions.length > 0 && list.sessions.every((s) => list.selected.has(s.id));
218
+
219
+ // Esc keluar dari selection mode (kecuali ada dialog terbuka — Esc
220
+ // miliknya). Daftar kosong (semua terhapus) juga mengakhiri mode ini.
221
+ const { selecting, stopSelecting, bulkDeleteOpen } = list;
222
+ useEffect(() => {
223
+ if (!selecting) return;
224
+ const onKey = (e: KeyboardEvent) => {
225
+ if (e.key !== "Escape" || bulkDeleteOpen || e.defaultPrevented) return;
226
+ if (document.querySelector("[role=dialog],[role=alertdialog]")) return;
227
+ stopSelecting();
228
+ };
229
+ window.addEventListener("keydown", onKey);
230
+ return () => window.removeEventListener("keydown", onKey);
231
+ }, [selecting, stopSelecting, bulkDeleteOpen]);
232
+ useEffect(() => {
233
+ if (selecting && !list.loading && list.sessions.length === 0) stopSelecting();
234
+ }, [selecting, stopSelecting, list.loading, list.sessions.length]);
213
235
  /** Layar lebar: panel samping inline. Sempit: panel di Sheet (tombol header). */
214
236
  const wide = useMediaQuery(WIDE_QUERY);
215
237
  const [detailsOpen, setDetailsOpen] = useState(false);
@@ -245,19 +267,13 @@ export function SessionList({
245
267
  )}
246
268
  </div>
247
269
 
248
- {/* Satu baris ringkasan — cukup teks, tanpa tile. Disembunyikan saat
249
- kosong: empty state di bawah sudah menyampaikannya. */}
250
- {!list.loading && !list.loadError && list.summary.total > 0 && (
251
- <p className="text-sm text-muted-foreground">{describeSessionSummary(list.summary)}</p>
252
- )}
253
-
254
270
  {/* Layar sempit: toolbar aksi sekunder di bawah judul. Aksi utama
255
271
  (New session) jadi tombol melayang kanan-bawah — area jempol. */}
256
272
  {!wide && (
257
273
  <div className="flex items-center justify-between gap-2">
258
274
  <Button
259
275
  type="button"
260
- variant="outline"
276
+ variant="secondary"
261
277
  onClick={() => setDetailsOpen(true)}
262
278
  aria-label="Project details: instructions, MCP servers, skills"
263
279
  className="h-10 rounded-full px-3.5"
@@ -283,40 +299,115 @@ export function SessionList({
283
299
  {list.loading ? (
284
300
  <SessionListSkeleton />
285
301
  ) : list.loadError ? (
286
- <Alert variant="destructive">
287
- <AlertTitle>Failed to load sessions</AlertTitle>
288
- <AlertDescription className="flex flex-col items-start gap-3">
289
- {list.loadError}
290
- <Button
291
- type="button"
292
- variant="outline"
293
- size="sm"
294
- onClick={() => void list.refresh()}
295
- >
296
- <RefreshCwIcon data-icon="inline-start" />
297
- Retry
298
- </Button>
299
- </AlertDescription>
300
- </Alert>
302
+ <PrefsGroup title="Sessions">
303
+ <ActionRow
304
+ prefix={<TriangleAlertIcon className="size-[18px] text-destructive" aria-hidden />}
305
+ title="Failed to load sessions"
306
+ subtitle={list.loadError}
307
+ suffix={
308
+ <Button
309
+ type="button"
310
+ variant="outline"
311
+ size="sm"
312
+ onClick={() => void list.refresh()}
313
+ >
314
+ <RefreshCwIcon data-icon="inline-start" />
315
+ Retry
316
+ </Button>
317
+ }
318
+ />
319
+ </PrefsGroup>
301
320
  ) : hasSessions ? (
302
- <ul className="-mx-3 flex flex-col">
303
- {list.ordered.map((session) => (
304
- <li key={session.id}>
305
- <SessionRow
306
- session={session}
307
- busy={
308
- list.stopping === session.id ||
309
- list.starting === session.id ||
310
- list.deleting === session.id
311
- }
312
- onOpen={() => onOpenSession(session)}
313
- onStop={() => void list.stop(session.id)}
314
- onStart={() => void list.start(session.id)}
315
- onDelete={() => list.requestDelete(session)}
316
- />
317
- </li>
318
- ))}
319
- </ul>
321
+ <>
322
+ {/* AdwPreferencesGroup: ringkasan status sebagai deskripsi grup,
323
+ Session sebagai boxed list. Tombol "Select" masuk selection
324
+ mode (pola Nautilus/Contacts) untuk hapus massal. */}
325
+ <PrefsGroup
326
+ title="Sessions"
327
+ description={
328
+ list.selecting
329
+ ? describeSelection(list.selected.size, list.sessions.length)
330
+ : describeSessionSummary(list.summary)
331
+ }
332
+ suffix={
333
+ list.selecting ? (
334
+ <Button
335
+ type="button"
336
+ variant="ghost"
337
+ size="sm"
338
+ onClick={list.toggleSelectAll}
339
+ disabled={list.bulkProgress !== null}
340
+ >
341
+ {allSelected ? "Deselect All" : "Select All"}
342
+ </Button>
343
+ ) : (
344
+ <Button
345
+ type="button"
346
+ variant="ghost"
347
+ size="sm"
348
+ onClick={list.startSelecting}
349
+ aria-label="Select sessions"
350
+ >
351
+ <ListChecksIcon data-icon="inline-start" />
352
+ Select
353
+ </Button>
354
+ )
355
+ }
356
+ >
357
+ {list.ordered.map((session) => (
358
+ <li key={session.id}>
359
+ <SessionRow
360
+ session={session}
361
+ busy={
362
+ list.stopping === session.id ||
363
+ list.starting === session.id ||
364
+ list.deleting === session.id
365
+ }
366
+ onOpen={() => onOpenSession(session)}
367
+ onStop={() => void list.stop(session.id)}
368
+ onStart={() => void list.start(session.id)}
369
+ onDelete={() => list.requestDelete(session)}
370
+ selectionMode={list.selecting}
371
+ selected={list.selected.has(session.id)}
372
+ onToggleSelect={() => list.toggleSelect(session.id)}
373
+ />
374
+ </li>
375
+ ))}
376
+ </PrefsGroup>
377
+
378
+ {/* Action bar selection mode (GtkActionBar): melayang di dasar
379
+ area scroll — jumlah terpilih, Cancel, Delete (destruktif). */}
380
+ {list.selecting && (
381
+ <div className="sticky bottom-[max(1rem,env(safe-area-inset-bottom))] z-20 mt-4 flex items-center gap-2 rounded-2xl border bg-popover py-2 pr-2 pl-4 shadow-lg">
382
+ <span className="min-w-0 truncate text-sm font-medium" aria-live="polite">
383
+ {list.selected.size === 0
384
+ ? "None selected"
385
+ : `${pluralSessions(list.selected.size)} selected`}
386
+ </span>
387
+ <Button
388
+ type="button"
389
+ variant="ghost"
390
+ size="sm"
391
+ className="ml-auto"
392
+ onClick={list.stopSelecting}
393
+ disabled={list.bulkProgress !== null}
394
+ >
395
+ Cancel
396
+ </Button>
397
+ <Button
398
+ type="button"
399
+ variant="destructive"
400
+ size="sm"
401
+ className="rounded-full px-3.5"
402
+ onClick={list.requestBulkDelete}
403
+ disabled={list.selected.size === 0 || list.bulkProgress !== null}
404
+ >
405
+ <Trash2Icon data-icon="inline-start" />
406
+ Delete
407
+ </Button>
408
+ </div>
409
+ )}
410
+ </>
320
411
  ) : (
321
412
  <EmptySessions projectName={project.name} onStart={openForm} />
322
413
  )}
@@ -350,7 +441,8 @@ export function SessionList({
350
441
  {/* Mobile/tablet: aksi utama melayang di kanan-bawah (jangkauan jempol,
351
442
  selalu terlihat saat scroll). Label teks tetap ada agar jelas —
352
443
  bukan ikon "+" tanpa arti. Safe-area iOS dihormati. */}
353
- {!wide && (
444
+ {/* Disembunyikan saat selection mode: tempatnya dipakai action bar. */}
445
+ {!wide && !list.selecting && (
354
446
  <Button
355
447
  type="button"
356
448
  onClick={openForm}
@@ -366,9 +458,12 @@ export function SessionList({
366
458
  open={formOpen}
367
459
  onOpenChange={setFormOpen}
368
460
  projectId={project.id}
369
- onCreated={() => {
461
+ projectName={project.name}
462
+ onCreated={(session) => {
370
463
  void list.refresh();
371
464
  notifyDataChanged();
465
+ // Session baru langsung dibuka — siap menerima prompt pertama.
466
+ onOpenSession(session);
372
467
  }}
373
468
  />
374
469
 
@@ -382,6 +477,18 @@ export function SessionList({
382
477
  onConfirm={() => void list.confirmRemove()}
383
478
  />
384
479
 
480
+ {/* Konfirmasi hapus massal (selection mode) — progres di tombol. */}
481
+ <ConfirmSessionDeleteDialog
482
+ open={list.bulkDeleteOpen}
483
+ onOpenChange={(open) => {
484
+ if (!open) list.cancelBulkDelete();
485
+ }}
486
+ count={list.selected.size}
487
+ deleting={list.bulkProgress !== null}
488
+ progress={list.bulkProgress}
489
+ onConfirm={() => void list.confirmBulkRemove()}
490
+ />
491
+
385
492
  {/* Konfirmasi hapus Project (beserta seluruh Session-nya) */}
386
493
  <ConfirmDeleteProjectDialog
387
494
  open={list.projectDeleteOpen}
@@ -1,12 +1,22 @@
1
1
  /**
2
- * Satu baris Session di daftar (`SessionList`).
3
- *
4
- * Baris polos tanpa kotak: titik status, judul, meta tipis, waktu di kanan.
2
+ * Satu baris Session di daftar (`SessionList`) — baris boxed list ala
3
+ * AdwActionRow (libadwaita), sama dengan baris panel samping Project: logo
4
+ * agent 18px sebagai ikon, judul + subjudul (status berwarna · model ·
5
+ * waktu), tombol menu bundar sebagai suffix, lalu chevron navigasi.
5
6
  * Badan baris adalah tombol buka (target sentuh lebar); aksi sekunder ada di
6
7
  * menu "..." — di perangkat dengan pointer halus (mouse) menu baru muncul
7
8
  * saat hover/fokus agar daftar tetap bersih, di layar sentuh selalu tampil.
9
+ * Pemanggil membungkusnya dengan `<li>` di dalam `PrefsGroup`.
8
10
  */
9
- import { MoreHorizontalIcon, PlayIcon, SquareIcon, Trash2Icon } from "lucide-react";
11
+ import {
12
+ CheckIcon,
13
+ ChevronRightIcon,
14
+ MoreHorizontalIcon,
15
+ PlayIcon,
16
+ SquareIcon,
17
+ Trash2Icon,
18
+ } from "lucide-react";
19
+ import { AgentLogo } from "@/components/sessions/AgentLogo";
10
20
  import { Button } from "@/components/ui/button";
11
21
  import {
12
22
  DropdownMenu,
@@ -17,6 +27,7 @@ import {
17
27
  } from "@/components/ui/dropdown-menu";
18
28
  import { Skeleton } from "@/components/ui/skeleton";
19
29
  import { Spinner } from "@/components/ui/spinner";
30
+ import { AGENT_TYPE_LABELS } from "@/lib/agent-types";
20
31
  import { formatRelativeTime } from "@/lib/project-overview";
21
32
  import { SESSION_STATUS_DOT, SESSION_STATUS_LABEL } from "@/lib/session-status";
22
33
  import { describeSessionModel } from "@/lib/session-summary";
@@ -32,106 +43,195 @@ export interface SessionRowProps {
32
43
  onStop: () => void;
33
44
  onStart: () => void;
34
45
  onDelete: () => void;
46
+ /**
47
+ * Selection mode (hapus massal): kotak centang di depan, klik baris
48
+ * memilih/membatalkan pilihan, menu & chevron disembunyikan.
49
+ */
50
+ selectionMode?: boolean;
51
+ selected?: boolean;
52
+ onToggleSelect?: () => void;
35
53
  }
36
54
 
37
- /** Titik status; running diberi denyut halus. */
38
- function StatusDot({ session, busy }: { session: Session; busy: boolean }) {
39
- if (busy) return <Spinner className="size-3 text-muted-foreground" />;
40
- const running = session.status === "running";
55
+ /** Kotak centang ala GtkCheckButton (GTK4): kotak membulat, terisi aksen. */
56
+ function CheckBox({ checked }: { checked: boolean }) {
41
57
  return (
42
- <span className="relative flex size-2" aria-hidden>
43
- {running && (
44
- <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-50" />
58
+ <span
59
+ aria-hidden
60
+ className={cn(
61
+ "flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border-2 transition-colors",
62
+ checked
63
+ ? "border-primary bg-primary text-primary-foreground"
64
+ : "border-muted-foreground/45",
45
65
  )}
46
- <span
47
- className={cn(
48
- "relative inline-flex size-2 rounded-full",
49
- SESSION_STATUS_DOT[session.status],
66
+ >
67
+ {checked && <CheckIcon className="size-3" strokeWidth={3.5} />}
68
+ </span>
69
+ );
70
+ }
71
+
72
+ /** Warna teks status di subjudul (selaras status MCP di panel samping). */
73
+ const STATUS_TEXT: Record<Session["status"], string> = {
74
+ running: "text-emerald-700 dark:text-emerald-400",
75
+ crashed: "text-destructive",
76
+ stopped: "text-muted-foreground",
77
+ };
78
+
79
+ /**
80
+ * Status di awal subjudul — titik + label berwarna, seperti status MCP.
81
+ * `stopped` (keadaan biasa) tidak ditampilkan agar daftar tidak ramai.
82
+ */
83
+ function StatusLabel({ session, busy }: { session: Session; busy: boolean }) {
84
+ if (busy) {
85
+ return (
86
+ <span className="inline-flex shrink-0 items-center gap-1.5">
87
+ <Spinner className="size-3" />
88
+ Working…
89
+ </span>
90
+ );
91
+ }
92
+ if (session.status === "stopped") return null;
93
+ const running = session.status === "running";
94
+ return (
95
+ <span className={cn("inline-flex shrink-0 items-center gap-1.5", STATUS_TEXT[session.status])}>
96
+ <span className="relative flex size-1.5" aria-hidden>
97
+ {running && (
98
+ <span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
50
99
  )}
51
- />
100
+ <span
101
+ className={cn(
102
+ "relative inline-flex size-1.5 rounded-full",
103
+ SESSION_STATUS_DOT[session.status],
104
+ )}
105
+ />
106
+ </span>
107
+ {SESSION_STATUS_LABEL[session.status]}
108
+ </span>
109
+ );
110
+ }
111
+
112
+ /** Pemisah "·" di subjudul; jarak kiri/kanan datang dari `gap` induk. */
113
+ function Dot() {
114
+ return (
115
+ <span className="opacity-50" aria-hidden>
116
+ ·
52
117
  </span>
53
118
  );
54
119
  }
55
120
 
56
- export function SessionRow({ session, busy, onOpen, onStop, onStart, onDelete }: SessionRowProps) {
121
+ export function SessionRow({
122
+ session,
123
+ busy,
124
+ onOpen,
125
+ onStop,
126
+ onStart,
127
+ onDelete,
128
+ selectionMode = false,
129
+ selected = false,
130
+ onToggleSelect,
131
+ }: SessionRowProps) {
57
132
  const running = session.status === "running";
58
133
  const title = displaySessionTitle(session.title);
134
+ const agentLabel = AGENT_TYPE_LABELS[session.agentType];
135
+ const hasStatus = busy || session.status !== "stopped";
59
136
 
60
137
  return (
61
- <div className="group relative flex items-center rounded-lg transition-colors hover:bg-muted/50 has-focus-visible:bg-muted/50 has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50">
138
+ // Baris AdwActionRow yang bisa diaktifkan — sama dengan baris di panel
139
+ // samping: ikon simbolik 18px, judul 15px + subjudul 13px, suffix, lalu
140
+ // chevron. Tombol buka "direntangkan" (after:inset-0) sehingga seluruh
141
+ // baris, termasuk chevron, bisa diklik; tombol menu berada di atasnya.
142
+ <div
143
+ className={cn(
144
+ "group relative flex min-h-14 items-center gap-3 pr-4 pl-4 transition-colors hover:bg-muted/60 has-focus-visible:bg-muted/60 has-[button:active]:bg-muted",
145
+ selectionMode && selected && "bg-primary/[0.06]",
146
+ )}
147
+ >
148
+ {selectionMode && <CheckBox checked={selected} />}
149
+ <AgentLogo agentType={session.agentType} className="size-[18px] text-muted-foreground" />
62
150
  <button
63
151
  type="button"
64
- onClick={onOpen}
152
+ onClick={selectionMode ? onToggleSelect : onOpen}
65
153
  disabled={busy}
66
- className="flex min-w-0 flex-1 items-center gap-3.5 rounded-lg px-3 py-3 text-left focus-visible:outline-none disabled:opacity-60"
67
- aria-label={`Open session ${title}, ${SESSION_STATUS_LABEL[session.status]}`}
154
+ aria-pressed={selectionMode ? selected : undefined}
155
+ title={`${title} — ${agentLabel}`}
156
+ className="flex min-w-0 flex-1 flex-col gap-0.5 py-2.5 text-left outline-none after:absolute after:inset-0 focus-visible:after:ring-[3px] focus-visible:after:ring-ring/40 focus-visible:after:ring-inset disabled:opacity-60"
157
+ aria-label={`${selectionMode ? "Select" : "Open"} session ${title}, ${agentLabel}, ${SESSION_STATUS_LABEL[session.status]}`}
68
158
  >
69
- <span className="flex size-3 shrink-0 items-center justify-center">
70
- <StatusDot session={session} busy={busy} />
71
- </span>
72
- <span className="flex min-w-0 flex-1 flex-col gap-0.5">
73
- <span className="truncate text-[15px] font-medium leading-snug">{title}</span>
74
- <span className="truncate text-xs text-muted-foreground">
75
- {session.agentType}
76
- <span className="mx-1.5 opacity-50">/</span>
77
- {describeSessionModel(session)}
159
+ <span className="truncate text-[15px] leading-5">{title}</span>
160
+ <span className="flex min-w-0 items-center gap-1.5 text-[13px] leading-snug text-muted-foreground">
161
+ <StatusLabel session={session} busy={busy} />
162
+ {/* Layar sempit + ada status: model disembunyikan (terpotong jadi
163
+ tak terbaca); status & waktu lebih penting. */}
164
+ <span className={cn("contents", hasStatus && "max-sm:hidden")}>
165
+ {hasStatus && <Dot />}
166
+ <span className="truncate">{describeSessionModel(session)}</span>
78
167
  </span>
79
- </span>
80
- <span className="shrink-0 text-xs tabular-nums text-muted-foreground">
81
- {formatRelativeTime(session.updatedAt)}
168
+ <Dot />
169
+ <span className="shrink-0 tabular-nums">{formatRelativeTime(session.updatedAt)}</span>
82
170
  </span>
83
171
  </button>
84
172
 
85
- <DropdownMenu>
86
- <DropdownMenuTrigger asChild>
87
- <Button
88
- type="button"
89
- variant="ghost"
90
- size="icon-sm"
91
- disabled={busy}
92
- aria-label={`Session actions ${title}`}
93
- className="mr-1.5 shrink-0 rounded-full text-muted-foreground transition-opacity pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100"
94
- >
95
- <MoreHorizontalIcon />
96
- </Button>
97
- </DropdownMenuTrigger>
98
- <DropdownMenuContent align="end">
99
- {running ? (
100
- <DropdownMenuItem onSelect={onStop}>
101
- <SquareIcon />
102
- Stop
103
- </DropdownMenuItem>
104
- ) : (
105
- <DropdownMenuItem onSelect={onStart}>
106
- <PlayIcon />
107
- Start
108
- </DropdownMenuItem>
109
- )}
110
- <DropdownMenuSeparator />
111
- <DropdownMenuItem variant="destructive" onSelect={onDelete}>
112
- <Trash2Icon />
113
- Delete permanently
114
- </DropdownMenuItem>
115
- </DropdownMenuContent>
116
- </DropdownMenu>
173
+ {!selectionMode && (
174
+ <>
175
+ <DropdownMenu>
176
+ <DropdownMenuTrigger asChild>
177
+ {/* Suffix: tombol bundar datar. Mouse: muncul saat hover/fokus;
178
+ layar sentuh: selalu tampil. */}
179
+ <Button
180
+ type="button"
181
+ variant="ghost"
182
+ size="icon-sm"
183
+ disabled={busy}
184
+ aria-label={`Session actions ${title}`}
185
+ className="relative z-10 shrink-0 rounded-full text-muted-foreground transition-opacity pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100"
186
+ >
187
+ <MoreHorizontalIcon />
188
+ </Button>
189
+ </DropdownMenuTrigger>
190
+ <DropdownMenuContent align="end">
191
+ {running ? (
192
+ <DropdownMenuItem onSelect={onStop}>
193
+ <SquareIcon />
194
+ Stop
195
+ </DropdownMenuItem>
196
+ ) : (
197
+ <DropdownMenuItem onSelect={onStart}>
198
+ <PlayIcon />
199
+ Start
200
+ </DropdownMenuItem>
201
+ )}
202
+ <DropdownMenuSeparator />
203
+ <DropdownMenuItem variant="destructive" onSelect={onDelete}>
204
+ <Trash2Icon />
205
+ Delete permanently
206
+ </DropdownMenuItem>
207
+ </DropdownMenuContent>
208
+ </DropdownMenu>
209
+ <ChevronRightIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
210
+ </>
211
+ )}
117
212
  </div>
118
213
  );
119
214
  }
120
215
 
121
- /** Placeholder daftar Session saat memuat — menjaga tinggi konten. */
216
+ /** Placeholder daftar Session saat memuat — bentuk boxed list yang sama. */
122
217
  export function SessionListSkeleton() {
123
218
  return (
124
- <div className="flex flex-col" aria-hidden>
125
- {[0, 1, 2].map((i) => (
126
- <div key={i} className="flex items-center gap-3.5 py-3">
127
- <Skeleton className="size-2 shrink-0 rounded-full" />
128
- <div className="flex min-w-0 flex-1 flex-col gap-1.5">
129
- <Skeleton className="h-4 w-48" />
130
- <Skeleton className="h-3 w-32" />
219
+ <div className="flex flex-col gap-2.5" aria-hidden>
220
+ <div className="flex flex-col gap-1.5 px-1">
221
+ <Skeleton className="h-4 w-20" />
222
+ <Skeleton className="h-3 w-36" />
223
+ </div>
224
+ <div className="flex flex-col divide-y overflow-hidden rounded-xl border bg-card">
225
+ {[0, 1, 2].map((i) => (
226
+ <div key={i} className="flex min-h-14 items-center gap-3 px-4 py-2.5">
227
+ <Skeleton className="size-[18px] shrink-0 rounded" />
228
+ <div className="flex min-w-0 flex-1 flex-col gap-1.5">
229
+ <Skeleton className="h-4" style={{ width: `${55 - i * 10}%` }} />
230
+ <Skeleton className="h-3 w-40" />
231
+ </div>
131
232
  </div>
132
- <Skeleton className="h-3 w-16" />
133
- </div>
134
- ))}
233
+ ))}
234
+ </div>
135
235
  </div>
136
236
  );
137
237
  }