kcg-code 0.0.0-stage → 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 +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- 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__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -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/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Primitif halaman preferensi bergaya GNOME Settings / libadwaita:
|
|
3
|
+
* - `PrefsGroup` judul + deskripsi grup, isi = "boxed list" (kartu
|
|
4
|
+
* membulat, baris berpemisah).
|
|
5
|
+
* - `ActionRow` judul & subjudul di kiri, konten (suffix) di kanan. Bisa
|
|
6
|
+
* diklik (tombol / tautan) dengan chevron seperti GNOME.
|
|
7
|
+
* - `EntryRow` label kecil di atas input sebaris; tombol ✓ muncul saat
|
|
8
|
+
* nilai berubah (seperti AdwEntryRow "apply button").
|
|
9
|
+
* - `ExpanderRow` baris yang membuka isi tambahan di bawahnya (seperti
|
|
10
|
+
* AdwExpanderRow): chevron berputar, isi berlatar redup.
|
|
11
|
+
* - `SwitchRow` tidak dibutuhkan saat ini; `ComboRow` diwakili ActionRow +
|
|
12
|
+
* Select di suffix.
|
|
13
|
+
*/
|
|
14
|
+
import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
|
|
15
|
+
import { type ReactNode, useId, useState } from "react";
|
|
16
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
17
|
+
import { cn } from "@/lib/utils";
|
|
18
|
+
|
|
19
|
+
export function PrefsGroup({
|
|
20
|
+
id,
|
|
21
|
+
title,
|
|
22
|
+
description,
|
|
23
|
+
suffix,
|
|
24
|
+
toolbar,
|
|
25
|
+
children,
|
|
26
|
+
}: {
|
|
27
|
+
id?: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
description?: ReactNode;
|
|
30
|
+
/** Aksi kecil di kanan judul grup (mis. tombol). */
|
|
31
|
+
suffix?: ReactNode;
|
|
32
|
+
/** Kontrol di antara judul & boxed list (mis. kolom cari, filter). */
|
|
33
|
+
toolbar?: ReactNode;
|
|
34
|
+
children: ReactNode;
|
|
35
|
+
}) {
|
|
36
|
+
const headingId = useId();
|
|
37
|
+
return (
|
|
38
|
+
<section
|
|
39
|
+
id={id}
|
|
40
|
+
aria-labelledby={title ? headingId : undefined}
|
|
41
|
+
className="flex scroll-mt-6 flex-col gap-2.5"
|
|
42
|
+
>
|
|
43
|
+
{(title || suffix) && (
|
|
44
|
+
// Suffix sebaris dengan judul (bukan deskripsi) agar deskripsi tetap
|
|
45
|
+
// selebar kolom di layar sempit, tidak terjepit tombol di kanannya.
|
|
46
|
+
<div className="flex flex-col gap-0.5 px-1">
|
|
47
|
+
<div className="flex min-h-8 items-center justify-between gap-3">
|
|
48
|
+
{title && (
|
|
49
|
+
<h2 id={headingId} className="min-w-0 text-[15px] font-semibold tracking-tight">
|
|
50
|
+
{title}
|
|
51
|
+
</h2>
|
|
52
|
+
)}
|
|
53
|
+
{suffix && <div className="-mr-2 ml-auto shrink-0">{suffix}</div>}
|
|
54
|
+
</div>
|
|
55
|
+
{description && <p className="text-[13px] text-muted-foreground">{description}</p>}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
{toolbar}
|
|
59
|
+
<div className="overflow-hidden rounded-xl border bg-card shadow-xs">
|
|
60
|
+
<ul className="flex flex-col divide-y">{children}</ul>
|
|
61
|
+
</div>
|
|
62
|
+
</section>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface ActionRowProps {
|
|
67
|
+
title: ReactNode;
|
|
68
|
+
subtitle?: ReactNode;
|
|
69
|
+
/** Ikon / avatar di kiri. */
|
|
70
|
+
prefix?: ReactNode;
|
|
71
|
+
/** Kontrol / nilai di kanan. */
|
|
72
|
+
suffix?: ReactNode;
|
|
73
|
+
/** Baris bisa diklik -> tampilkan chevron. */
|
|
74
|
+
onActivate?: () => void;
|
|
75
|
+
/** Gaya merah (aksi berbahaya). */
|
|
76
|
+
destructive?: boolean;
|
|
77
|
+
disabled?: boolean;
|
|
78
|
+
/** Tautkan `<label>` ke kontrol di suffix. */
|
|
79
|
+
htmlFor?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Di layar sempit (< sm) suffix selalu turun ke bawah teks. Cocok untuk
|
|
82
|
+
* baris dengan subjudul panjang + tombol, agar subjudul tidak terjepit.
|
|
83
|
+
* Tanpa ini suffix tetap turun otomatis bila ruang teks < 10rem.
|
|
84
|
+
*/
|
|
85
|
+
stack?: boolean;
|
|
86
|
+
className?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function ActionRow({
|
|
90
|
+
title,
|
|
91
|
+
subtitle,
|
|
92
|
+
prefix,
|
|
93
|
+
suffix,
|
|
94
|
+
onActivate,
|
|
95
|
+
destructive,
|
|
96
|
+
disabled,
|
|
97
|
+
htmlFor,
|
|
98
|
+
stack,
|
|
99
|
+
className,
|
|
100
|
+
}: ActionRowProps) {
|
|
101
|
+
const titleClass = cn("text-[15px] leading-5", destructive && "text-destructive");
|
|
102
|
+
const body = (
|
|
103
|
+
<>
|
|
104
|
+
{prefix && <span className="flex shrink-0 items-center">{prefix}</span>}
|
|
105
|
+
{/* Teks + suffix dalam flex-wrap: suffix pindah ke baris berikutnya
|
|
106
|
+
(rata kiri dengan teks) saat lebar tidak cukup, alih-alih memeras
|
|
107
|
+
subjudul jadi kolom sempit. */}
|
|
108
|
+
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-2">
|
|
109
|
+
<span
|
|
110
|
+
className={cn(
|
|
111
|
+
"flex min-w-[min(100%,10rem)] flex-1 flex-col gap-0.5 py-0.5",
|
|
112
|
+
stack && "basis-full sm:basis-0",
|
|
113
|
+
)}
|
|
114
|
+
>
|
|
115
|
+
{htmlFor ? (
|
|
116
|
+
<label htmlFor={htmlFor} className={titleClass}>
|
|
117
|
+
{title}
|
|
118
|
+
</label>
|
|
119
|
+
) : (
|
|
120
|
+
<span className={titleClass}>{title}</span>
|
|
121
|
+
)}
|
|
122
|
+
{subtitle && (
|
|
123
|
+
<span className="text-[13px] leading-snug text-muted-foreground">{subtitle}</span>
|
|
124
|
+
)}
|
|
125
|
+
</span>
|
|
126
|
+
{suffix && <span className="flex shrink-0 items-center gap-2">{suffix}</span>}
|
|
127
|
+
</span>
|
|
128
|
+
{onActivate && (
|
|
129
|
+
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
130
|
+
)}
|
|
131
|
+
</>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const base = "flex min-h-14 w-full items-center gap-3 px-4 py-2.5 text-left";
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<li className={className}>
|
|
138
|
+
{onActivate ? (
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={onActivate}
|
|
142
|
+
disabled={disabled}
|
|
143
|
+
className={cn(
|
|
144
|
+
base,
|
|
145
|
+
"transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset active:bg-muted disabled:pointer-events-none disabled:opacity-50",
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{body}
|
|
149
|
+
</button>
|
|
150
|
+
) : (
|
|
151
|
+
<div className={base}>{body}</div>
|
|
152
|
+
)}
|
|
153
|
+
</li>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Baris input ala AdwEntryRow: label kecil di atas nilai; tombol ✓ muncul
|
|
159
|
+
* saat nilai berubah. Enter menyimpan, Esc mengembalikan nilai awal.
|
|
160
|
+
*/
|
|
161
|
+
export function EntryRow({
|
|
162
|
+
title,
|
|
163
|
+
value,
|
|
164
|
+
placeholder,
|
|
165
|
+
maxLength,
|
|
166
|
+
autoComplete,
|
|
167
|
+
onSave,
|
|
168
|
+
}: {
|
|
169
|
+
title: string;
|
|
170
|
+
value: string;
|
|
171
|
+
placeholder?: string;
|
|
172
|
+
maxLength?: number;
|
|
173
|
+
autoComplete?: string;
|
|
174
|
+
/** Kembalikan pesan error untuk ditampilkan, atau null bila sukses. */
|
|
175
|
+
onSave: (next: string) => Promise<string | null>;
|
|
176
|
+
}) {
|
|
177
|
+
const id = useId();
|
|
178
|
+
const [draft, setDraft] = useState(value);
|
|
179
|
+
const [saving, setSaving] = useState(false);
|
|
180
|
+
const [error, setError] = useState<string | null>(null);
|
|
181
|
+
const [lastValue, setLastValue] = useState(value);
|
|
182
|
+
// Nilai dari server berubah (disimpan di tempat lain) -> sinkronkan draft.
|
|
183
|
+
if (value !== lastValue) {
|
|
184
|
+
setLastValue(value);
|
|
185
|
+
setDraft(value);
|
|
186
|
+
}
|
|
187
|
+
const dirty = draft.trim() !== value;
|
|
188
|
+
|
|
189
|
+
const save = async () => {
|
|
190
|
+
if (!dirty || saving) return;
|
|
191
|
+
setSaving(true);
|
|
192
|
+
setError(await onSave(draft.trim()));
|
|
193
|
+
setSaving(false);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<li>
|
|
198
|
+
<form
|
|
199
|
+
className="flex min-h-14 items-center gap-3 px-4 py-2 focus-within:bg-muted/30"
|
|
200
|
+
onSubmit={(e) => {
|
|
201
|
+
e.preventDefault();
|
|
202
|
+
void save();
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
206
|
+
<label htmlFor={id} className="text-[12px] leading-4 text-muted-foreground">
|
|
207
|
+
{title}
|
|
208
|
+
</label>
|
|
209
|
+
<input
|
|
210
|
+
id={id}
|
|
211
|
+
value={draft}
|
|
212
|
+
maxLength={maxLength}
|
|
213
|
+
placeholder={placeholder}
|
|
214
|
+
autoComplete={autoComplete}
|
|
215
|
+
aria-invalid={error ? true : undefined}
|
|
216
|
+
aria-describedby={error ? `${id}-err` : undefined}
|
|
217
|
+
onChange={(e) => {
|
|
218
|
+
setDraft(e.target.value);
|
|
219
|
+
setError(null);
|
|
220
|
+
}}
|
|
221
|
+
onKeyDown={(e) => {
|
|
222
|
+
if (e.key === "Escape") {
|
|
223
|
+
setDraft(value);
|
|
224
|
+
setError(null);
|
|
225
|
+
}
|
|
226
|
+
}}
|
|
227
|
+
// 16px di mobile: iOS Safari men-zoom halaman saat fokus ke input < 16px.
|
|
228
|
+
className="h-7 w-full min-w-0 bg-transparent text-base outline-none placeholder:text-muted-foreground/70 sm:text-[15px]"
|
|
229
|
+
/>
|
|
230
|
+
{error && (
|
|
231
|
+
<span id={`${id}-err`} role="alert" className="text-[12px] text-destructive">
|
|
232
|
+
{error}
|
|
233
|
+
</span>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
{dirty && (
|
|
237
|
+
<button
|
|
238
|
+
type="submit"
|
|
239
|
+
aria-label={`Save ${title.toLowerCase()}`}
|
|
240
|
+
disabled={saving}
|
|
241
|
+
className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-xs transition-opacity outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-60"
|
|
242
|
+
>
|
|
243
|
+
{saving ? <Spinner className="size-4" /> : <CheckIcon className="size-4" />}
|
|
244
|
+
</button>
|
|
245
|
+
)}
|
|
246
|
+
</form>
|
|
247
|
+
</li>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Baris ala AdwExpanderRow: judul + subjudul (1 baris), suffix, chevron;
|
|
253
|
+
* klik membuka `children` di bawahnya (latar redup, dipisah garis). Tanpa
|
|
254
|
+
* `children` baris tampil biasa (tidak bisa dibuka).
|
|
255
|
+
*/
|
|
256
|
+
export function ExpanderRow({
|
|
257
|
+
title,
|
|
258
|
+
subtitle,
|
|
259
|
+
prefix,
|
|
260
|
+
suffix,
|
|
261
|
+
children,
|
|
262
|
+
className,
|
|
263
|
+
}: {
|
|
264
|
+
title: ReactNode;
|
|
265
|
+
subtitle?: ReactNode;
|
|
266
|
+
prefix?: ReactNode;
|
|
267
|
+
suffix?: ReactNode;
|
|
268
|
+
children?: ReactNode;
|
|
269
|
+
className?: string;
|
|
270
|
+
}) {
|
|
271
|
+
const [open, setOpen] = useState(false);
|
|
272
|
+
const contentId = useId();
|
|
273
|
+
const expandable = children !== undefined && children !== null && children !== false;
|
|
274
|
+
return (
|
|
275
|
+
<li className={className}>
|
|
276
|
+
<button
|
|
277
|
+
type="button"
|
|
278
|
+
onClick={() => expandable && setOpen((v) => !v)}
|
|
279
|
+
aria-expanded={expandable ? open : undefined}
|
|
280
|
+
aria-controls={expandable ? contentId : undefined}
|
|
281
|
+
disabled={!expandable}
|
|
282
|
+
className="flex min-h-14 w-full items-center gap-3 px-4 py-2.5 text-left transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset active:bg-muted disabled:cursor-default disabled:hover:bg-transparent"
|
|
283
|
+
>
|
|
284
|
+
{prefix && <span className="flex shrink-0 items-center">{prefix}</span>}
|
|
285
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
|
|
286
|
+
<span className="flex min-w-0 items-center gap-2 text-[15px] leading-5">{title}</span>
|
|
287
|
+
{subtitle && (
|
|
288
|
+
<span className="truncate text-[13px] leading-snug text-muted-foreground">
|
|
289
|
+
{subtitle}
|
|
290
|
+
</span>
|
|
291
|
+
)}
|
|
292
|
+
</span>
|
|
293
|
+
{suffix && <span className="flex shrink-0 items-center gap-2">{suffix}</span>}
|
|
294
|
+
{expandable && (
|
|
295
|
+
<ChevronDownIcon
|
|
296
|
+
className={cn(
|
|
297
|
+
"size-4 shrink-0 text-muted-foreground transition-transform duration-200",
|
|
298
|
+
open && "rotate-180",
|
|
299
|
+
)}
|
|
300
|
+
aria-hidden
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
</button>
|
|
304
|
+
{expandable && open && (
|
|
305
|
+
<div id={contentId} className="border-t bg-muted/30 px-4 py-3">
|
|
306
|
+
{children}
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
</li>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Pil status kecil untuk suffix baris (mis. "On" / "Off"). */
|
|
314
|
+
export function RowBadge({
|
|
315
|
+
tone = "muted",
|
|
316
|
+
children,
|
|
317
|
+
}: {
|
|
318
|
+
tone?: "muted" | "success" | "warning";
|
|
319
|
+
children: ReactNode;
|
|
320
|
+
}) {
|
|
321
|
+
return (
|
|
322
|
+
<span
|
|
323
|
+
className={cn(
|
|
324
|
+
"rounded-full px-2 py-0.5 text-[12px] font-medium",
|
|
325
|
+
tone === "success" && "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
|
326
|
+
tone === "warning" && "bg-amber-500/15 text-amber-800 dark:text-amber-300",
|
|
327
|
+
tone === "muted" && "bg-muted text-muted-foreground",
|
|
328
|
+
)}
|
|
329
|
+
>
|
|
330
|
+
{children}
|
|
331
|
+
</span>
|
|
332
|
+
);
|
|
333
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifikasi instalasi skill — tumpukan kartu di pojok kanan bawah (lebar
|
|
3
|
+
* penuh di HP), dipasang sekali di `AppShell` sehingga tetap tampil di
|
|
4
|
+
* halaman mana pun.
|
|
5
|
+
*
|
|
6
|
+
* Tiap kartu: status (spinner / centang / silang), nama skill -> Project,
|
|
7
|
+
* waktu berjalan, dan panel output CLI (gaya terminal) yang bisa dibuka-
|
|
8
|
+
* tutup dan otomatis menggulir ke baris terbaru — kecuali user sedang
|
|
9
|
+
* menggulir ke atas membaca log.
|
|
10
|
+
*
|
|
11
|
+
* Aksesibilitas: wadah `role="region"`; perubahan status diumumkan lewat
|
|
12
|
+
* satu `aria-live` ringkas (bukan tiap baris log, agar tidak berisik).
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
BanIcon,
|
|
16
|
+
CheckCircle2Icon,
|
|
17
|
+
ChevronDownIcon,
|
|
18
|
+
ClockIcon,
|
|
19
|
+
CopyIcon,
|
|
20
|
+
SquareIcon,
|
|
21
|
+
TerminalIcon,
|
|
22
|
+
XCircleIcon,
|
|
23
|
+
XIcon,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import { useEffect, useRef, useState } from "react";
|
|
26
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
27
|
+
import { AGENT_TYPE_LABELS } from "@/lib/agent-types";
|
|
28
|
+
import { apiErrorMessage } from "@/lib/api";
|
|
29
|
+
import {
|
|
30
|
+
cancelInstall,
|
|
31
|
+
dismissInstall,
|
|
32
|
+
hydrateInstalls,
|
|
33
|
+
type InstallEntry,
|
|
34
|
+
toggleInstallLog,
|
|
35
|
+
useInstalls,
|
|
36
|
+
} from "@/lib/skill-installs";
|
|
37
|
+
import { cn } from "@/lib/utils";
|
|
38
|
+
|
|
39
|
+
/** Kartu yang tampil bersamaan; sisanya diringkas "+N more". */
|
|
40
|
+
const MAX_VISIBLE = 3;
|
|
41
|
+
|
|
42
|
+
export function InstallNotifications() {
|
|
43
|
+
const all = useInstalls();
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
void hydrateInstalls();
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const visible = all.filter((e) => !e.dismissed);
|
|
50
|
+
const shown = visible.slice(0, MAX_VISIBLE);
|
|
51
|
+
const hidden = visible.length - shown.length;
|
|
52
|
+
const running = all.filter((e) => e.job.status === "running").length;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<>
|
|
56
|
+
{/* Pengumuman ringkas untuk pembaca layar. */}
|
|
57
|
+
<p className="sr-only" aria-live="polite">
|
|
58
|
+
{running > 0 ? `${running} skill installation${running > 1 ? "s" : ""} in progress.` : ""}
|
|
59
|
+
</p>
|
|
60
|
+
{shown.length > 0 && (
|
|
61
|
+
<section
|
|
62
|
+
aria-label="Skill installations"
|
|
63
|
+
className="pointer-events-none fixed inset-x-3 bottom-3 z-50 flex flex-col-reverse gap-2 sm:inset-x-auto sm:right-4 sm:bottom-4 sm:w-[26rem]"
|
|
64
|
+
>
|
|
65
|
+
{shown.map((entry) => (
|
|
66
|
+
<InstallCard key={entry.job.id} entry={entry} />
|
|
67
|
+
))}
|
|
68
|
+
{hidden > 0 && (
|
|
69
|
+
<p className="pointer-events-auto self-end rounded-full border bg-popover px-3 py-1 text-xs text-muted-foreground shadow-md">
|
|
70
|
+
+{hidden} more
|
|
71
|
+
</p>
|
|
72
|
+
)}
|
|
73
|
+
</section>
|
|
74
|
+
)}
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Detik berjalan (di-tick tiap detik selama job belum selesai). */
|
|
80
|
+
function useNow(active: boolean): number {
|
|
81
|
+
const [now, setNow] = useState(() => Date.now());
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!active) return;
|
|
84
|
+
const t = setInterval(() => setNow(Date.now()), 1000);
|
|
85
|
+
return () => clearInterval(t);
|
|
86
|
+
}, [active]);
|
|
87
|
+
return now;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function formatSeconds(ms: number): string {
|
|
91
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
92
|
+
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Diam selama ini -> tampilkan petunjuk "belum ada output". */
|
|
96
|
+
const QUIET_HINT_MS = 15_000;
|
|
97
|
+
|
|
98
|
+
function InstallCard({ entry }: { entry: InstallEntry }) {
|
|
99
|
+
const { job, lines, expanded, pollError, truncated, cancelling } = entry;
|
|
100
|
+
const running = job.status === "running";
|
|
101
|
+
const failed = job.status === "failed";
|
|
102
|
+
const cancelled = job.status === "cancelled";
|
|
103
|
+
const now = useNow(running);
|
|
104
|
+
const elapsed = formatSeconds((job.finishedAt ?? now) - job.startedAt);
|
|
105
|
+
const quietMs = running ? now - job.lastOutputAt : 0;
|
|
106
|
+
const logId = `install-log-${job.id}`;
|
|
107
|
+
|
|
108
|
+
const title = running
|
|
109
|
+
? cancelling
|
|
110
|
+
? `Cancelling ${job.skillId}…`
|
|
111
|
+
: `Installing ${job.skillId}`
|
|
112
|
+
: cancelled
|
|
113
|
+
? `Cancelled ${job.skillId}`
|
|
114
|
+
: failed
|
|
115
|
+
? `Couldn't install ${job.skillId}`
|
|
116
|
+
: `Installed ${job.skillId}`;
|
|
117
|
+
|
|
118
|
+
/** Terpasang di disk, tapi agent belum memuat ulang skill (chat berjalan). */
|
|
119
|
+
const awaitingActivation = job.status === "succeeded" && job.refreshed === false;
|
|
120
|
+
|
|
121
|
+
const subtitle = cancelled
|
|
122
|
+
? `Nothing was added to ${job.projectName}.`
|
|
123
|
+
: failed
|
|
124
|
+
? apiErrorMessage(job.error ?? "SKILL_INSTALL_FAILED")
|
|
125
|
+
: awaitingActivation
|
|
126
|
+
? `Added to ${job.projectName}. Activates automatically when the running chat finishes.`
|
|
127
|
+
: `${running ? "into" : "Added to"} ${job.projectName} · ${(job.agentTypes ?? ["opencode"])
|
|
128
|
+
.map((a) => AGENT_TYPE_LABELS[a])
|
|
129
|
+
.join(" + ")}${job.status === "succeeded" ? " · active" : ""}`;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<article
|
|
133
|
+
aria-label={title}
|
|
134
|
+
className={cn(
|
|
135
|
+
"pointer-events-auto overflow-hidden rounded-xl border bg-popover text-popover-foreground shadow-lg",
|
|
136
|
+
"animate-in fade-in-0 slide-in-from-bottom-2",
|
|
137
|
+
failed && "border-destructive/40",
|
|
138
|
+
)}
|
|
139
|
+
>
|
|
140
|
+
<div className="flex items-start gap-3 p-3.5">
|
|
141
|
+
<span className="mt-0.5 flex size-5 shrink-0 items-center justify-center" aria-hidden>
|
|
142
|
+
{running ? (
|
|
143
|
+
<Spinner className="size-4 text-primary" />
|
|
144
|
+
) : cancelled ? (
|
|
145
|
+
<BanIcon className="size-5 text-muted-foreground" />
|
|
146
|
+
) : failed ? (
|
|
147
|
+
<XCircleIcon className="size-5 text-destructive" />
|
|
148
|
+
) : awaitingActivation ? (
|
|
149
|
+
<ClockIcon className="size-5 text-amber-600 dark:text-amber-400" />
|
|
150
|
+
) : (
|
|
151
|
+
<CheckCircle2Icon className="size-5 text-emerald-600 dark:text-emerald-400" />
|
|
152
|
+
)}
|
|
153
|
+
</span>
|
|
154
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
155
|
+
<p className="flex min-w-0 items-baseline gap-2">
|
|
156
|
+
<span className="truncate text-sm font-semibold">{title}</span>
|
|
157
|
+
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{elapsed}</span>
|
|
158
|
+
</p>
|
|
159
|
+
<p
|
|
160
|
+
className={cn(
|
|
161
|
+
"line-clamp-2 text-xs",
|
|
162
|
+
failed ? "text-destructive" : "text-muted-foreground",
|
|
163
|
+
)}
|
|
164
|
+
>
|
|
165
|
+
{subtitle}
|
|
166
|
+
</p>
|
|
167
|
+
{awaitingActivation && (
|
|
168
|
+
<span className="mt-0.5 flex w-fit items-center gap-1.5 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-400">
|
|
169
|
+
<span className="size-1.5 animate-pulse rounded-full bg-current" aria-hidden />
|
|
170
|
+
Waiting for chat to finish
|
|
171
|
+
</span>
|
|
172
|
+
)}
|
|
173
|
+
{pollError && <p className="text-xs text-amber-600 dark:text-amber-400">{pollError}</p>}
|
|
174
|
+
{running && !cancelling && quietMs >= QUIET_HINT_MS && (
|
|
175
|
+
<p className="text-xs text-amber-600 dark:text-amber-400">
|
|
176
|
+
No output for {formatSeconds(quietMs)} — still downloading, or you can cancel.
|
|
177
|
+
</p>
|
|
178
|
+
)}
|
|
179
|
+
<span className="truncate font-mono text-[11px] text-muted-foreground/80">
|
|
180
|
+
{job.source}
|
|
181
|
+
</span>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="-mt-0.5 -mr-1 flex shrink-0 items-center gap-0.5">
|
|
184
|
+
{running && (
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
onClick={() => void cancelInstall(job.id)}
|
|
188
|
+
disabled={cancelling}
|
|
189
|
+
aria-label={`Cancel installing ${job.skillId}`}
|
|
190
|
+
title="Cancel installation"
|
|
191
|
+
className="flex h-7 items-center gap-1 rounded-md px-2 text-xs font-medium text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-50"
|
|
192
|
+
>
|
|
193
|
+
<SquareIcon className="size-3 fill-current" aria-hidden />
|
|
194
|
+
Cancel
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={() => dismissInstall(job.id)}
|
|
200
|
+
aria-label={running ? "Hide (installation continues)" : "Dismiss"}
|
|
201
|
+
title={running ? "Hide — installation continues in the background" : "Dismiss"}
|
|
202
|
+
className="flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
|
|
203
|
+
>
|
|
204
|
+
<XIcon className="size-4" />
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{/* Indeterminate progress selama berjalan. */}
|
|
210
|
+
{running && (
|
|
211
|
+
<div className="h-0.5 w-full overflow-hidden bg-muted" aria-hidden>
|
|
212
|
+
<div className="h-full w-1/3 animate-[install-progress_1.4s_ease-in-out_infinite] rounded-full bg-primary" />
|
|
213
|
+
</div>
|
|
214
|
+
)}
|
|
215
|
+
|
|
216
|
+
<div className="border-t">
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
onClick={() => toggleInstallLog(job.id)}
|
|
220
|
+
aria-expanded={expanded}
|
|
221
|
+
aria-controls={logId}
|
|
222
|
+
className="flex w-full items-center gap-2 px-3.5 py-2 text-xs font-medium text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
|
223
|
+
>
|
|
224
|
+
<TerminalIcon className="size-3.5" aria-hidden />
|
|
225
|
+
{expanded ? "Hide output" : "Show output"}
|
|
226
|
+
<span className="tabular-nums text-muted-foreground/70">
|
|
227
|
+
{lines.length > 0 && `· ${lines.length} lines`}
|
|
228
|
+
</span>
|
|
229
|
+
<ChevronDownIcon
|
|
230
|
+
className={cn("ml-auto size-3.5 transition-transform", expanded && "rotate-180")}
|
|
231
|
+
aria-hidden
|
|
232
|
+
/>
|
|
233
|
+
</button>
|
|
234
|
+
{expanded && <LogPanel id={logId} lines={lines} running={running} truncated={truncated} />}
|
|
235
|
+
</div>
|
|
236
|
+
</article>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function LogPanel({
|
|
241
|
+
id,
|
|
242
|
+
lines,
|
|
243
|
+
running,
|
|
244
|
+
truncated,
|
|
245
|
+
}: {
|
|
246
|
+
id: string;
|
|
247
|
+
lines: string[];
|
|
248
|
+
running: boolean;
|
|
249
|
+
truncated: boolean;
|
|
250
|
+
}) {
|
|
251
|
+
const ref = useRef<HTMLPreElement>(null);
|
|
252
|
+
/** Ikuti baris terbaru selama user tidak menggulir ke atas. */
|
|
253
|
+
const stick = useRef(true);
|
|
254
|
+
const [copied, setCopied] = useState(false);
|
|
255
|
+
|
|
256
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: gulir tiap ada baris baru
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const el = ref.current;
|
|
259
|
+
if (el && stick.current) el.scrollTop = el.scrollHeight;
|
|
260
|
+
}, [lines.length]);
|
|
261
|
+
|
|
262
|
+
const copy = async () => {
|
|
263
|
+
try {
|
|
264
|
+
await navigator.clipboard.writeText(lines.join("\n"));
|
|
265
|
+
setCopied(true);
|
|
266
|
+
setTimeout(() => setCopied(false), 1500);
|
|
267
|
+
} catch {
|
|
268
|
+
// Clipboard tidak tersedia (HTTP non-localhost) — abaikan.
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<div className="relative">
|
|
274
|
+
{/* role="log": wilayah output berurutan; fokusable agar bisa digulir
|
|
275
|
+
dengan keyboard. Tidak `aria-live` — pembaruan status diumumkan
|
|
276
|
+
terpisah supaya pembaca layar tidak membacakan tiap baris. */}
|
|
277
|
+
<pre
|
|
278
|
+
ref={ref}
|
|
279
|
+
id={id}
|
|
280
|
+
role="log"
|
|
281
|
+
aria-live="off"
|
|
282
|
+
// biome-ignore lint/a11y/noNoninteractiveTabindex: area gulir harus bisa difokus keyboard
|
|
283
|
+
tabIndex={0}
|
|
284
|
+
aria-label="Installation output"
|
|
285
|
+
onScroll={(e) => {
|
|
286
|
+
const el = e.currentTarget;
|
|
287
|
+
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
|
288
|
+
}}
|
|
289
|
+
className="max-h-56 overflow-auto bg-zinc-950 px-3.5 py-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-zinc-200 outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
|
|
290
|
+
>
|
|
291
|
+
{truncated && <span className="text-zinc-500">… earlier output trimmed{"\n"}</span>}
|
|
292
|
+
{lines.length === 0 ? (
|
|
293
|
+
<span className="text-zinc-500">Waiting for output…</span>
|
|
294
|
+
) : (
|
|
295
|
+
lines.map((line, i) => (
|
|
296
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: log append-only
|
|
297
|
+
<span key={i} className={lineClass(line)}>
|
|
298
|
+
{line}
|
|
299
|
+
{"\n"}
|
|
300
|
+
</span>
|
|
301
|
+
))
|
|
302
|
+
)}
|
|
303
|
+
{running && <span className="animate-pulse text-zinc-400">▍</span>}
|
|
304
|
+
</pre>
|
|
305
|
+
{lines.length > 0 && (
|
|
306
|
+
<button
|
|
307
|
+
type="button"
|
|
308
|
+
onClick={() => void copy()}
|
|
309
|
+
aria-label="Copy output"
|
|
310
|
+
className="absolute top-2 right-2 flex h-6 items-center gap-1 rounded-md bg-zinc-800/90 px-2 text-[11px] text-zinc-300 hover:bg-zinc-700"
|
|
311
|
+
>
|
|
312
|
+
<CopyIcon className="size-3" aria-hidden />
|
|
313
|
+
{copied ? "Copied" : "Copy"}
|
|
314
|
+
</button>
|
|
315
|
+
)}
|
|
316
|
+
</div>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Warna baris log: perintah, sukses, gagal. */
|
|
321
|
+
function lineClass(line: string): string | undefined {
|
|
322
|
+
if (line.startsWith("$ ")) return "text-sky-300";
|
|
323
|
+
if (line.startsWith("✓")) return "text-emerald-400";
|
|
324
|
+
if (line.startsWith("✗") || /^(error|■)/i.test(line.trimStart())) return "text-red-400";
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|