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.
- 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 +876 -159
- package/src/cli/index.ts +10 -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
|
@@ -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 —
|
|
5
|
-
*
|
|
6
|
-
* - Header: nama Project besar, path (salin saat hover)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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:
|
|
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 {
|
|
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="
|
|
146
|
+
variant="secondary"
|
|
143
147
|
size="icon"
|
|
144
148
|
aria-label="More project actions"
|
|
145
|
-
className={cn("rounded-full
|
|
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
|
|
170
|
-
*
|
|
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
|
|
175
|
-
<
|
|
176
|
-
className="
|
|
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-
|
|
183
|
-
<h2 className="text-
|
|
184
|
-
<p className="text-
|
|
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-
|
|
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="
|
|
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
|
-
<
|
|
287
|
-
<
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
{
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
38
|
-
function
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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="
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
<span className="
|
|
75
|
-
{
|
|
76
|
-
<span className="
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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 —
|
|
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
|
-
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
))}
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
135
235
|
</div>
|
|
136
236
|
);
|
|
137
237
|
}
|