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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Penjelasan izin tool untuk manusia — logika murni (tanpa DOM, diuji).
|
|
3
|
+
*
|
|
4
|
+
* Server menyimpan judul permission sebagai satu string ringkas hasil
|
|
5
|
+
* `describePermission`: `"<permission> — `pola1` — `pola2`"` (mis.
|
|
6
|
+
* ``external_directory — `/home/u/.config/opencode/*` ``). String itu
|
|
7
|
+
* akurat tapi tidak ramah: nama permission internal opencode + pola glob
|
|
8
|
+
* mentah. Helper ini memecahnya menjadi:
|
|
9
|
+
* - `label` : kalimat singkat apa yang diminta agent ("Access a folder
|
|
10
|
+
* outside the project").
|
|
11
|
+
* - `targets`: pola yang terdampak, tanpa backtick.
|
|
12
|
+
* - `risk` : seberapa hati-hati pengguna perlu (menentukan warna kartu).
|
|
13
|
+
*
|
|
14
|
+
* Daftar permission mengikuti skema `PermissionConfig` opencode. Permission
|
|
15
|
+
* yang tidak dikenal (versi opencode lebih baru) tetap tampil dengan nama
|
|
16
|
+
* aslinya — tidak pernah disembunyikan.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type PermissionRisk = "low" | "medium" | "high";
|
|
20
|
+
|
|
21
|
+
export interface PermissionInfo {
|
|
22
|
+
/** Nama permission opencode apa adanya (mis. `external_directory`). */
|
|
23
|
+
permission: string | null;
|
|
24
|
+
/** Kalimat ramah tentang apa yang diminta. */
|
|
25
|
+
label: string;
|
|
26
|
+
/** Pola/path/perintah yang terdampak (tanpa backtick). */
|
|
27
|
+
targets: string[];
|
|
28
|
+
risk: PermissionRisk;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface PermissionMeta {
|
|
32
|
+
label: string;
|
|
33
|
+
risk: PermissionRisk;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Permission opencode -> label & risiko.
|
|
38
|
+
* - high : bisa mengubah sistem / keluar dari batas project.
|
|
39
|
+
* - medium : mengubah file project / menjalankan agent lain / akses jaringan.
|
|
40
|
+
* - low : hanya membaca.
|
|
41
|
+
*/
|
|
42
|
+
const PERMISSIONS: Record<string, PermissionMeta> = {
|
|
43
|
+
bash: { label: "Run a shell command", risk: "high" },
|
|
44
|
+
external_directory: { label: "Access a folder outside the project", risk: "high" },
|
|
45
|
+
doom_loop: { label: "Keep going after repeating the same step", risk: "medium" },
|
|
46
|
+
edit: { label: "Edit files", risk: "medium" },
|
|
47
|
+
task: { label: "Start a sub-agent", risk: "medium" },
|
|
48
|
+
webfetch: { label: "Fetch a web page", risk: "medium" },
|
|
49
|
+
websearch: { label: "Search the web", risk: "medium" },
|
|
50
|
+
skill: { label: "Use a skill", risk: "medium" },
|
|
51
|
+
read: { label: "Read files", risk: "low" },
|
|
52
|
+
list: { label: "List folder contents", risk: "low" },
|
|
53
|
+
glob: { label: "Search for files", risk: "low" },
|
|
54
|
+
grep: { label: "Search inside files", risk: "low" },
|
|
55
|
+
lsp: { label: "Use the language server", risk: "low" },
|
|
56
|
+
todowrite: { label: "Update the task list", risk: "low" },
|
|
57
|
+
question: { label: "Ask you a question", risk: "low" },
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Judul fallback server saat event tidak membawa nama permission. */
|
|
61
|
+
const FALLBACK_TITLES = new Set(["Izin tool", "Tool permission"]);
|
|
62
|
+
|
|
63
|
+
/** Ubah `snake_case` menjadi kalimat ("external_directory" -> "External directory"). */
|
|
64
|
+
function humanize(name: string): string {
|
|
65
|
+
const spaced = name.replace(/[_-]+/g, " ").trim();
|
|
66
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Pecah judul permission server menjadi info yang bisa dibaca manusia.
|
|
71
|
+
* Format masukan: `"<permission>"`, `"<permission> — `a` — `b`"`, atau
|
|
72
|
+
* judul fallback / null.
|
|
73
|
+
*/
|
|
74
|
+
export function parsePermissionTitle(title: string | null): PermissionInfo {
|
|
75
|
+
const raw = (title ?? "").trim();
|
|
76
|
+
if (raw === "" || FALLBACK_TITLES.has(raw)) {
|
|
77
|
+
return { permission: null, label: "Use a tool", targets: [], risk: "medium" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const [head = "", ...rest] = raw.split(" — ");
|
|
81
|
+
const permission = head.trim();
|
|
82
|
+
// Pola dibungkus backtick oleh server; buang backtick-nya. Bagian tanpa
|
|
83
|
+
// backtick tetap diterima (format lama / pihak lain).
|
|
84
|
+
const targets = rest
|
|
85
|
+
.map((part) =>
|
|
86
|
+
part
|
|
87
|
+
.trim()
|
|
88
|
+
.replace(/^`(.*)`$/s, "$1")
|
|
89
|
+
.trim(),
|
|
90
|
+
)
|
|
91
|
+
.filter((t) => t !== "");
|
|
92
|
+
|
|
93
|
+
const meta = PERMISSIONS[permission.toLowerCase()];
|
|
94
|
+
return {
|
|
95
|
+
permission,
|
|
96
|
+
label: meta?.label ?? humanize(permission),
|
|
97
|
+
targets,
|
|
98
|
+
// Permission tak dikenal: anggap sedang — jangan meremehkan risikonya.
|
|
99
|
+
risk: meta?.risk ?? "medium",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper tampilan panel samping halaman Project (MCP & skill) — logika murni
|
|
3
|
+
* tanpa DOM, dipisah agar dapat diuji `bun test`.
|
|
4
|
+
*/
|
|
5
|
+
import type {
|
|
6
|
+
McpServerInfo,
|
|
7
|
+
McpStatus,
|
|
8
|
+
SkillInfo,
|
|
9
|
+
SkillSource,
|
|
10
|
+
} from "@/server/services/opencode-client";
|
|
11
|
+
|
|
12
|
+
/** Label status MCP untuk UI. */
|
|
13
|
+
export const MCP_STATUS_LABEL: Record<McpStatus, string> = {
|
|
14
|
+
connected: "Connected",
|
|
15
|
+
disabled: "Disabled",
|
|
16
|
+
failed: "Failed",
|
|
17
|
+
needs_auth: "Needs auth",
|
|
18
|
+
needs_client_registration: "Needs registration",
|
|
19
|
+
unknown: "Unknown",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Warna titik status MCP (sepadan dengan titik status Session). */
|
|
23
|
+
export const MCP_STATUS_DOT: Record<McpStatus, string> = {
|
|
24
|
+
connected: "bg-emerald-500",
|
|
25
|
+
disabled: "bg-muted-foreground/40",
|
|
26
|
+
failed: "bg-destructive",
|
|
27
|
+
needs_auth: "bg-amber-500",
|
|
28
|
+
needs_client_registration: "bg-amber-500",
|
|
29
|
+
unknown: "bg-muted-foreground/40",
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Ringkasan singkat untuk header seksi MCP ("2 of 3 connected"). */
|
|
33
|
+
export function describeMcpSummary(servers: readonly McpServerInfo[]): string {
|
|
34
|
+
if (servers.length === 0) return "None configured";
|
|
35
|
+
const connected = servers.filter((s) => s.status === "connected").length;
|
|
36
|
+
return `${connected} of ${servers.length} connected`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Label asal skill. */
|
|
40
|
+
export const SKILL_SOURCE_LABEL: Record<SkillSource, string> = {
|
|
41
|
+
project: "This project",
|
|
42
|
+
global: "Global",
|
|
43
|
+
builtin: "Built-in",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export interface SkillGroup {
|
|
47
|
+
source: SkillSource;
|
|
48
|
+
label: string;
|
|
49
|
+
skills: SkillInfo[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Kelompokkan skill per asal: Project dulu (paling relevan), lalu global,
|
|
54
|
+
* lalu bawaan. Kelompok kosong dibuang; urutan dalam kelompok dipertahankan.
|
|
55
|
+
*/
|
|
56
|
+
export function groupSkills(skills: readonly SkillInfo[]): SkillGroup[] {
|
|
57
|
+
const order: SkillSource[] = ["project", "global", "builtin"];
|
|
58
|
+
return order
|
|
59
|
+
.map((source) => ({
|
|
60
|
+
source,
|
|
61
|
+
label: SKILL_SOURCE_LABEL[source],
|
|
62
|
+
skills: skills.filter((s) => s.source === source),
|
|
63
|
+
}))
|
|
64
|
+
.filter((g) => g.skills.length > 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Filter skill berdasarkan kata kunci (nama atau deskripsi, case-insensitive). */
|
|
68
|
+
export function filterSkills(skills: readonly SkillInfo[], query: string): SkillInfo[] {
|
|
69
|
+
const q = query.trim().toLowerCase();
|
|
70
|
+
if (q === "") return [...skills];
|
|
71
|
+
return skills.filter(
|
|
72
|
+
(s) => s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ringkasan Project untuk homepage — logika murni (tanpa DOM / fetch).
|
|
3
|
+
*
|
|
4
|
+
* Homepage (`pages/projects/ProjectsPage.tsx`) menggabungkan `GET /api/projects`
|
|
5
|
+
* dengan `GET /api/sessions` agar setiap Project bisa langsung "dilanjutkan":
|
|
6
|
+
* jumlah Session, Session yang sedang berjalan, dan Session terakhir yang
|
|
7
|
+
* disentuh. Dipisah ke `lib/` supaya bisa diuji `bun test` (pola yang sama
|
|
8
|
+
* dengan `lib/routes.ts`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Project, Session } from "@/types";
|
|
12
|
+
|
|
13
|
+
/** Project + agregat Session miliknya, siap dirender di daftar homepage. */
|
|
14
|
+
export interface ProjectOverview {
|
|
15
|
+
project: Project;
|
|
16
|
+
/** Jumlah Session milik Project ini. */
|
|
17
|
+
sessionCount: number;
|
|
18
|
+
/** Jumlah Session berstatus `running`. */
|
|
19
|
+
runningCount: number;
|
|
20
|
+
/**
|
|
21
|
+
* Session yang paling baru diperbarui (`updatedAt` terbesar) — target tombol
|
|
22
|
+
* "Lanjutkan"; `null` bila Project belum punya Session.
|
|
23
|
+
*/
|
|
24
|
+
lastSession: Session | null;
|
|
25
|
+
/**
|
|
26
|
+
* Waktu aktivitas terakhir: `updatedAt` Session terbaru, atau `createdAt`
|
|
27
|
+
* Project bila belum ada Session. Dipakai untuk urutan daftar.
|
|
28
|
+
*/
|
|
29
|
+
lastActivityAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Gabungkan Project dengan Session-nya, terurut dari aktivitas terbaru.
|
|
34
|
+
* Session dengan `projectId` yang tidak dikenal diabaikan.
|
|
35
|
+
*/
|
|
36
|
+
export function buildProjectOverviews(
|
|
37
|
+
projects: readonly Project[],
|
|
38
|
+
sessions: readonly Session[],
|
|
39
|
+
): ProjectOverview[] {
|
|
40
|
+
const byProject = new Map<string, Session[]>();
|
|
41
|
+
for (const session of sessions) {
|
|
42
|
+
const bucket = byProject.get(session.projectId);
|
|
43
|
+
if (bucket === undefined) byProject.set(session.projectId, [session]);
|
|
44
|
+
else bucket.push(session);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const overviews = projects.map((project) => {
|
|
48
|
+
const own = byProject.get(project.id) ?? [];
|
|
49
|
+
let lastSession: Session | null = null;
|
|
50
|
+
let runningCount = 0;
|
|
51
|
+
for (const session of own) {
|
|
52
|
+
if (session.status === "running") runningCount += 1;
|
|
53
|
+
if (lastSession === null || session.updatedAt > lastSession.updatedAt) {
|
|
54
|
+
lastSession = session;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
project,
|
|
59
|
+
sessionCount: own.length,
|
|
60
|
+
runningCount,
|
|
61
|
+
lastSession,
|
|
62
|
+
lastActivityAt: lastSession?.updatedAt ?? project.createdAt,
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Aktivitas terbaru di atas; nama (case-insensitive) sebagai tie-break agar
|
|
67
|
+
// urutan stabil dan tidak berubah-ubah antar refresh.
|
|
68
|
+
return overviews.sort((a, b) => {
|
|
69
|
+
if (b.lastActivityAt !== a.lastActivityAt) return b.lastActivityAt - a.lastActivityAt;
|
|
70
|
+
return a.project.name.localeCompare(b.project.name, undefined, { sensitivity: "base" });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Filter daftar berdasarkan kata kunci (cocok pada nama atau path, tanpa
|
|
76
|
+
* membedakan huruf besar/kecil). Query kosong/whitespace -> daftar utuh.
|
|
77
|
+
*/
|
|
78
|
+
export function filterProjectOverviews(
|
|
79
|
+
overviews: readonly ProjectOverview[],
|
|
80
|
+
query: string,
|
|
81
|
+
): ProjectOverview[] {
|
|
82
|
+
const needle = query.trim().toLowerCase();
|
|
83
|
+
if (needle === "") return [...overviews];
|
|
84
|
+
return overviews.filter(
|
|
85
|
+
({ project }) =>
|
|
86
|
+
project.name.toLowerCase().includes(needle) || project.path.toLowerCase().includes(needle),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Total Session yang sedang berjalan di seluruh Project. */
|
|
91
|
+
export function totalRunningSessions(overviews: readonly ProjectOverview[]): number {
|
|
92
|
+
return overviews.reduce((sum, o) => sum + o.runningCount, 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Short relative label for activity time ("just now", "3 hours ago").
|
|
97
|
+
* `now` diinjeksi agar deterministik saat diuji.
|
|
98
|
+
*/
|
|
99
|
+
export function formatRelativeTime(ts: number, now: number = Date.now()): string {
|
|
100
|
+
const diff = now - ts;
|
|
101
|
+
if (diff < 0) return "just now";
|
|
102
|
+
|
|
103
|
+
const minute = 60_000;
|
|
104
|
+
const hour = 60 * minute;
|
|
105
|
+
const day = 24 * hour;
|
|
106
|
+
|
|
107
|
+
if (diff < minute) return "just now";
|
|
108
|
+
if (diff < hour) return `${Math.floor(diff / minute)} minutes ago`;
|
|
109
|
+
if (diff < day) return `${Math.floor(diff / hour)} hours ago`;
|
|
110
|
+
if (diff < 7 * day) return `${Math.floor(diff / day)} days ago`;
|
|
111
|
+
|
|
112
|
+
return new Date(ts).toLocaleDateString(undefined, {
|
|
113
|
+
day: "2-digit",
|
|
114
|
+
month: "short",
|
|
115
|
+
year: "numeric",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive_Prompt — logika murni (tanpa DOM).
|
|
3
|
+
*
|
|
4
|
+
* Dipisah dari `PromptCard.tsx` agar dapat diuji `bun test` (unit test 24.6):
|
|
5
|
+
* - `getPromptActions`: aksi yang dirender untuk sebuah prompt.
|
|
6
|
+
* - `groupPrompts`: beberapa permission identik (kind + title sama) menjadi
|
|
7
|
+
* SATU kartu (bug kartu nyepam).
|
|
8
|
+
*/
|
|
9
|
+
import type { InteractivePrompt, PromptQuestion, PromptResponse } from "@/types";
|
|
10
|
+
|
|
11
|
+
export type PromptActionVariant = "default" | "destructive" | "outline";
|
|
12
|
+
|
|
13
|
+
export interface PromptAction {
|
|
14
|
+
key: string;
|
|
15
|
+
label: string;
|
|
16
|
+
response: PromptResponse;
|
|
17
|
+
variant: PromptActionVariant;
|
|
18
|
+
/** Ikon khusus (mis. "shield-check" untuk Always allow). */
|
|
19
|
+
icon?: "shield-check";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Aksi yang dirender untuk sebuah Interactive_Prompt (murni — diuji di 24.6).
|
|
24
|
+
* Untuk `confirmation`: Approve, Always allow, Deny, dan Cancel; Cancel
|
|
25
|
+
* memakai respon yang sama dengan Deny (Requirement 8.5).
|
|
26
|
+
*
|
|
27
|
+
* Menu MULTI-SELECT tidak memakai aksi per opsi — jawaban dikumpulkan lewat
|
|
28
|
+
* checkbox lalu dikirim sekaligus `{ options: string[] }` (lihat PromptCard).
|
|
29
|
+
*/
|
|
30
|
+
export function getPromptActions(prompt: InteractivePrompt): PromptAction[] {
|
|
31
|
+
if (prompt.type === "menu") {
|
|
32
|
+
return (prompt.options ?? []).map((option) => ({
|
|
33
|
+
key: `option-${option.label}`,
|
|
34
|
+
label: option.label,
|
|
35
|
+
response: { option: option.label },
|
|
36
|
+
variant: "outline",
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
return [
|
|
40
|
+
{ key: "approve", label: "Approve", response: "approve", variant: "default" },
|
|
41
|
+
{
|
|
42
|
+
key: "always",
|
|
43
|
+
label: "Always allow",
|
|
44
|
+
response: "always",
|
|
45
|
+
variant: "outline",
|
|
46
|
+
icon: "shield-check",
|
|
47
|
+
},
|
|
48
|
+
{ key: "deny", label: "Deny", response: "deny", variant: "destructive" },
|
|
49
|
+
// Requirement 8.5: Cancel diperlakukan sebagai respon Deny yang sama.
|
|
50
|
+
{ key: "cancel", label: "Cancel", response: "deny", variant: "outline" },
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Daftar pertanyaan yang dirender kartu — multi-question memakai field
|
|
56
|
+
* `questions`; pertanyaan tunggal diwrap dari field legacy.
|
|
57
|
+
* Murni (diuji tanpa DOM) agar konsisten dengan helper lain di file ini.
|
|
58
|
+
*/
|
|
59
|
+
export function getPromptQuestions(prompt: InteractivePrompt): PromptQuestion[] {
|
|
60
|
+
if (prompt.questions && prompt.questions.length > 0) return prompt.questions;
|
|
61
|
+
if (prompt.kind !== "question") return [];
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
question: prompt.title,
|
|
65
|
+
options: prompt.options ?? null,
|
|
66
|
+
multiple: prompt.multiple === true ? true : undefined,
|
|
67
|
+
custom: prompt.custom === true ? true : undefined,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Kelompokkan prompt pending yang identik (kind + title sama) menjadi satu
|
|
74
|
+
* kartu. Kunci sama dengan sisi server (`permissionGroupKey`) agar kartu yang
|
|
75
|
+
* dijawab user persis grup yang di-fan-out server. Urutan kemunculan
|
|
76
|
+
* anggota pertama dipertahankan.
|
|
77
|
+
*/
|
|
78
|
+
export function groupPrompts(prompts: readonly InteractivePrompt[]): InteractivePrompt[][] {
|
|
79
|
+
const groups = new Map<string, InteractivePrompt[]>();
|
|
80
|
+
for (const p of prompts) {
|
|
81
|
+
const key = `${p.kind}\u0000${p.title ?? ""}`;
|
|
82
|
+
const g = groups.get(key);
|
|
83
|
+
if (g) g.push(p);
|
|
84
|
+
else groups.set(key, [p]);
|
|
85
|
+
}
|
|
86
|
+
return [...groups.values()];
|
|
87
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daftar "Reference": file project yang DIBACA agent untuk menjawab.
|
|
3
|
+
*
|
|
4
|
+
* Diturunkan dari parts pesan yang SUDAH dimiliki timeline (tidak ada request
|
|
5
|
+
* tambahan): tiap pembacaan file dikumpulkan tanpa duplikat, TERBARU DI ATAS.
|
|
6
|
+
*
|
|
7
|
+
* "Terbaru" = pembacaan terakhir, bukan pertama: file yang dibaca lagi naik
|
|
8
|
+
* kembali ke atas. Jadi bagian atas daftar selalu mencerminkan apa yang
|
|
9
|
+
* sedang dikerjakan agent saat ini.
|
|
10
|
+
*
|
|
11
|
+
* Yang dihitung "dibaca":
|
|
12
|
+
* - Tool `read` dengan target satu file.
|
|
13
|
+
* - Referensi `@file` dari pengguna (part `file` TANPA `attachmentId`) — isi
|
|
14
|
+
* file itu dibaca untuk menjawab.
|
|
15
|
+
*
|
|
16
|
+
* Yang TIDAK dihitung:
|
|
17
|
+
* - Tool penulis (`write`, `edit`, `patch`, …). Reference menjawab "konteks
|
|
18
|
+
* apa yang dipakai agent", bukan "apa yang diubah". File yang dibaca lalu
|
|
19
|
+
* diubah tetap muncul — karena ia DIBACA; file yang hanya ditulis tidak.
|
|
20
|
+
* - `bash`, `grep`, `glob`, `list`, `webfetch`, dll — targetnya pola/perintah,
|
|
21
|
+
* bukan satu file.
|
|
22
|
+
* - Lampiran upload pengguna (part `file` dengan `attachmentId`): sudah tampil
|
|
23
|
+
* di bubble pesannya sendiri dan bukan file project.
|
|
24
|
+
* - Path di luar project, direktori (berakhiran `/`), dan pola glob.
|
|
25
|
+
*/
|
|
26
|
+
import type { MessagePart, SessionMessage } from "@/types";
|
|
27
|
+
import { toolTarget } from "./turns";
|
|
28
|
+
|
|
29
|
+
/** Tool yang membaca isi satu file. */
|
|
30
|
+
const READ_TOOLS = new Set(["read"]);
|
|
31
|
+
|
|
32
|
+
export interface FileReference {
|
|
33
|
+
/** Path relatif terhadap root project. */
|
|
34
|
+
path: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Nama file terakhir dari sebuah path (untuk tampilan ringkas). */
|
|
38
|
+
export function baseName(p: string): string {
|
|
39
|
+
return p.split("/").filter(Boolean).pop() ?? p;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Path tampak seperti pola glob / direktori, bukan satu file. */
|
|
43
|
+
function isPatternOrDir(p: string): boolean {
|
|
44
|
+
return p.endsWith("/") || /[*?{}[\]]/.test(p);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalisasi target tool menjadi path relatif project, atau null bila bukan
|
|
49
|
+
* file project (di luar `cwd`, glob, direktori, kosong).
|
|
50
|
+
*/
|
|
51
|
+
export function toProjectPath(target: string, cwd: string | null): string | null {
|
|
52
|
+
const raw = target.trim();
|
|
53
|
+
if (raw === "" || isPatternOrDir(raw)) return null;
|
|
54
|
+
|
|
55
|
+
let rel = raw;
|
|
56
|
+
if (raw.startsWith("/")) {
|
|
57
|
+
// Absolut: hanya diterima bila berada di dalam worktree project.
|
|
58
|
+
if (cwd === null || cwd === "") return null;
|
|
59
|
+
const base = cwd.endsWith("/") ? cwd : `${cwd}/`;
|
|
60
|
+
if (!raw.startsWith(base)) return null;
|
|
61
|
+
rel = raw.slice(base.length);
|
|
62
|
+
}
|
|
63
|
+
rel = rel.replace(/^\.\//, "");
|
|
64
|
+
// `..` keluar dari project -> bukan file project.
|
|
65
|
+
if (rel === "" || rel.startsWith("../")) return null;
|
|
66
|
+
return rel;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Kumpulkan file yang dibaca di seluruh pesan satu Session, terbaru di atas.
|
|
71
|
+
* `cwd` dipakai untuk mengubah path absolut menjadi relatif; null -> path
|
|
72
|
+
* absolut diabaikan.
|
|
73
|
+
*/
|
|
74
|
+
export function collectReferences(
|
|
75
|
+
messages: readonly SessionMessage[],
|
|
76
|
+
cwd: string | null = null,
|
|
77
|
+
): FileReference[] {
|
|
78
|
+
// Map mempertahankan urutan sisip: hapus lalu sisip ulang = pindahkan ke
|
|
79
|
+
// akhir. Setelah selesai, akhir Map = pembacaan paling baru -> dibalik.
|
|
80
|
+
const byRecency = new Map<string, FileReference>();
|
|
81
|
+
for (const message of messages) {
|
|
82
|
+
for (const part of message.parts) {
|
|
83
|
+
const path = readPath(part, cwd);
|
|
84
|
+
if (path === null) continue;
|
|
85
|
+
byRecency.delete(path);
|
|
86
|
+
byRecency.set(path, { path });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...byRecency.values()].reverse();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Path file project yang DIBACA oleh satu part, atau null. */
|
|
93
|
+
function readPath(part: MessagePart, cwd: string | null): string | null {
|
|
94
|
+
// Echo `@file` dari pengguna: part `file` tanpa `attachmentId` (lampiran
|
|
95
|
+
// upload selalu punya `attachmentId` dan bukan file project).
|
|
96
|
+
if (part.type === "file") {
|
|
97
|
+
if (typeof part.attachmentId === "string") return null;
|
|
98
|
+
const name = typeof part.filename === "string" ? part.filename : "";
|
|
99
|
+
return toProjectPath(name, cwd);
|
|
100
|
+
}
|
|
101
|
+
if (part.type !== "tool") return null;
|
|
102
|
+
const tool = typeof part.tool === "string" ? part.tool.toLowerCase() : "";
|
|
103
|
+
if (!READ_TOOLS.has(tool)) return null;
|
|
104
|
+
const target = toolTarget(part);
|
|
105
|
+
return target === null ? null : toProjectPath(target, cwd);
|
|
106
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route URL KCG Code — logika murni (tanpa DOM / History API).
|
|
3
|
+
*
|
|
4
|
+
* Dipisah dari `hooks/useRouter.ts` agar dapat diuji dengan `bun test`
|
|
5
|
+
* (pola yang sama dengan `useTheme.ts`). Server (`src/server/app.ts`)
|
|
6
|
+
* menyajikan shell SPA hanya untuk rute halaman yang terdaftar di
|
|
7
|
+
* `src/index.ts` (lihat `spaPaths`), sehingga React tinggal memetakan
|
|
8
|
+
* `location.pathname` -> view lewat `parseRoute()`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Route hasil parsing `location.pathname`. */
|
|
12
|
+
export type AppRoute =
|
|
13
|
+
| { name: "projects" }
|
|
14
|
+
| { name: "project"; projectId: string }
|
|
15
|
+
| { name: "session"; projectId: string; sessionId: string }
|
|
16
|
+
| { name: "skills" }
|
|
17
|
+
| { name: "settings"; section: SettingsSection; anchor?: string }
|
|
18
|
+
| { name: "not-found" };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sub-halaman Settings (`/settings/:section`). Tab yang tampil hanya
|
|
22
|
+
* profile/security/workspace/remote; `about` dibuka dari ikon di judul.
|
|
23
|
+
*/
|
|
24
|
+
export const SETTINGS_SECTIONS = ["profile", "security", "workspace", "remote", "about"] as const;
|
|
25
|
+
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Alias URL lama -> section + anchor. `devices` kini bagian dari Security
|
|
29
|
+
* (grup "Signed-in devices"); URL lamanya tetap berfungsi.
|
|
30
|
+
*/
|
|
31
|
+
const SETTINGS_ALIASES: Record<string, { section: SettingsSection; anchor: string }> = {
|
|
32
|
+
devices: { section: "security", anchor: "devices" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function isSettingsSection(value: string): value is SettingsSection {
|
|
36
|
+
return (SETTINGS_SECTIONS as readonly string[]).includes(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** URL daftar Project. */
|
|
40
|
+
export function projectsPath(): string {
|
|
41
|
+
return "/";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** URL daftar Session milik sebuah Project. */
|
|
45
|
+
export function projectPath(projectId: string): string {
|
|
46
|
+
return `/projects/${projectId}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** URL Session view milik sebuah Project. */
|
|
50
|
+
export function sessionPath(projectId: string, sessionId: string): string {
|
|
51
|
+
return `/projects/${projectId}/sessions/${sessionId}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* URL halaman Skills. `projectId` (opsional) jadi query `?project=` agar
|
|
56
|
+
* Project tersebut otomatis terpilih saat halaman dibuka.
|
|
57
|
+
*/
|
|
58
|
+
export function skillsPath(projectId?: string | null): string {
|
|
59
|
+
return projectId ? `/skills?project=${encodeURIComponent(projectId)}` : "/skills";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** URL halaman Settings; tanpa `section` = `/settings` (dibuka sebagai Profile). */
|
|
63
|
+
export function settingsPath(section?: SettingsSection): string {
|
|
64
|
+
return section ? `/settings/${section}` : "/settings";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Ambil id Project dari query string halaman Skills (`?project=`). */
|
|
68
|
+
export function parseSkillsProject(search: string): string | null {
|
|
69
|
+
const id = new URLSearchParams(search).get("project");
|
|
70
|
+
return id !== null && id.length > 0 ? id : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pemetaan `pathname` -> `AppRoute`. Segment id dianggap opaque — id
|
|
75
|
+
* Project/Session berupa UUID sehingga aman tanpa decoding. Trailing slash
|
|
76
|
+
* diabaikan (`/projects/x/` sama dengan `/projects/x`).
|
|
77
|
+
*/
|
|
78
|
+
export function parseRoute(pathname: string): AppRoute {
|
|
79
|
+
const segments = pathname
|
|
80
|
+
.replace(/\/+$/, "")
|
|
81
|
+
.split("/")
|
|
82
|
+
.filter((segment) => segment.length > 0);
|
|
83
|
+
|
|
84
|
+
if (segments.length === 0) return { name: "projects" };
|
|
85
|
+
if (segments[0] === "skills" && segments.length === 1) return { name: "skills" };
|
|
86
|
+
if (segments[0] === "settings") {
|
|
87
|
+
if (segments.length === 1) return { name: "settings", section: "profile" };
|
|
88
|
+
const section = segments[1];
|
|
89
|
+
if (segments.length === 2 && section !== undefined && isSettingsSection(section)) {
|
|
90
|
+
return { name: "settings", section };
|
|
91
|
+
}
|
|
92
|
+
// `Object.hasOwn`: hindari kunci prototipe (mis. "/settings/constructor").
|
|
93
|
+
const alias =
|
|
94
|
+
segments.length === 2 && section !== undefined && Object.hasOwn(SETTINGS_ALIASES, section)
|
|
95
|
+
? SETTINGS_ALIASES[section]
|
|
96
|
+
: undefined;
|
|
97
|
+
if (alias) return { name: "settings", section: alias.section, anchor: alias.anchor };
|
|
98
|
+
return { name: "not-found" };
|
|
99
|
+
}
|
|
100
|
+
if (segments[0] !== "projects") return { name: "not-found" };
|
|
101
|
+
|
|
102
|
+
const projectId = segments[1];
|
|
103
|
+
if (projectId === undefined || projectId.length === 0) return { name: "not-found" };
|
|
104
|
+
|
|
105
|
+
if (segments.length === 2) return { name: "project", projectId };
|
|
106
|
+
|
|
107
|
+
if (segments[2] === "sessions" && segments.length === 4) {
|
|
108
|
+
const sessionId = segments[3];
|
|
109
|
+
if (sessionId !== undefined && sessionId.length > 0) {
|
|
110
|
+
return { name: "session", projectId, sessionId };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { name: "not-found" };
|
|
115
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serah-terima homepage -> Session view agar perpindahan tanpa jeda:
|
|
3
|
+
* Session yang baru dibuat (`POST /api/sessions`) dititipkan di memori
|
|
4
|
+
* sehingga `SessionPage` bisa langsung merender tanpa `GET /api/sessions`
|
|
5
|
+
* (tidak ada kilatan "Loading…" di tengah animasi).
|
|
6
|
+
*
|
|
7
|
+
* Hanya di memori (per tab, per kunjungan): refresh halaman kembali ke jalur
|
|
8
|
+
* normal (fetch). Dibaca dengan `peek` (aman untuk StrictMode yang memanggil
|
|
9
|
+
* initializer dua kali) lalu dibersihkan oleh pemakai setelah mount.
|
|
10
|
+
*/
|
|
11
|
+
import type { Session } from "@/types";
|
|
12
|
+
|
|
13
|
+
const seeded = new Map<string, Session>();
|
|
14
|
+
|
|
15
|
+
export function seedSession(session: Session): void {
|
|
16
|
+
seeded.set(session.id, session);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function peekSeededSession(sessionId: string): Session | null {
|
|
20
|
+
return seeded.get(sessionId) ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function clearSeededSession(sessionId: string): void {
|
|
24
|
+
seeded.delete(sessionId);
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pemetaan status Session & koneksi WS ke label/visual UI (murni, tanpa DOM).
|
|
3
|
+
*
|
|
4
|
+
* Dipakai header chat (`SessionHeader`) dan daftar Session (`SessionList`),
|
|
5
|
+
* agar warna titik & teks status tidak didefinisikan ganda.
|
|
6
|
+
*/
|
|
7
|
+
import type { WsConnectionStatus } from "@/hooks/useWebSocket";
|
|
8
|
+
import type { SessionStatus } from "@/types";
|
|
9
|
+
|
|
10
|
+
/** Warna titik status Session (sepadan di header chat & baris daftar). */
|
|
11
|
+
export const SESSION_STATUS_DOT: Record<SessionStatus, string> = {
|
|
12
|
+
running: "bg-emerald-500",
|
|
13
|
+
stopped: "bg-muted-foreground/50",
|
|
14
|
+
crashed: "bg-destructive",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Label teks status Session (dipakai badge di daftar Session). */
|
|
18
|
+
export const SESSION_STATUS_LABEL: Record<SessionStatus, string> = {
|
|
19
|
+
running: "Running",
|
|
20
|
+
stopped: "Stopped",
|
|
21
|
+
crashed: "Crashed",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Variant Badge shadcn untuk sebuah status Session. */
|
|
25
|
+
export function sessionStatusVariant(
|
|
26
|
+
status: SessionStatus,
|
|
27
|
+
): "default" | "secondary" | "destructive" {
|
|
28
|
+
if (status === "running") return "default";
|
|
29
|
+
if (status === "crashed") return "destructive";
|
|
30
|
+
return "secondary";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Label status koneksi WS untuk UI (mis. subtitle header chat). */
|
|
34
|
+
export function wsStatusLabel(status: WsConnectionStatus): string {
|
|
35
|
+
switch (status) {
|
|
36
|
+
case "connected":
|
|
37
|
+
return "connected";
|
|
38
|
+
case "connecting":
|
|
39
|
+
return "connecting…";
|
|
40
|
+
case "reconnecting":
|
|
41
|
+
return "reconnecting…";
|
|
42
|
+
default:
|
|
43
|
+
return "disconnected";
|
|
44
|
+
}
|
|
45
|
+
}
|