kcg-code 0.0.0-stage → 0.1.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 +228 -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 +83 -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__/dashboard.test.ts +62 -0
- package/src/cli/args.ts +123 -0
- package/src/cli/commands.ts +174 -0
- package/src/cli/dashboard.tsx +343 -0
- package/src/cli/index.ts +37 -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 +142 -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 +250 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/PanelSection.tsx +102 -0
- package/src/components/projects/ProjectInstructions.tsx +229 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +448 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +123 -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 +72 -0
- package/src/components/sessions/ContextPanel.tsx +561 -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 +209 -0
- package/src/components/sessions/ModelSearchList.tsx +151 -0
- package/src/components/sessions/NewSessionDialog.tsx +189 -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 +640 -0
- package/src/components/sessions/SessionHeader.tsx +175 -0
- package/src/components/sessions/SessionList.tsx +396 -0
- package/src/components/sessions/SessionRow.tsx +137 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +236 -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 +220 -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 +262 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1226 -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 +265 -0
- package/src/components/skills/InstallNotifications.tsx +325 -0
- package/src/components/skills/InstallSkillDialog.tsx +233 -0
- package/src/components/skills/SkillCards.tsx +210 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -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/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 +85 -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 +97 -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 +234 -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 +52 -0
- package/src/lib/__tests__/project-overview.test.ts +173 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -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 +134 -0
- package/src/lib/__tests__/skills-catalog.test.ts +96 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -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 +74 -0
- package/src/lib/project-overview.ts +117 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +115 -0
- package/src/lib/session-handoff.ts +25 -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 +360 -0
- package/src/lib/skills-catalog.ts +99 -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 +130 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +299 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +9 -0
- package/src/pages/skills/SkillsPage.tsx +701 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1031 -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 +415 -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 +157 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +117 -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__/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 +3469 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +226 -0
- package/src/server/services/__tests__/skills-registry.test.ts +359 -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/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 +1374 -0
- package/src/server/services/skill-install-jobs.ts +262 -0
- package/src/server/services/skills-registry.ts +468 -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,1085 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kartu Interactive_Prompt (Requirement 8.4, 8.5).
|
|
3
|
+
*
|
|
4
|
+
* - Tipe `"confirmation"`: tombol aksi cepat Approve, Always allow, Deny, dan
|
|
5
|
+
* Cancel. "Always allow" mengirim `always` — opencode mengingat pola yang
|
|
6
|
+
* disetujui sehingga request identik berikutnya tidak menampilkan kartu
|
|
7
|
+
* lagi. "Cancel" memperlakukan sebagai respon Deny yang sama (Requirement
|
|
8
|
+
* 8.5 — meneruskan mengikuti mekanisme penyelesaian Requirement 6).
|
|
9
|
+
* - Tipe `"menu"` (question): baris opsi ala TUI opencode — label beserta
|
|
10
|
+
* keterangan (`description` dari skema tool `question`). Single choice
|
|
11
|
+
* langsung menyelesaikan kartu saat baris diklik (`{ option }`); multi-
|
|
12
|
+
* select (`multiple: true`) memakai checkbox + footer Dismiss/Submit dan
|
|
13
|
+
* mengirim seluruh label terpilih sebagai `{ options: string[] }`. Jawaban
|
|
14
|
+
* bebas (`custom`) tetap tersedia di kedua mode.
|
|
15
|
+
* - Multi-question (`questions` berisi >1 pertanyaan): SEMUA pertanyaan
|
|
16
|
+
* dirender sebagai bagian dalam satu kartu; jawaban dikumpulkan per
|
|
17
|
+
* pertanyaan lalu dikirim SEKALIGUS sebagai `{ answers: string[][] }` —
|
|
18
|
+
* satu array label per pertanyaan, urut sesuai daftar. Submit baru aktif
|
|
19
|
+
* setelah tiap pertanyaan punya minimal satu jawaban (opsi dan/atau teks
|
|
20
|
+
* kustom); Dismiss menolak seluruh request.
|
|
21
|
+
* - Beberapa request permission yang identik (opencode memancarkan satu
|
|
22
|
+
* request per tool call) digroup jadi SATU kartu: `prompts` berisi seluruh
|
|
23
|
+
* anggota grup, satu set tombol, dan jawaban diteruskan ke SEMUA id —
|
|
24
|
+
* server meneruskan reply ke tiap request (fan-out).
|
|
25
|
+
*
|
|
26
|
+
* Feedback klik: tombol/baris yang diklik menampilkan spinner dan seluruh
|
|
27
|
+
* aksi terkunci sampai `prompt_resolved` menghapus kartu. Kegagalan (WS
|
|
28
|
+
* putus, server menolak) ditampilkan DI kartu via `errorSignal` — klik yang
|
|
29
|
+
* gagal tidak lagi diam saja sehingga tombol terasa mati.
|
|
30
|
+
*
|
|
31
|
+
* Desain: panel floating di ATAS composer (ala dialog izin Claude/opencode)
|
|
32
|
+
* dengan animasi masuk/keluar (Motion, di `PromptPanel`). Logika pemetaan aksi & pengelompokan prompt dipisah ke
|
|
33
|
+
* `lib/prompts.ts` (fungsi murni, diuji tanpa DOM — unit test 24.6).
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
ArrowUpIcon,
|
|
37
|
+
CheckIcon,
|
|
38
|
+
ChevronLeftIcon,
|
|
39
|
+
ChevronRightIcon,
|
|
40
|
+
HelpCircleIcon,
|
|
41
|
+
ListChecksIcon,
|
|
42
|
+
PencilLineIcon,
|
|
43
|
+
ShieldAlertIcon,
|
|
44
|
+
ShieldCheckIcon,
|
|
45
|
+
} from "lucide-react";
|
|
46
|
+
import * as m from "motion/react-m";
|
|
47
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
48
|
+
import { Badge } from "@/components/ui/badge";
|
|
49
|
+
import { Button } from "@/components/ui/button";
|
|
50
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
51
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
52
|
+
import { type PermissionRisk, parsePermissionTitle } from "@/lib/permission-info";
|
|
53
|
+
import { getPromptQuestions } from "@/lib/prompts";
|
|
54
|
+
import { cn } from "@/lib/utils";
|
|
55
|
+
import type { InteractivePrompt, PromptOption, PromptResponse } from "@/types";
|
|
56
|
+
|
|
57
|
+
export interface PromptCardProps {
|
|
58
|
+
/**
|
|
59
|
+
* Grup prompt yang tampil sebagai satu kartu — minimal satu elemen.
|
|
60
|
+
* Anggota identik (kind + title sama): permission bertumpuk dari opencode.
|
|
61
|
+
*/
|
|
62
|
+
prompts: InteractivePrompt[];
|
|
63
|
+
/** Dipanggil dengan respon pengguna untuk tiap id anggota (Req 6.3, 6.7). */
|
|
64
|
+
onResolve: (promptId: string, response: PromptResponse) => void;
|
|
65
|
+
/**
|
|
66
|
+
* Error resolusi terbaru dari server (WS `error` berkode PROMPT_*).
|
|
67
|
+
* `null` = tidak ada. Non-null -> tampil sebagai error kartu, lalu
|
|
68
|
+
* dikonsumsi via `onConsumeError` agar tidak muncul lagi di render lain.
|
|
69
|
+
*/
|
|
70
|
+
errorSignal?: string | null;
|
|
71
|
+
/** Reset `errorSignal` di pemilik state setelah kartu menampilkannya. */
|
|
72
|
+
onConsumeError?: () => void;
|
|
73
|
+
/**
|
|
74
|
+
* `true` = kartu docked di dasar MENGGANTIKAN composer (question pending):
|
|
75
|
+
* mengisi sisa tinggi kolom dan hanya area jawaban yang scroll — header
|
|
76
|
+
* pertanyaan dan footer aksi (Dismiss/Submit) selalu terlihat. `false` =
|
|
77
|
+
* kartu mengambang di atas composer (ukuran alami, tanpa scroll internal).
|
|
78
|
+
*/
|
|
79
|
+
docked?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function PromptCard({
|
|
83
|
+
prompts,
|
|
84
|
+
onResolve,
|
|
85
|
+
errorSignal,
|
|
86
|
+
onConsumeError,
|
|
87
|
+
docked = false,
|
|
88
|
+
}: PromptCardProps) {
|
|
89
|
+
const prompt = prompts[0];
|
|
90
|
+
/** Key aksi yang diklik — spinner pada tombol itu, tombol lain terkunci. */
|
|
91
|
+
const [submittingKey, setSubmittingKey] = useState<string | null>(null);
|
|
92
|
+
/** Kunci kirim sinkron — mencegah balasan ganda dari klik beruntun cepat. */
|
|
93
|
+
const sendingRef = useRef(false);
|
|
94
|
+
/** Pesan error klik TERAKHIR — tampil di kartu, bukan di atas chat. */
|
|
95
|
+
const [error, setError] = useState<string | null>(null);
|
|
96
|
+
/** Isi input jawaban bebas (question dengan flag `custom`). */
|
|
97
|
+
const [customValue, setCustomValue] = useState("");
|
|
98
|
+
/** Multi-select: label opsi yang dicentang (urut klik, bukan urutan daftar). */
|
|
99
|
+
const [selectedMulti, setSelectedMulti] = useState<Set<string>>(new Set());
|
|
100
|
+
/**
|
|
101
|
+
* Multi-question: label terpilih per indeks pertanyaan. Pertanyaan non-
|
|
102
|
+
* multiple berperilaku radio — pilihan baru menggantikan yang lama.
|
|
103
|
+
*/
|
|
104
|
+
const [multiSel, setMultiSel] = useState<Record<number, Set<string>>>({});
|
|
105
|
+
/** Multi-question: isi input jawaban bebas per indeks pertanyaan. */
|
|
106
|
+
const [customValues, setCustomValues] = useState<Record<number, string>>({});
|
|
107
|
+
/**
|
|
108
|
+
* Multi-question = wizard bertahap: satu pertanyaan per langkah dengan
|
|
109
|
+
* Back / Next, dan Submit di langkah terakhir.
|
|
110
|
+
*/
|
|
111
|
+
const [step, setStep] = useState(0);
|
|
112
|
+
/** Timer auto-lanjut setelah memilih opsi single choice di wizard. */
|
|
113
|
+
const advanceTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
114
|
+
|
|
115
|
+
// Error server (PROMPT_NOT_FOUND / PROMPT_ALREADY_RESOLVED) -> tampil di
|
|
116
|
+
// kartu, lalu sinyal dikonsumsi supaya tidak muncul di kartu lain.
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
if (errorSignal) {
|
|
119
|
+
sendingRef.current = false;
|
|
120
|
+
setSubmittingKey(null);
|
|
121
|
+
setError(errorSignal);
|
|
122
|
+
onConsumeError?.();
|
|
123
|
+
}
|
|
124
|
+
}, [errorSignal, onConsumeError]);
|
|
125
|
+
|
|
126
|
+
// Kartu dipakai ulang untuk grup prompt berbeda (prompt.id berganti) —
|
|
127
|
+
// reset state lokal agar tidak membawa pilihan kartu sebelumnya.
|
|
128
|
+
// prompt?.id SATU-SATUNYA trigger reset — menghapusnya dari deps membuat
|
|
129
|
+
// efek jalan tiap render dan selalu mengosongkan jawaban.
|
|
130
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: prompt?.id sengaja jadi satu-satunya trigger reset state
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
setSelectedMulti(new Set());
|
|
133
|
+
setCustomValue("");
|
|
134
|
+
setMultiSel({});
|
|
135
|
+
setCustomValues({});
|
|
136
|
+
setStep(0);
|
|
137
|
+
sendingRef.current = false;
|
|
138
|
+
setSubmittingKey(null);
|
|
139
|
+
setError(null);
|
|
140
|
+
return () => clearTimeout(advanceTimer.current);
|
|
141
|
+
}, [prompt?.id]);
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Pintasan keyboard question (desktop): angka 1–9 memilih opsi ke-n.
|
|
145
|
+
* Handler terbaru disimpan di ref agar listener cukup dipasang sekali.
|
|
146
|
+
*/
|
|
147
|
+
const keyHandlerRef = useRef<(e: KeyboardEvent) => void>(() => {});
|
|
148
|
+
useEffect(() => {
|
|
149
|
+
const onKey = (e: KeyboardEvent) => keyHandlerRef.current(e);
|
|
150
|
+
window.addEventListener("keydown", onKey);
|
|
151
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
152
|
+
}, []);
|
|
153
|
+
|
|
154
|
+
if (!prompt) return null;
|
|
155
|
+
const isMenu = prompt.type === "menu";
|
|
156
|
+
const isPermission = prompt.kind === "permission";
|
|
157
|
+
// Question multi-select: jawaban dikumpulkan lewat checkbox lalu dikirim
|
|
158
|
+
// sekaligus — bukan satu klik per opsi.
|
|
159
|
+
const isMulti = isMenu && prompt.multiple === true;
|
|
160
|
+
// Multi-question: seluruh pertanyaan request dirender dalam satu kartu.
|
|
161
|
+
const questions = getPromptQuestions(prompt);
|
|
162
|
+
const isMultiQuestion = isMenu && questions.length > 1;
|
|
163
|
+
// Permission identik yang menumpuk di server — beri tahu user bahwa satu
|
|
164
|
+
// klik menjawab semuanya.
|
|
165
|
+
const twinCount = prompts.length;
|
|
166
|
+
|
|
167
|
+
const resolveResponse = (key: string, response: PromptResponse) => {
|
|
168
|
+
// Kunci SINKRON (ref), bukan state: klik beruntun cepat terjadi sebelum
|
|
169
|
+
// React me-render ulang, sehingga `submittingKey` dari closure masih
|
|
170
|
+
// null dan tiap klik lolos -> balasan ganda ke opencode.
|
|
171
|
+
if (sendingRef.current) return;
|
|
172
|
+
sendingRef.current = true;
|
|
173
|
+
setError(null);
|
|
174
|
+
setSubmittingKey(key);
|
|
175
|
+
try {
|
|
176
|
+
for (const p of prompts) onResolve(p.id, response);
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// Gagal kirim (WS putus dsb.) — pesan error di kartu, tombol aktif lagi.
|
|
179
|
+
sendingRef.current = false;
|
|
180
|
+
setSubmittingKey(null);
|
|
181
|
+
setError(e instanceof Error ? e.message : "Failed to send the response.");
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Kirim jawaban bebas dari input kustom (bentuk respon sama dengan opsi). */
|
|
186
|
+
const submitCustom = () => {
|
|
187
|
+
const text = customValue.trim();
|
|
188
|
+
if (text.length === 0) return;
|
|
189
|
+
setCustomValue("");
|
|
190
|
+
resolveResponse("custom", { option: text });
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
/** Toggle satu opsi multi-select (checkbox baris opsi). */
|
|
194
|
+
const toggleMulti = (label: string) => {
|
|
195
|
+
setSelectedMulti((prev) => {
|
|
196
|
+
const next = new Set(prev);
|
|
197
|
+
if (next.has(label)) next.delete(label);
|
|
198
|
+
else next.add(label);
|
|
199
|
+
return next;
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Kirim jawaban multi-select: seluruh opsi tercentang (+ teks kustom bila
|
|
205
|
+
* diisi) sebagai satu array label — bentuk respon `{ options: string[] }`.
|
|
206
|
+
*/
|
|
207
|
+
const submitMulti = (extra?: string) => {
|
|
208
|
+
const labels = [...selectedMulti];
|
|
209
|
+
const text = extra?.trim() ?? "";
|
|
210
|
+
if (text.length > 0) labels.push(text);
|
|
211
|
+
if (labels.length === 0) return;
|
|
212
|
+
setCustomValue("");
|
|
213
|
+
resolveResponse("multi-submit", { options: labels });
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
/** Toggle opsi pada pertanyaan ke-`qi` dalam kartu multi-question. */
|
|
217
|
+
const toggleQOption = (qi: number, label: string, allowMulti: boolean) => {
|
|
218
|
+
setMultiSel((prev) => {
|
|
219
|
+
const cur = new Set(prev[qi] ?? []);
|
|
220
|
+
if (allowMulti) {
|
|
221
|
+
if (cur.has(label)) cur.delete(label);
|
|
222
|
+
else cur.add(label);
|
|
223
|
+
} else {
|
|
224
|
+
// Pertanyaan single choice dalam batch: pilihan baru menggantikan.
|
|
225
|
+
cur.clear();
|
|
226
|
+
cur.add(label);
|
|
227
|
+
}
|
|
228
|
+
return { ...prev, [qi]: cur };
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Kirim jawaban multi-question: SATU array label per pertanyaan (urut
|
|
234
|
+
* daftar). Semua pertanyaan wajib terjawab (opsi dan/atau teks kustom) —
|
|
235
|
+
* opencode menuntut jawaban per pertanyaan; menolak sebagian = Dismiss.
|
|
236
|
+
*/
|
|
237
|
+
const submitMultiQuestion = () => {
|
|
238
|
+
const answers = questions.map((_, qi) => {
|
|
239
|
+
const labels = [...(multiSel[qi] ?? [])];
|
|
240
|
+
const text = (customValues[qi] ?? "").trim();
|
|
241
|
+
if (text.length > 0) labels.push(text);
|
|
242
|
+
return labels;
|
|
243
|
+
});
|
|
244
|
+
if (answers.some((a) => a.length === 0)) return;
|
|
245
|
+
setCustomValues({});
|
|
246
|
+
resolveResponse("multi-question", { answers });
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Semua pertanyaan multi-question sudah punya minimal satu jawaban. */
|
|
250
|
+
const allQuestionsAnswered = questions.every(
|
|
251
|
+
(_q, qi) => (multiSel[qi]?.size ?? 0) > 0 || (customValues[qi] ?? "").trim().length > 0,
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Baris opsi question (boxed list ala libadwaita):
|
|
256
|
+
* - `action` : single choice — ketuk baris langsung menjawab.
|
|
257
|
+
* - `radio` : single choice dalam multi-question — memilih, dikirim lewat
|
|
258
|
+
* Submit.
|
|
259
|
+
* - `checkbox` : multi-select — centang beberapa, dikirim lewat Submit.
|
|
260
|
+
* Baris minimal 52px di HP (target sentuh nyaman), 44px di desktop.
|
|
261
|
+
*/
|
|
262
|
+
const optionRows = (
|
|
263
|
+
opts: PromptOption[],
|
|
264
|
+
optsKey: string,
|
|
265
|
+
mode: "action" | "radio" | "checkbox",
|
|
266
|
+
checked: (label: string) => boolean,
|
|
267
|
+
onToggle: (label: string) => void,
|
|
268
|
+
onPick: (label: string) => void,
|
|
269
|
+
) =>
|
|
270
|
+
opts.map((opt, i) => {
|
|
271
|
+
const isChecked = mode !== "action" && checked(opt.label);
|
|
272
|
+
const busy = mode === "action" && submittingKey === `option-${opt.label}`;
|
|
273
|
+
const disabled = submittingKey !== null;
|
|
274
|
+
// Pintasan angka hanya untuk daftar opsi utama (bukan multi-question).
|
|
275
|
+
const shortcut =
|
|
276
|
+
(optsKey === "single" || optsKey.startsWith("step")) && i < 9 ? String(i + 1) : undefined;
|
|
277
|
+
return (
|
|
278
|
+
<li key={`${optsKey}-${opt.label}`}>
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
role={mode === "checkbox" ? "checkbox" : mode === "radio" ? "radio" : undefined}
|
|
282
|
+
{...(mode !== "action" ? { "aria-checked": isChecked } : {})}
|
|
283
|
+
aria-busy={busy}
|
|
284
|
+
aria-keyshortcuts={shortcut}
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
onClick={() => (mode === "action" ? onPick(opt.label) : onToggle(opt.label))}
|
|
287
|
+
className={cn(
|
|
288
|
+
// Baris boxed list ala `ActionRow` (settings/prefs.tsx): 56px,
|
|
289
|
+
// px-4, hover/active muted, ring inset. `touch-manipulation`
|
|
290
|
+
// menghapus jeda tap di HP.
|
|
291
|
+
"flex min-h-14 w-full touch-manipulation items-center gap-3 px-4 py-2.5 text-left transition-colors outline-none",
|
|
292
|
+
"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",
|
|
293
|
+
disabled && !busy && "pointer-events-none opacity-50",
|
|
294
|
+
)}
|
|
295
|
+
>
|
|
296
|
+
{mode === "checkbox" && (
|
|
297
|
+
<span
|
|
298
|
+
aria-hidden
|
|
299
|
+
className={cn(
|
|
300
|
+
"flex size-5 shrink-0 items-center justify-center rounded-md border-2 transition-colors sm:size-4.5",
|
|
301
|
+
isChecked
|
|
302
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
303
|
+
: "border-muted-foreground/45",
|
|
304
|
+
)}
|
|
305
|
+
>
|
|
306
|
+
{isChecked && <CheckIcon className="size-3" strokeWidth={3} />}
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
309
|
+
{mode === "radio" && (
|
|
310
|
+
<span
|
|
311
|
+
aria-hidden
|
|
312
|
+
className={cn(
|
|
313
|
+
"flex size-5 shrink-0 items-center justify-center rounded-full border-2 transition-colors sm:size-4.5",
|
|
314
|
+
isChecked ? "border-primary bg-primary" : "border-muted-foreground/45",
|
|
315
|
+
)}
|
|
316
|
+
>
|
|
317
|
+
{isChecked && <span className="size-1.5 rounded-full bg-primary-foreground" />}
|
|
318
|
+
</span>
|
|
319
|
+
)}
|
|
320
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
|
|
321
|
+
<span className="wrap-anywhere text-[15px] leading-5">{opt.label}</span>
|
|
322
|
+
{opt.description && (
|
|
323
|
+
<span className="wrap-anywhere text-[13px] leading-snug text-muted-foreground">
|
|
324
|
+
{opt.description}
|
|
325
|
+
</span>
|
|
326
|
+
)}
|
|
327
|
+
</span>
|
|
328
|
+
{/* Suffix ala AdwActionRow: chevron untuk baris yang langsung
|
|
329
|
+
menjawab; spinner saat terkirim. Pintasan angka sebagai
|
|
330
|
+
keycap kecil, hanya di desktop. */}
|
|
331
|
+
{mode === "action" && (
|
|
332
|
+
<span className="flex shrink-0 items-center gap-2">
|
|
333
|
+
{shortcut && (
|
|
334
|
+
<kbd
|
|
335
|
+
aria-hidden
|
|
336
|
+
className="hidden rounded border px-1.5 font-mono text-[11px] leading-5 text-muted-foreground sm:inline"
|
|
337
|
+
>
|
|
338
|
+
{shortcut}
|
|
339
|
+
</kbd>
|
|
340
|
+
)}
|
|
341
|
+
{busy ? (
|
|
342
|
+
<Spinner className="size-4" />
|
|
343
|
+
) : (
|
|
344
|
+
<ChevronRightIcon aria-hidden className="size-4 text-muted-foreground" />
|
|
345
|
+
)}
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</button>
|
|
349
|
+
</li>
|
|
350
|
+
);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Input jawaban bebas. Enter mengirim sesuai mode kartu. `showSend: false`
|
|
355
|
+
* = jawaban ikut terkirim lewat Submit di footer (multi-question /
|
|
356
|
+
* multi-select), jadi tombol kirim per baris disembunyikan.
|
|
357
|
+
* Font 16px di HP: iOS Safari tidak men-zoom halaman saat input difokus.
|
|
358
|
+
*/
|
|
359
|
+
const customInput = (opts: {
|
|
360
|
+
value: string;
|
|
361
|
+
onChange: (v: string) => void;
|
|
362
|
+
onSubmit: () => void;
|
|
363
|
+
sendDisabled: boolean;
|
|
364
|
+
busy: boolean;
|
|
365
|
+
ariaLabel?: string;
|
|
366
|
+
showSend?: boolean;
|
|
367
|
+
enterHint?: "send" | "next";
|
|
368
|
+
}) => (
|
|
369
|
+
// Baris ala `EntryRow` (settings/prefs.tsx): tinggal di DALAM boxed list
|
|
370
|
+
// opsi sebagai baris terakhir, label kecil di atas input; tombol kirim
|
|
371
|
+
// bulat muncul begitu ada isi.
|
|
372
|
+
<li>
|
|
373
|
+
<label className="flex min-h-14 cursor-text items-center gap-3 px-4 py-2 focus-within:bg-muted/30">
|
|
374
|
+
<PencilLineIcon aria-hidden className="size-4 shrink-0 text-muted-foreground" />
|
|
375
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
376
|
+
<span className="text-[12px] leading-4 text-muted-foreground">Other</span>
|
|
377
|
+
<input
|
|
378
|
+
value={opts.value}
|
|
379
|
+
onChange={(e) => opts.onChange(e.target.value)}
|
|
380
|
+
onKeyDown={(e) => {
|
|
381
|
+
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
|
|
382
|
+
e.preventDefault();
|
|
383
|
+
opts.onSubmit();
|
|
384
|
+
}
|
|
385
|
+
}}
|
|
386
|
+
placeholder="Type your own answer…"
|
|
387
|
+
disabled={submittingKey !== null}
|
|
388
|
+
aria-label={opts.ariaLabel ?? "Your own answer"}
|
|
389
|
+
enterKeyHint={opts.enterHint ?? (opts.showSend === false ? "next" : "send")}
|
|
390
|
+
// 16px di HP: iOS Safari tidak men-zoom saat input difokus.
|
|
391
|
+
className="h-7 w-full min-w-0 bg-transparent text-base outline-none placeholder:text-muted-foreground/70 disabled:opacity-60 sm:text-[15px]"
|
|
392
|
+
/>
|
|
393
|
+
</span>
|
|
394
|
+
{opts.showSend !== false && (
|
|
395
|
+
<button
|
|
396
|
+
type="button"
|
|
397
|
+
disabled={submittingKey !== null || opts.sendDisabled}
|
|
398
|
+
aria-busy={opts.busy}
|
|
399
|
+
aria-label="Send your answer"
|
|
400
|
+
onClick={opts.onSubmit}
|
|
401
|
+
className={cn(
|
|
402
|
+
"flex size-9 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-0 sm:size-8",
|
|
403
|
+
opts.busy && "disabled:opacity-60",
|
|
404
|
+
)}
|
|
405
|
+
>
|
|
406
|
+
{opts.busy ? <Spinner className="size-4" /> : <ArrowUpIcon className="size-4" />}
|
|
407
|
+
</button>
|
|
408
|
+
)}
|
|
409
|
+
</label>
|
|
410
|
+
</li>
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
// Izin tool punya kartu sendiri: penjelasan ramah + aksi yang jelas
|
|
414
|
+
// konsekuensinya (lihat `PermissionCard`).
|
|
415
|
+
if (isPermission) {
|
|
416
|
+
return (
|
|
417
|
+
<PermissionCard
|
|
418
|
+
prompt={prompt}
|
|
419
|
+
twinCount={twinCount}
|
|
420
|
+
submittingKey={submittingKey}
|
|
421
|
+
error={error}
|
|
422
|
+
onResolve={resolveResponse}
|
|
423
|
+
/>
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---- Question ----------------------------------------------------------
|
|
428
|
+
/** Pertanyaan ke-`qi` sudah punya jawaban (opsi dan/atau teks kustom). */
|
|
429
|
+
const isAnswered = (qi: number) =>
|
|
430
|
+
(multiSel[qi]?.size ?? 0) > 0 || (customValues[qi] ?? "").trim().length > 0;
|
|
431
|
+
const answeredCount = questions.filter((_q, qi) => isAnswered(qi)).length;
|
|
432
|
+
|
|
433
|
+
// Wizard multi-question: indeks dijaga dalam rentang (daftar bisa berubah).
|
|
434
|
+
const stepIdx = Math.min(step, Math.max(questions.length - 1, 0));
|
|
435
|
+
const isLastStep = stepIdx === questions.length - 1;
|
|
436
|
+
const goTo = (next: number) => {
|
|
437
|
+
clearTimeout(advanceTimer.current);
|
|
438
|
+
if (next < 0 || next >= questions.length || next === stepIdx) return;
|
|
439
|
+
setStep(next);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Pilih opsi di wizard. Pertanyaan single choice otomatis lanjut ke
|
|
444
|
+
* langkah berikutnya setelah jeda singkat (pilihan sempat terlihat) —
|
|
445
|
+
* kecuali di langkah terakhir, di mana user menekan Submit.
|
|
446
|
+
*/
|
|
447
|
+
const pickStepOption = (qi: number, label: string, allowMulti: boolean) => {
|
|
448
|
+
toggleQOption(qi, label, allowMulti);
|
|
449
|
+
if (allowMulti || qi >= questions.length - 1) return;
|
|
450
|
+
clearTimeout(advanceTimer.current);
|
|
451
|
+
advanceTimer.current = setTimeout(() => goTo(qi + 1), 220);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Aksi footer: Submit hanya untuk mode yang MENGUMPULKAN jawaban (multi-
|
|
455
|
+
// select / multi-question). Single choice tanpa Submit — ketuk opsi
|
|
456
|
+
// langsung menjawab.
|
|
457
|
+
const footer = isMulti
|
|
458
|
+
? {
|
|
459
|
+
submitDisabled: selectedMulti.size === 0 && customValue.trim().length === 0,
|
|
460
|
+
busy: submittingKey === "multi-submit",
|
|
461
|
+
onSubmit: () => submitMulti(customValue),
|
|
462
|
+
}
|
|
463
|
+
: null;
|
|
464
|
+
|
|
465
|
+
const hint = isMulti ? "Select all that apply" : "Tap an option to answer";
|
|
466
|
+
|
|
467
|
+
// Pintasan keyboard (desktop), diabaikan saat mengetik / ada modifier:
|
|
468
|
+
// - Angka 1–9 = pilih opsi ke-n (single: jawab / lanjut; multi: toggle).
|
|
469
|
+
// - Wizard: ← / → = langkah sebelumnya / berikutnya.
|
|
470
|
+
keyHandlerRef.current = (e) => {
|
|
471
|
+
if (submittingKey !== null || e.defaultPrevented || e.isComposing) return;
|
|
472
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
473
|
+
const el = e.target as HTMLElement | null;
|
|
474
|
+
if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) return;
|
|
475
|
+
if (isMultiQuestion) {
|
|
476
|
+
if (e.key === "ArrowLeft") {
|
|
477
|
+
e.preventDefault();
|
|
478
|
+
goTo(stepIdx - 1);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (e.key === "ArrowRight") {
|
|
482
|
+
e.preventDefault();
|
|
483
|
+
if (isAnswered(stepIdx)) goTo(stepIdx + 1);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (!/^[1-9]$/.test(e.key)) return;
|
|
488
|
+
const n = Number(e.key) - 1;
|
|
489
|
+
if (isMultiQuestion) {
|
|
490
|
+
const q = questions[stepIdx];
|
|
491
|
+
const opt = q?.options?.[n];
|
|
492
|
+
if (!q || !opt) return;
|
|
493
|
+
e.preventDefault();
|
|
494
|
+
pickStepOption(stepIdx, opt.label, q.multiple === true);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const opt = (prompt.options ?? [])[n];
|
|
498
|
+
if (!opt) return;
|
|
499
|
+
e.preventDefault();
|
|
500
|
+
if (isMulti) toggleMulti(opt.label);
|
|
501
|
+
else resolveResponse(`option-${opt.label}`, { option: opt.label });
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
const optionCount = isMultiQuestion
|
|
505
|
+
? (questions[stepIdx]?.options?.length ?? 0)
|
|
506
|
+
: (prompt.options?.length ?? 0);
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<Card
|
|
510
|
+
role="dialog"
|
|
511
|
+
aria-labelledby={`q-${prompt.id}-title`}
|
|
512
|
+
className={cn(
|
|
513
|
+
// Permukaan ala AdwDialog: latar "window" (bg-background) supaya boxed
|
|
514
|
+
// list (bg-card) di dalamnya terbaca sebagai grup — sama seperti
|
|
515
|
+
// halaman Settings. Tanpa ring/blur.
|
|
516
|
+
"gap-0 rounded-2xl border bg-background py-0 shadow-lg",
|
|
517
|
+
// Docked (menggantikan composer): kartu mengisi sisa kolom; hanya
|
|
518
|
+
// daftar jawaban yang scroll, judul & footer tetap terlihat.
|
|
519
|
+
docked && "flex min-h-0 shrink flex-col overflow-hidden",
|
|
520
|
+
)}
|
|
521
|
+
>
|
|
522
|
+
{/* Header: judul = PERTANYAAN itu sendiri (bukan label "Question").
|
|
523
|
+
Pertanyaan sangat panjang dibatasi tingginya (scroll sendiri) agar
|
|
524
|
+
opsi tetap kelihatan di layar HP. */}
|
|
525
|
+
<CardHeader className="flex max-h-[30dvh] shrink-0 flex-row items-start gap-3 overflow-y-auto px-5 pt-4 pb-3">
|
|
526
|
+
<span
|
|
527
|
+
aria-hidden
|
|
528
|
+
className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-foreground/80"
|
|
529
|
+
>
|
|
530
|
+
{isMultiQuestion || isMulti ? (
|
|
531
|
+
<ListChecksIcon className="size-4" />
|
|
532
|
+
) : (
|
|
533
|
+
<HelpCircleIcon className="size-4" />
|
|
534
|
+
)}
|
|
535
|
+
</span>
|
|
536
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
537
|
+
<CardTitle
|
|
538
|
+
id={`q-${prompt.id}-title`}
|
|
539
|
+
className="wrap-anywhere text-[17px] leading-6 font-semibold tracking-tight sm:text-base"
|
|
540
|
+
>
|
|
541
|
+
{isMultiQuestion
|
|
542
|
+
? `The agent has ${questions.length} questions`
|
|
543
|
+
: (prompt.title ?? "The agent has a question")}
|
|
544
|
+
</CardTitle>
|
|
545
|
+
<CardDescription className="flex items-center gap-1.5 text-[13px]">
|
|
546
|
+
{submittingKey !== null ? (
|
|
547
|
+
<>
|
|
548
|
+
<Spinner className="size-3" />
|
|
549
|
+
Sending…
|
|
550
|
+
</>
|
|
551
|
+
) : isMultiQuestion ? (
|
|
552
|
+
`Question ${stepIdx + 1} of ${questions.length} · ${answeredCount} answered`
|
|
553
|
+
) : optionCount > 0 ? (
|
|
554
|
+
<>
|
|
555
|
+
{/* HP: petunjuk sentuh. Desktop: pintasan angka. */}
|
|
556
|
+
<span className="sm:hidden">{hint}</span>
|
|
557
|
+
<span className="hidden sm:inline">
|
|
558
|
+
{isMulti ? hint : "Click an option"} · press{" "}
|
|
559
|
+
<kbd className="rounded border px-1 font-mono text-[10px]">1</kbd>
|
|
560
|
+
{optionCount > 1 && (
|
|
561
|
+
<>
|
|
562
|
+
–
|
|
563
|
+
<kbd className="rounded border px-1 font-mono text-[10px]">
|
|
564
|
+
{Math.min(optionCount, 9)}
|
|
565
|
+
</kbd>
|
|
566
|
+
</>
|
|
567
|
+
)}
|
|
568
|
+
</span>
|
|
569
|
+
</>
|
|
570
|
+
) : (
|
|
571
|
+
hint
|
|
572
|
+
)}
|
|
573
|
+
</CardDescription>
|
|
574
|
+
</div>
|
|
575
|
+
</CardHeader>
|
|
576
|
+
|
|
577
|
+
{isMultiQuestion && (
|
|
578
|
+
<StepIndicator
|
|
579
|
+
count={questions.length}
|
|
580
|
+
current={stepIdx}
|
|
581
|
+
answered={isAnswered}
|
|
582
|
+
labels={questions.map((q, qi) => q.header ?? `Question ${qi + 1}`)}
|
|
583
|
+
disabled={submittingKey !== null}
|
|
584
|
+
onSelect={goTo}
|
|
585
|
+
/>
|
|
586
|
+
)}
|
|
587
|
+
|
|
588
|
+
<CardContent
|
|
589
|
+
className={cn(
|
|
590
|
+
"flex flex-col px-3 sm:px-4",
|
|
591
|
+
"pt-1",
|
|
592
|
+
docked ? "min-h-0 flex-auto overflow-hidden" : "pb-3",
|
|
593
|
+
)}
|
|
594
|
+
>
|
|
595
|
+
{/* biome-ignore lint/a11y/useSemanticElements: wrapper flex/scroll kartu; fieldset mengubah layout & styling */}
|
|
596
|
+
<div
|
|
597
|
+
role="group"
|
|
598
|
+
aria-label="Answers"
|
|
599
|
+
className={cn(
|
|
600
|
+
"flex flex-col",
|
|
601
|
+
docked &&
|
|
602
|
+
"min-h-0 flex-auto overflow-x-hidden overflow-y-auto overscroll-contain pb-3 [scrollbar-width:thin] [-webkit-overflow-scrolling:touch]",
|
|
603
|
+
)}
|
|
604
|
+
>
|
|
605
|
+
{isMultiQuestion ? (
|
|
606
|
+
// Semua langkah ditumpuk di SATU sel grid: tinggi kartu = langkah
|
|
607
|
+
// tertinggi dan TETAP selama navigasi. Sebelumnya hanya langkah
|
|
608
|
+
// aktif yang dirender, sehingga tinggi kartu berubah tiap ganti
|
|
609
|
+
// langkah — dan karena kartu docked di bawah timeline (flex),
|
|
610
|
+
// timeline ikut membesar/mengecil lalu scroller menyesuaikan
|
|
611
|
+
// posisi scroll: terlihat seperti kartu melebar lalu menyusut.
|
|
612
|
+
// Langkah non-aktif tetap ada di layout tapi `invisible` + `inert`
|
|
613
|
+
// (tidak terlihat, tidak bisa difokus, tidak dibaca screen reader).
|
|
614
|
+
// `overflow-x-clip`: langkah non-aktif digeser ±24px (animasi
|
|
615
|
+
// slide) sehingga melewati tepi grid. Tanpa clip, area scroll
|
|
616
|
+
// kartu ikut bisa digeser horizontal. `clip` (bukan `hidden`)
|
|
617
|
+
// tidak membuat scroll container baru, jadi scroll vertikal
|
|
618
|
+
// tetap ditangani wadah jawaban.
|
|
619
|
+
<div className="grid min-w-0 overflow-x-clip">
|
|
620
|
+
{questions.map((q, qi) => {
|
|
621
|
+
const active = qi === stepIdx;
|
|
622
|
+
const qMulti = q.multiple === true;
|
|
623
|
+
const sel = multiSel[qi] ?? new Set<string>();
|
|
624
|
+
// Geser horizontal sesuai posisi relatif terhadap langkah aktif
|
|
625
|
+
// (dimatikan bila prefers-reduced-motion — MotionConfig di App).
|
|
626
|
+
const offset = qi < stepIdx ? -24 : qi > stepIdx ? 24 : 0;
|
|
627
|
+
return (
|
|
628
|
+
<m.section
|
|
629
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: daftar pertanyaan statis, state diindeks per qi
|
|
630
|
+
key={`step-${qi}`}
|
|
631
|
+
initial={false}
|
|
632
|
+
animate={{
|
|
633
|
+
opacity: active ? 1 : 0,
|
|
634
|
+
x: offset,
|
|
635
|
+
transition: { duration: active ? 0.2 : 0.12, ease: "easeOut" },
|
|
636
|
+
}}
|
|
637
|
+
inert={!active}
|
|
638
|
+
aria-hidden={!active}
|
|
639
|
+
aria-labelledby={`q-${prompt.id}-${qi}`}
|
|
640
|
+
className={cn(
|
|
641
|
+
"col-start-1 row-start-1 flex min-w-0 flex-col gap-2.5",
|
|
642
|
+
!active && "pointer-events-none",
|
|
643
|
+
)}
|
|
644
|
+
>
|
|
645
|
+
{/* Judul grup ala `PrefsGroup`: header kecil, pertanyaan
|
|
646
|
+
sebagai judul, petunjuk sebagai deskripsi. */}
|
|
647
|
+
<div className="flex flex-col gap-0.5 px-1">
|
|
648
|
+
{q.header && (
|
|
649
|
+
<span className="text-[12px] text-muted-foreground">{q.header}</span>
|
|
650
|
+
)}
|
|
651
|
+
<h3
|
|
652
|
+
id={`q-${prompt.id}-${qi}`}
|
|
653
|
+
className="wrap-anywhere text-[15px] leading-5 font-semibold tracking-tight"
|
|
654
|
+
>
|
|
655
|
+
{q.question ?? `Question ${qi + 1}`}
|
|
656
|
+
</h3>
|
|
657
|
+
{qMulti && (
|
|
658
|
+
<p className="text-[13px] text-muted-foreground">Select all that apply</p>
|
|
659
|
+
)}
|
|
660
|
+
</div>
|
|
661
|
+
<OptionList>
|
|
662
|
+
{optionRows(
|
|
663
|
+
q.options ?? [],
|
|
664
|
+
// Pintasan angka hanya untuk langkah aktif.
|
|
665
|
+
active ? `step${qi}` : `hidden${qi}`,
|
|
666
|
+
qMulti ? "checkbox" : "radio",
|
|
667
|
+
(label) => sel.has(label),
|
|
668
|
+
(label) => pickStepOption(qi, label, qMulti),
|
|
669
|
+
() => {},
|
|
670
|
+
)}
|
|
671
|
+
{q.custom !== false &&
|
|
672
|
+
customInput({
|
|
673
|
+
value: customValues[qi] ?? "",
|
|
674
|
+
onChange: (v) => setCustomValues((prev) => ({ ...prev, [qi]: v })),
|
|
675
|
+
// Enter: lanjut ke langkah berikutnya, atau kirim
|
|
676
|
+
// di langkah terakhir.
|
|
677
|
+
onSubmit: () =>
|
|
678
|
+
qi === questions.length - 1
|
|
679
|
+
? submitMultiQuestion()
|
|
680
|
+
: isAnswered(qi) && goTo(qi + 1),
|
|
681
|
+
sendDisabled: true,
|
|
682
|
+
busy: false,
|
|
683
|
+
ariaLabel: `Your own answer for question ${qi + 1}`,
|
|
684
|
+
showSend: false,
|
|
685
|
+
enterHint: qi === questions.length - 1 ? "send" : "next",
|
|
686
|
+
})}
|
|
687
|
+
</OptionList>
|
|
688
|
+
</m.section>
|
|
689
|
+
);
|
|
690
|
+
})}
|
|
691
|
+
</div>
|
|
692
|
+
) : (
|
|
693
|
+
<OptionList>
|
|
694
|
+
{optionRows(
|
|
695
|
+
prompt.options ?? [],
|
|
696
|
+
"single",
|
|
697
|
+
isMulti ? "checkbox" : "action",
|
|
698
|
+
(label) => selectedMulti.has(label),
|
|
699
|
+
toggleMulti,
|
|
700
|
+
(label) => resolveResponse(`option-${label}`, { option: label }),
|
|
701
|
+
)}
|
|
702
|
+
{prompt.custom === true &&
|
|
703
|
+
customInput({
|
|
704
|
+
value: customValue,
|
|
705
|
+
onChange: setCustomValue,
|
|
706
|
+
onSubmit: () => (isMulti ? submitMulti(customValue) : submitCustom()),
|
|
707
|
+
sendDisabled: isMulti
|
|
708
|
+
? customValue.trim().length === 0 && selectedMulti.size === 0
|
|
709
|
+
: customValue.trim().length === 0,
|
|
710
|
+
busy: submittingKey === "custom" || submittingKey === "multi-submit",
|
|
711
|
+
// Multi-select: teks ikut terkirim lewat Submit di footer.
|
|
712
|
+
showSend: !isMulti,
|
|
713
|
+
})}
|
|
714
|
+
</OptionList>
|
|
715
|
+
)}
|
|
716
|
+
</div>
|
|
717
|
+
{error && (
|
|
718
|
+
<p className="pt-2 text-[13px] text-destructive sm:text-xs" role="alert">
|
|
719
|
+
{error}
|
|
720
|
+
</p>
|
|
721
|
+
)}
|
|
722
|
+
</CardContent>
|
|
723
|
+
|
|
724
|
+
{/* Footer selalu di luar area scroll.
|
|
725
|
+
- Wizard: Skip | Back · Next/Submit answers.
|
|
726
|
+
- Multi-select: Skip | Submit.
|
|
727
|
+
- Single choice: hanya Skip (ketuk opsi langsung menjawab). */}
|
|
728
|
+
<div
|
|
729
|
+
className={cn(
|
|
730
|
+
"flex shrink-0 items-center gap-2 px-3 pt-1 pb-3 sm:px-4",
|
|
731
|
+
footer === null && !isMultiQuestion && "justify-end",
|
|
732
|
+
)}
|
|
733
|
+
>
|
|
734
|
+
<Button
|
|
735
|
+
type="button"
|
|
736
|
+
variant={isMultiQuestion ? "ghost" : "secondary"}
|
|
737
|
+
disabled={submittingKey !== null}
|
|
738
|
+
aria-busy={submittingKey === "dismiss"}
|
|
739
|
+
onClick={() => resolveResponse("dismiss", "cancel")}
|
|
740
|
+
className={cn(
|
|
741
|
+
"h-11 rounded-full px-5 text-[15px] sm:h-9 sm:text-sm",
|
|
742
|
+
isMultiQuestion && "px-3 text-muted-foreground hover:text-foreground sm:px-4",
|
|
743
|
+
footer !== null && "flex-1 sm:flex-none",
|
|
744
|
+
)}
|
|
745
|
+
>
|
|
746
|
+
{submittingKey === "dismiss" && <Spinner className="size-4" data-icon="inline-start" />}
|
|
747
|
+
Skip
|
|
748
|
+
</Button>
|
|
749
|
+
{isMultiQuestion && (
|
|
750
|
+
<div className="ms-auto flex items-center gap-2">
|
|
751
|
+
<Button
|
|
752
|
+
type="button"
|
|
753
|
+
variant="secondary"
|
|
754
|
+
disabled={submittingKey !== null || stepIdx === 0}
|
|
755
|
+
onClick={() => goTo(stepIdx - 1)}
|
|
756
|
+
aria-label="Previous question"
|
|
757
|
+
aria-keyshortcuts="ArrowLeft"
|
|
758
|
+
className="h-11 rounded-full px-4 text-[15px] sm:h-9 sm:text-sm"
|
|
759
|
+
>
|
|
760
|
+
<ChevronLeftIcon className="size-4" data-icon="inline-start" />
|
|
761
|
+
<span className="hidden sm:inline">Back</span>
|
|
762
|
+
</Button>
|
|
763
|
+
{isLastStep ? (
|
|
764
|
+
<Button
|
|
765
|
+
type="button"
|
|
766
|
+
disabled={submittingKey !== null || !allQuestionsAnswered}
|
|
767
|
+
aria-busy={submittingKey === "multi-question"}
|
|
768
|
+
onClick={submitMultiQuestion}
|
|
769
|
+
className="h-11 rounded-full px-5 text-[15px] sm:h-9 sm:text-sm"
|
|
770
|
+
>
|
|
771
|
+
{submittingKey === "multi-question" && (
|
|
772
|
+
<Spinner className="size-4" data-icon="inline-start" />
|
|
773
|
+
)}
|
|
774
|
+
Submit answers
|
|
775
|
+
</Button>
|
|
776
|
+
) : (
|
|
777
|
+
<Button
|
|
778
|
+
type="button"
|
|
779
|
+
disabled={submittingKey !== null || !isAnswered(stepIdx)}
|
|
780
|
+
onClick={() => goTo(stepIdx + 1)}
|
|
781
|
+
aria-keyshortcuts="ArrowRight"
|
|
782
|
+
className="h-11 rounded-full px-5 text-[15px] sm:h-9 sm:text-sm"
|
|
783
|
+
>
|
|
784
|
+
Next
|
|
785
|
+
<ChevronRightIcon className="size-4" data-icon="inline-end" />
|
|
786
|
+
</Button>
|
|
787
|
+
)}
|
|
788
|
+
</div>
|
|
789
|
+
)}
|
|
790
|
+
{footer && (
|
|
791
|
+
<Button
|
|
792
|
+
type="button"
|
|
793
|
+
disabled={submittingKey !== null || footer.submitDisabled}
|
|
794
|
+
aria-busy={footer.busy}
|
|
795
|
+
onClick={footer.onSubmit}
|
|
796
|
+
className="h-11 flex-[2] rounded-full px-5 text-[15px] sm:ms-auto sm:h-9 sm:flex-none sm:text-sm"
|
|
797
|
+
>
|
|
798
|
+
{footer.busy && <Spinner className="size-4" data-icon="inline-start" />}
|
|
799
|
+
Submit
|
|
800
|
+
</Button>
|
|
801
|
+
)}
|
|
802
|
+
</div>
|
|
803
|
+
</Card>
|
|
804
|
+
);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* Penanda langkah wizard: satu segmen per pertanyaan. Segmen terisi =
|
|
809
|
+
* sudah dijawab, segmen aktif lebih tebal. Tiap segmen bisa diketuk untuk
|
|
810
|
+
* lompat ke pertanyaan itu (target sentuh 44px, garisnya tetap tipis).
|
|
811
|
+
*/
|
|
812
|
+
function StepIndicator({
|
|
813
|
+
count,
|
|
814
|
+
current,
|
|
815
|
+
answered,
|
|
816
|
+
labels,
|
|
817
|
+
disabled,
|
|
818
|
+
onSelect,
|
|
819
|
+
}: {
|
|
820
|
+
count: number;
|
|
821
|
+
current: number;
|
|
822
|
+
answered: (i: number) => boolean;
|
|
823
|
+
labels: string[];
|
|
824
|
+
disabled: boolean;
|
|
825
|
+
onSelect: (i: number) => void;
|
|
826
|
+
}) {
|
|
827
|
+
return (
|
|
828
|
+
<nav aria-label="Questions" className="shrink-0 px-4 sm:px-5">
|
|
829
|
+
<ol className="-my-2 flex gap-1.5">
|
|
830
|
+
{Array.from({ length: count }, (_, i) => {
|
|
831
|
+
const done = answered(i);
|
|
832
|
+
const active = i === current;
|
|
833
|
+
return (
|
|
834
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: segmen statis per indeks pertanyaan
|
|
835
|
+
<li key={i} className="min-w-0 flex-1">
|
|
836
|
+
<button
|
|
837
|
+
type="button"
|
|
838
|
+
disabled={disabled}
|
|
839
|
+
onClick={() => onSelect(i)}
|
|
840
|
+
aria-current={active ? "step" : undefined}
|
|
841
|
+
aria-label={`${labels[i]}${done ? " (answered)" : ""}`}
|
|
842
|
+
className="group flex h-11 w-full touch-manipulation items-center outline-none sm:h-6"
|
|
843
|
+
>
|
|
844
|
+
<span
|
|
845
|
+
className={cn(
|
|
846
|
+
"h-1 w-full rounded-full transition-colors group-focus-visible:ring-[3px] group-focus-visible:ring-ring/50",
|
|
847
|
+
active
|
|
848
|
+
? "bg-primary"
|
|
849
|
+
: done
|
|
850
|
+
? "bg-primary/45 group-hover:bg-primary/60"
|
|
851
|
+
: "bg-muted group-hover:bg-muted-foreground/30",
|
|
852
|
+
)}
|
|
853
|
+
/>
|
|
854
|
+
</button>
|
|
855
|
+
</li>
|
|
856
|
+
);
|
|
857
|
+
})}
|
|
858
|
+
</ol>
|
|
859
|
+
</nav>
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** Daftar opsi bergaya boxed list (kotak membulat, baris berpemisah). */
|
|
864
|
+
function OptionList({ children }: { children: ReactNode }) {
|
|
865
|
+
// Sama dengan isi `PrefsGroup` (settings/prefs.tsx): kartu membulat,
|
|
866
|
+
// baris berpemisah, latar card.
|
|
867
|
+
return (
|
|
868
|
+
<div className="overflow-hidden rounded-xl border bg-card shadow-xs">
|
|
869
|
+
<ul className="flex flex-col divide-y">{children}</ul>
|
|
870
|
+
</div>
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// -------------------------------------------------------- permission ----
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Tingkat risiko hanya diwakili ikon + label teks (bukan garis/warna
|
|
878
|
+
* dekoratif) — tetap sederhana ala libadwaita, dan tidak bergantung pada
|
|
879
|
+
* warna saja.
|
|
880
|
+
*/
|
|
881
|
+
const RISK_STYLE: Record<PermissionRisk, { icon: string; label: string }> = {
|
|
882
|
+
high: { icon: "bg-amber-500/15 text-amber-600 dark:text-amber-400", label: "Review carefully" },
|
|
883
|
+
medium: { icon: "bg-muted text-foreground/80", label: "" },
|
|
884
|
+
low: { icon: "bg-muted text-muted-foreground", label: "Read-only" },
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* Kartu izin tool.
|
|
889
|
+
*
|
|
890
|
+
* - Judul = KALIMAT apa yang diminta ("Access a folder outside the
|
|
891
|
+
* project"), nama permission internal jadi label kecil.
|
|
892
|
+
* - Target (path/perintah) tampil sebagai daftar monospace yang terbaca.
|
|
893
|
+
* - Aksi menurut konsekuensi: "Allow once" (utama) & "Deny"; "Always allow"
|
|
894
|
+
* terpisah karena efeknya permanen untuk sesi ini. "Cancel" dihapus —
|
|
895
|
+
* identik dengan Deny (Req 8.5).
|
|
896
|
+
* - Pintasan keyboard: Enter = izinkan sekali, Esc = tolak.
|
|
897
|
+
*
|
|
898
|
+
* Responsif:
|
|
899
|
+
* - Desktop: satu baris aksi ringkas, tombol kecil + petunjuk pintasan.
|
|
900
|
+
* - HP: teks lebih besar & tombol setinggi target sentuh (44px+), disusun
|
|
901
|
+
* vertikal selebar kartu dengan aksi utama di ATAS (paling dekat ibu jari
|
|
902
|
+
* saat kartu di atas composer).
|
|
903
|
+
*/
|
|
904
|
+
function PermissionCard({
|
|
905
|
+
prompt,
|
|
906
|
+
twinCount,
|
|
907
|
+
submittingKey,
|
|
908
|
+
error,
|
|
909
|
+
onResolve,
|
|
910
|
+
}: {
|
|
911
|
+
prompt: InteractivePrompt;
|
|
912
|
+
twinCount: number;
|
|
913
|
+
submittingKey: string | null;
|
|
914
|
+
error: string | null;
|
|
915
|
+
onResolve: (key: string, response: PromptResponse) => void;
|
|
916
|
+
}) {
|
|
917
|
+
const info = parsePermissionTitle(prompt.title);
|
|
918
|
+
const style = RISK_STYLE[info.risk];
|
|
919
|
+
const busy = submittingKey !== null;
|
|
920
|
+
|
|
921
|
+
// Enter = allow once, Esc = deny — hanya saat fokus TIDAK di kolom isian
|
|
922
|
+
// (composer / input lain), agar mengetik tidak menyetujui izin tanpa sengaja.
|
|
923
|
+
useEffect(() => {
|
|
924
|
+
const onKey = (e: KeyboardEvent) => {
|
|
925
|
+
if (busy || e.defaultPrevented || e.isComposing) return;
|
|
926
|
+
const el = e.target as HTMLElement | null;
|
|
927
|
+
const typing =
|
|
928
|
+
el !== null &&
|
|
929
|
+
(el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable);
|
|
930
|
+
if (typing) return;
|
|
931
|
+
if (e.key === "Enter" && !e.shiftKey && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
932
|
+
e.preventDefault();
|
|
933
|
+
onResolve("approve", "approve");
|
|
934
|
+
} else if (e.key === "Escape") {
|
|
935
|
+
e.preventDefault();
|
|
936
|
+
onResolve("deny", "deny");
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
window.addEventListener("keydown", onKey);
|
|
940
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
941
|
+
}, [busy, onResolve]);
|
|
942
|
+
|
|
943
|
+
const spin = (key: string) =>
|
|
944
|
+
submittingKey === key ? <Spinner className="size-4" data-icon="inline-start" /> : null;
|
|
945
|
+
|
|
946
|
+
/** Tombol: tinggi sentuh di HP, ringkas di desktop. */
|
|
947
|
+
const btn = "h-11 w-full text-[15px] sm:h-8 sm:w-auto sm:text-sm";
|
|
948
|
+
|
|
949
|
+
return (
|
|
950
|
+
<Card
|
|
951
|
+
role="alertdialog"
|
|
952
|
+
aria-labelledby={`perm-${prompt.id}-title`}
|
|
953
|
+
aria-describedby={`perm-${prompt.id}-targets`}
|
|
954
|
+
className="gap-4 rounded-2xl border bg-card py-4 shadow-lg sm:gap-3 sm:rounded-xl sm:py-3.5"
|
|
955
|
+
>
|
|
956
|
+
<CardHeader className="flex flex-row items-start gap-3 px-4">
|
|
957
|
+
<span
|
|
958
|
+
aria-hidden
|
|
959
|
+
className={cn(
|
|
960
|
+
"flex size-10 shrink-0 items-center justify-center rounded-full sm:size-8",
|
|
961
|
+
style.icon,
|
|
962
|
+
)}
|
|
963
|
+
>
|
|
964
|
+
<ShieldAlertIcon className="size-5 sm:size-4.5" />
|
|
965
|
+
</span>
|
|
966
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1 sm:gap-0.5">
|
|
967
|
+
<CardTitle
|
|
968
|
+
id={`perm-${prompt.id}-title`}
|
|
969
|
+
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-base leading-6 sm:text-sm sm:leading-5"
|
|
970
|
+
>
|
|
971
|
+
<span>{info.label}</span>
|
|
972
|
+
{twinCount > 1 && (
|
|
973
|
+
<Badge variant="secondary" title={`${twinCount} identical requests`}>
|
|
974
|
+
×{twinCount}
|
|
975
|
+
</Badge>
|
|
976
|
+
)}
|
|
977
|
+
</CardTitle>
|
|
978
|
+
<CardDescription className="flex flex-wrap items-center gap-x-1.5 text-[13px] sm:text-xs">
|
|
979
|
+
<span>The agent is asking for permission</span>
|
|
980
|
+
{info.permission && (
|
|
981
|
+
<>
|
|
982
|
+
<span aria-hidden>·</span>
|
|
983
|
+
<code className="font-mono text-[12px] sm:text-[11px]">{info.permission}</code>
|
|
984
|
+
</>
|
|
985
|
+
)}
|
|
986
|
+
{style.label && (
|
|
987
|
+
<>
|
|
988
|
+
<span aria-hidden>·</span>
|
|
989
|
+
<span
|
|
990
|
+
className={cn(
|
|
991
|
+
info.risk === "high" && "font-medium text-amber-700 dark:text-amber-400",
|
|
992
|
+
)}
|
|
993
|
+
>
|
|
994
|
+
{style.label}
|
|
995
|
+
</span>
|
|
996
|
+
</>
|
|
997
|
+
)}
|
|
998
|
+
</CardDescription>
|
|
999
|
+
</div>
|
|
1000
|
+
</CardHeader>
|
|
1001
|
+
|
|
1002
|
+
<CardContent className="flex flex-col gap-4 px-4 sm:gap-3">
|
|
1003
|
+
{info.targets.length > 0 && (
|
|
1004
|
+
<ul
|
|
1005
|
+
id={`perm-${prompt.id}-targets`}
|
|
1006
|
+
className="flex max-h-40 flex-col gap-1 overflow-y-auto rounded-xl bg-muted/60 px-3 py-2.5 [scrollbar-width:thin] sm:max-h-32 sm:rounded-lg sm:py-2"
|
|
1007
|
+
>
|
|
1008
|
+
{info.targets.map((t) => (
|
|
1009
|
+
// wrap-anywhere: path panjang tanpa spasi tidak meluapkan kartu.
|
|
1010
|
+
<li
|
|
1011
|
+
key={t}
|
|
1012
|
+
className="wrap-anywhere font-mono text-[13px] leading-relaxed text-foreground/90 sm:text-xs"
|
|
1013
|
+
>
|
|
1014
|
+
{t}
|
|
1015
|
+
</li>
|
|
1016
|
+
))}
|
|
1017
|
+
</ul>
|
|
1018
|
+
)}
|
|
1019
|
+
|
|
1020
|
+
{/* HP: tombol penuh lebar bertumpuk, aksi utama di atas. Desktop:
|
|
1021
|
+
satu baris — "Always allow" di kiri, Deny & Allow once di kanan. */}
|
|
1022
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
|
1023
|
+
<Button
|
|
1024
|
+
type="button"
|
|
1025
|
+
size="sm"
|
|
1026
|
+
disabled={busy}
|
|
1027
|
+
aria-busy={submittingKey === "approve"}
|
|
1028
|
+
onClick={() => onResolve("approve", "approve")}
|
|
1029
|
+
aria-keyshortcuts="Enter"
|
|
1030
|
+
className={cn(btn, "sm:order-3")}
|
|
1031
|
+
>
|
|
1032
|
+
{spin("approve")}
|
|
1033
|
+
Allow once
|
|
1034
|
+
<Kbd>↵</Kbd>
|
|
1035
|
+
</Button>
|
|
1036
|
+
<Button
|
|
1037
|
+
type="button"
|
|
1038
|
+
size="sm"
|
|
1039
|
+
variant="outline"
|
|
1040
|
+
disabled={busy}
|
|
1041
|
+
aria-busy={submittingKey === "deny"}
|
|
1042
|
+
onClick={() => onResolve("deny", "deny")}
|
|
1043
|
+
aria-keyshortcuts="Escape"
|
|
1044
|
+
className={cn(btn, "sm:order-2 sm:ms-auto")}
|
|
1045
|
+
>
|
|
1046
|
+
{spin("deny")}
|
|
1047
|
+
Deny
|
|
1048
|
+
<Kbd>Esc</Kbd>
|
|
1049
|
+
</Button>
|
|
1050
|
+
<Button
|
|
1051
|
+
type="button"
|
|
1052
|
+
size="sm"
|
|
1053
|
+
variant="ghost"
|
|
1054
|
+
disabled={busy}
|
|
1055
|
+
aria-busy={submittingKey === "always"}
|
|
1056
|
+
onClick={() => onResolve("always", "always")}
|
|
1057
|
+
title="Don't ask again for matching requests in this session"
|
|
1058
|
+
className={cn(btn, "text-muted-foreground hover:text-foreground sm:order-1")}
|
|
1059
|
+
>
|
|
1060
|
+
{spin("always") ?? <ShieldCheckIcon className="size-4" data-icon="inline-start" />}
|
|
1061
|
+
Always allow
|
|
1062
|
+
</Button>
|
|
1063
|
+
</div>
|
|
1064
|
+
|
|
1065
|
+
{error && (
|
|
1066
|
+
<p className="text-[13px] text-destructive sm:text-xs" role="alert">
|
|
1067
|
+
{error}
|
|
1068
|
+
</p>
|
|
1069
|
+
)}
|
|
1070
|
+
</CardContent>
|
|
1071
|
+
</Card>
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Petunjuk pintasan kecil di dalam tombol (disembunyikan di layar sentuh). */
|
|
1076
|
+
function Kbd({ children }: { children: string }) {
|
|
1077
|
+
return (
|
|
1078
|
+
<kbd
|
|
1079
|
+
aria-hidden
|
|
1080
|
+
className="ms-0.5 hidden rounded border border-current/20 px-1 font-sans text-[10px] leading-4 opacity-60 sm:inline"
|
|
1081
|
+
>
|
|
1082
|
+
{children}
|
|
1083
|
+
</kbd>
|
|
1084
|
+
);
|
|
1085
|
+
}
|