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,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook WebSocket Client (task 22.3).
|
|
3
|
+
*
|
|
4
|
+
* Mengelola koneksi ke `/ws` server KCG Code:
|
|
5
|
+
* - `attach(sessionId)` membuka koneksi (bila perlu) dan mengirim pesan
|
|
6
|
+
* `attach` — setelah reconnect otomatis, `attach` dikirim ulang agar riwayat
|
|
7
|
+
* tersinkronisasi (Requirement 4.1).
|
|
8
|
+
* - Reconnect otomatis dengan jeda konfigurabel saat koneksi putus tak terduga;
|
|
9
|
+
* `disconnect()` menutup koneksi secara manual tanpa reconnect.
|
|
10
|
+
* - Pesan masuk (`history`, `output`, `prompt`, `prompt_resolved`,
|
|
11
|
+
* `session_status`, `error`) diteruskan ke `onMessage` sesuai `ws-protocol.ts`.
|
|
12
|
+
*/
|
|
13
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
14
|
+
import { markLocked, refreshAuth } from "@/lib/auth";
|
|
15
|
+
import type { ClientMessage, ServerMessage } from "../ws-protocol";
|
|
16
|
+
|
|
17
|
+
/** Kode close server: sesi login dicabut / terkunci (lihat `app.ts`). */
|
|
18
|
+
const WS_CLOSE_LOCKED = 4401;
|
|
19
|
+
|
|
20
|
+
export type WsConnectionStatus = "idle" | "connecting" | "connected" | "reconnecting" | "closed";
|
|
21
|
+
|
|
22
|
+
export interface UseWebSocketOptions {
|
|
23
|
+
/** URL WebSocket; default: `ws(s)://<host>/ws`. */
|
|
24
|
+
url?: string;
|
|
25
|
+
/** Dipanggil untuk setiap pesan Server -> Client. */
|
|
26
|
+
onMessage?: (msg: ServerMessage) => void;
|
|
27
|
+
/** Jeda reconnect setelah koneksi putus tak terduga (ms). */
|
|
28
|
+
reconnectDelayMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseWebSocketResult {
|
|
32
|
+
status: WsConnectionStatus;
|
|
33
|
+
/** Buka/reattach ke Session (kirim pesan `attach`). */
|
|
34
|
+
attach(sessionId: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Kirim pesan Client -> Server sesuai protokol ws-protocol.ts.
|
|
37
|
+
* `false` bila socket belum/tidak terbuka (pesan tidak terkirim).
|
|
38
|
+
*/
|
|
39
|
+
send(msg: ClientMessage): boolean;
|
|
40
|
+
/** Tutup koneksi secara manual (tanpa reconnect otomatis). */
|
|
41
|
+
disconnect(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function defaultWsUrl(): string | null {
|
|
45
|
+
if (typeof location === "undefined") return null;
|
|
46
|
+
const proto = location.protocol === "https:" ? "wss" : "ws";
|
|
47
|
+
return `${proto}://${location.host}/ws`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useWebSocket(opts: UseWebSocketOptions = {}): UseWebSocketResult {
|
|
51
|
+
const { onMessage, reconnectDelayMs = 1500, url } = opts;
|
|
52
|
+
const [status, setStatus] = useState<WsConnectionStatus>("idle");
|
|
53
|
+
|
|
54
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
55
|
+
const sessionRef = useRef<string | null>(null);
|
|
56
|
+
const manualRef = useRef(false);
|
|
57
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
58
|
+
/** Socket saat ini sempat terbuka (membedakan putus vs upgrade ditolak). */
|
|
59
|
+
const openedRef = useRef(false);
|
|
60
|
+
/** Kegagalan membuka koneksi beruntun. */
|
|
61
|
+
const failuresRef = useRef(0);
|
|
62
|
+
const onMessageRef = useRef(onMessage);
|
|
63
|
+
onMessageRef.current = onMessage;
|
|
64
|
+
|
|
65
|
+
const buildUrl = useCallback(() => url ?? defaultWsUrl(), [url]);
|
|
66
|
+
|
|
67
|
+
const open = useCallback(() => {
|
|
68
|
+
if (wsRef.current) return;
|
|
69
|
+
const target = buildUrl();
|
|
70
|
+
if (!target) return;
|
|
71
|
+
|
|
72
|
+
manualRef.current = false;
|
|
73
|
+
setStatus("connecting");
|
|
74
|
+
const ws = new WebSocket(target);
|
|
75
|
+
wsRef.current = ws;
|
|
76
|
+
|
|
77
|
+
// Handler socket lama diabaikan: `disconnect()` + `attach()` beruntun
|
|
78
|
+
// (mis. StrictMode / remount) membuat socket baru sebelum `close` socket
|
|
79
|
+
// lama tiba. Tanpa guard ini `onclose` lama menimpa `wsRef` socket baru
|
|
80
|
+
// dengan null sehingga `send()` diam-diam membuang pesan.
|
|
81
|
+
ws.onopen = () => {
|
|
82
|
+
if (wsRef.current !== ws) return;
|
|
83
|
+
openedRef.current = true;
|
|
84
|
+
failuresRef.current = 0;
|
|
85
|
+
setStatus("connected");
|
|
86
|
+
// Re-attach otomatis setelah reconnect (Requirement 4.1). `!== null`
|
|
87
|
+
// (bukan truthy) agar mode daftar (`attach("")`) ikut dikirim ulang.
|
|
88
|
+
const sid = sessionRef.current;
|
|
89
|
+
if (sid !== null)
|
|
90
|
+
ws.send(JSON.stringify({ type: "attach", sessionId: sid } satisfies ClientMessage));
|
|
91
|
+
};
|
|
92
|
+
ws.onmessage = (event) => {
|
|
93
|
+
if (wsRef.current !== ws) return;
|
|
94
|
+
try {
|
|
95
|
+
const msg = JSON.parse(String(event.data)) as ServerMessage;
|
|
96
|
+
onMessageRef.current?.(msg);
|
|
97
|
+
} catch {
|
|
98
|
+
// Abaikan frame yang bukan JSON valid.
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
ws.onerror = () => {
|
|
102
|
+
// Penanganan dilakukan di onclose.
|
|
103
|
+
};
|
|
104
|
+
ws.onclose = (event) => {
|
|
105
|
+
if (wsRef.current !== ws) return;
|
|
106
|
+
wsRef.current = null;
|
|
107
|
+
if (manualRef.current) {
|
|
108
|
+
setStatus("closed");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
// Server menutup karena sesi MILIK SOCKET INI berakhir. Itu belum tentu
|
|
112
|
+
// berarti perangkat terkunci: setelah ganti kunci, sesi lama dicabut
|
|
113
|
+
// tapi cookie sudah berisi sesi baru. Cek ke server dulu — terkunci ->
|
|
114
|
+
// lock screen; masih valid -> sambung ulang dengan cookie baru.
|
|
115
|
+
if (event.code === WS_CLOSE_LOCKED) {
|
|
116
|
+
setStatus("reconnecting");
|
|
117
|
+
void refreshAuth().then((st) => {
|
|
118
|
+
if (manualRef.current) return;
|
|
119
|
+
if (st?.protected && !st.authenticated) {
|
|
120
|
+
manualRef.current = true;
|
|
121
|
+
setStatus("closed");
|
|
122
|
+
markLocked();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
open();
|
|
126
|
+
});
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
// Upgrade ditolak (401) tampak sebagai putus biasa di browser; cek
|
|
130
|
+
// status kunci agar tidak reconnect tanpa akhir saat terkunci.
|
|
131
|
+
if (!openedRef.current) {
|
|
132
|
+
failuresRef.current += 1;
|
|
133
|
+
if (failuresRef.current >= 2) {
|
|
134
|
+
void refreshAuth().then((st) => {
|
|
135
|
+
if (st?.protected && !st.authenticated) {
|
|
136
|
+
manualRef.current = true;
|
|
137
|
+
setStatus("closed");
|
|
138
|
+
markLocked();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
openedRef.current = false;
|
|
144
|
+
setStatus("reconnecting");
|
|
145
|
+
timerRef.current = setTimeout(() => {
|
|
146
|
+
timerRef.current = null;
|
|
147
|
+
open();
|
|
148
|
+
}, reconnectDelayMs);
|
|
149
|
+
};
|
|
150
|
+
}, [buildUrl, reconnectDelayMs]);
|
|
151
|
+
|
|
152
|
+
const send = useCallback((msg: ClientMessage): boolean => {
|
|
153
|
+
const ws = wsRef.current;
|
|
154
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
|
155
|
+
ws.send(JSON.stringify(msg));
|
|
156
|
+
return true;
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
const attach = useCallback(
|
|
160
|
+
(sessionId: string) => {
|
|
161
|
+
sessionRef.current = sessionId;
|
|
162
|
+
if (!wsRef.current) {
|
|
163
|
+
open();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (wsRef.current.readyState === WebSocket.OPEN) {
|
|
167
|
+
wsRef.current.send(JSON.stringify({ type: "attach", sessionId } satisfies ClientMessage));
|
|
168
|
+
}
|
|
169
|
+
// Bila masih connecting, onopen akan mengirim attach otomatis.
|
|
170
|
+
},
|
|
171
|
+
[open],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const disconnect = useCallback(() => {
|
|
175
|
+
manualRef.current = true;
|
|
176
|
+
if (timerRef.current) {
|
|
177
|
+
clearTimeout(timerRef.current);
|
|
178
|
+
timerRef.current = null;
|
|
179
|
+
}
|
|
180
|
+
wsRef.current?.close();
|
|
181
|
+
wsRef.current = null;
|
|
182
|
+
setStatus("closed");
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
// Bersihkan koneksi & timer saat komponen unmount.
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
return () => {
|
|
188
|
+
manualRef.current = true;
|
|
189
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
190
|
+
wsRef.current?.close();
|
|
191
|
+
wsRef.current = null;
|
|
192
|
+
};
|
|
193
|
+
}, []);
|
|
194
|
+
|
|
195
|
+
return { status, attach, send, disconnect };
|
|
196
|
+
}
|
package/src/index.css
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
@import "../styles/globals.css";
|
|
2
|
+
|
|
3
|
+
@layer base {
|
|
4
|
+
:root {
|
|
5
|
+
@apply font-sans;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
html {
|
|
9
|
+
@apply overflow-x-hidden;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* Tanpa display: grid — auto track grid mengikuti max-content item
|
|
13
|
+
terlebar sehingga di layar HP seluruh halaman ikut selebar konten
|
|
14
|
+
terlebar lalu terpotong (overflow horizontal 414px -> 465px pada bug
|
|
15
|
+
#25.1). Shell app sudah h-dvh w-full, jadi body cukup block biasa. */
|
|
16
|
+
body {
|
|
17
|
+
@apply block relative m-0 min-h-screen min-w-[320px] bg-background text-foreground overflow-x-hidden;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@media (prefers-reduced-motion) {
|
|
22
|
+
*,
|
|
23
|
+
::before,
|
|
24
|
+
::after {
|
|
25
|
+
animation: none !important;
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.html
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="icon" type="image/svg+xml" href="./logo.svg" />
|
|
7
|
+
<meta name="theme-color" content="#0a0a0a" />
|
|
8
|
+
<title>KCG Code</title>
|
|
9
|
+
<script type="module" src="./frontend.tsx" async></script>
|
|
10
|
+
</head>
|
|
11
|
+
<body class="font-sans">
|
|
12
|
+
<div id="root"></div>
|
|
13
|
+
</body>
|
|
14
|
+
</html>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KCG Code — entry server dev (`bun dev` / `bun start`).
|
|
3
|
+
*
|
|
4
|
+
* Mode runtime default `dev`: config prioritaskan `./kcg-code.config.json`,
|
|
5
|
+
* data di `./data/`, HMR aktif. Binary global `kcgcode` memanggil
|
|
6
|
+
* `setRunMode("cli")` di `bin/kcgcode.ts` (data di `~/.kcgcode`).
|
|
7
|
+
*
|
|
8
|
+
* Wiring `Bun.serve` diimplementasikan di `src/server/app.ts`
|
|
9
|
+
* (`createKcgServer`) agar dapat diuji secara e2e (task 20.2). File ini:
|
|
10
|
+
* - mendaftarkan rute halaman yang menyajikan shell SPA (`index.html`)
|
|
11
|
+
* secara eksplisit — `spaPaths` di bawah, pola yang sama dengan daftar
|
|
12
|
+
* rute SPA di entry kcgcode; path lain di luar daftar mengembalikan 404
|
|
13
|
+
* (bukan catch-all seperti sebelumnya),
|
|
14
|
+
* - memulai server,
|
|
15
|
+
* - mendaftarkan handler shutdown SIGINT/SIGTERM yang menyimpan status
|
|
16
|
+
* Session `running` dalam anggaran 5 detik sebelum berhenti (Req 2.3).
|
|
17
|
+
*/
|
|
18
|
+
import index from "./index.html";
|
|
19
|
+
import { setRunMode } from "./runtime";
|
|
20
|
+
import { createKcgServer } from "./server/app";
|
|
21
|
+
|
|
22
|
+
// Entry ini = development di repo. (CLI global men-set "cli" di bin/.)
|
|
23
|
+
setRunMode("dev");
|
|
24
|
+
|
|
25
|
+
if (import.meta.main) {
|
|
26
|
+
const app = createKcgServer({
|
|
27
|
+
spa: index,
|
|
28
|
+
// Rute halaman SPA: `/`, `/projects/:projectId`, dan Session view
|
|
29
|
+
// (`/projects/:projectId/sessions/:sessionId` — tercakup wildcard).
|
|
30
|
+
spaPaths: ["/", "/projects", "/projects/*", "/skills", "/settings", "/settings/*"],
|
|
31
|
+
});
|
|
32
|
+
console.log(`🚀 KCG Code berjalan di ${app.server.url}`);
|
|
33
|
+
|
|
34
|
+
let shuttingDown = false;
|
|
35
|
+
async function shutdown(signal: string): Promise<void> {
|
|
36
|
+
if (shuttingDown) return;
|
|
37
|
+
shuttingDown = true;
|
|
38
|
+
console.log(`\n${signal} diterima — menyimpan status Session lalu menghentikan server...`);
|
|
39
|
+
try {
|
|
40
|
+
await app.close();
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error("Error saat shutdown:", err);
|
|
43
|
+
}
|
|
44
|
+
process.exit(0);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
48
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
49
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
attachmentKind,
|
|
4
|
+
fileExtension,
|
|
5
|
+
formatBytes,
|
|
6
|
+
modelFacingMime,
|
|
7
|
+
normalizeMime,
|
|
8
|
+
} from "../attachments";
|
|
9
|
+
|
|
10
|
+
describe("attachmentKind", () => {
|
|
11
|
+
test("gambar umum & PDF dikenali dari mime", () => {
|
|
12
|
+
expect(attachmentKind("image/png", "a.png")).toBe("image");
|
|
13
|
+
expect(attachmentKind("image/webp", "a.webp")).toBe("image");
|
|
14
|
+
expect(attachmentKind("application/pdf", "doc.pdf")).toBe("pdf");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("file kode dikenali dari ekstensi walau mime salah / kosong", () => {
|
|
18
|
+
expect(attachmentKind("video/mp2t", "main.ts")).toBe("text");
|
|
19
|
+
expect(attachmentKind("", "lib.rs")).toBe("text");
|
|
20
|
+
expect(attachmentKind("", "Dockerfile")).toBe("text");
|
|
21
|
+
expect(attachmentKind("application/json", "data")).toBe("text");
|
|
22
|
+
expect(attachmentKind("text/csv", "x.csv")).toBe("text");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("SVG diperlakukan teks, bukan gambar pratinjau", () => {
|
|
26
|
+
expect(attachmentKind("image/svg+xml", "logo.svg")).toBe("text");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("lainnya biner", () => {
|
|
30
|
+
expect(attachmentKind("application/zip", "a.zip")).toBe("binary");
|
|
31
|
+
expect(attachmentKind("image/heic", "p.heic")).toBe("binary");
|
|
32
|
+
expect(attachmentKind("", "blob")).toBe("binary");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("modelFacingMime", () => {
|
|
37
|
+
test("teks dikirim sebagai text/plain, biner tidak dikirim", () => {
|
|
38
|
+
expect(modelFacingMime("text/csv", "x.csv")).toBe("text/plain");
|
|
39
|
+
expect(modelFacingMime("image/png", "a.png")).toBe("image/png");
|
|
40
|
+
expect(modelFacingMime("application/pdf", "a.pdf")).toBe("application/pdf");
|
|
41
|
+
expect(modelFacingMime("application/zip", "a.zip")).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("normalizeMime menolak nilai yang bisa menyuntik header", () => {
|
|
46
|
+
expect(normalizeMime("Text/Plain")).toBe("text/plain");
|
|
47
|
+
expect(normalizeMime("")).toBe("application/octet-stream");
|
|
48
|
+
expect(normalizeMime("text/html\r\nx-evil: 1")).toBe("application/octet-stream");
|
|
49
|
+
expect(normalizeMime("text/plain; charset=utf-8")).toBe("application/octet-stream");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("fileExtension & formatBytes", () => {
|
|
53
|
+
expect(fileExtension("a/b/c.TAR.GZ")).toBe("gz");
|
|
54
|
+
expect(fileExtension(".env")).toBe("");
|
|
55
|
+
expect(formatBytes(512)).toBe("512 B");
|
|
56
|
+
expect(formatBytes(1536)).toBe("1.5 KB");
|
|
57
|
+
expect(formatBytes(20 * 1024 * 1024)).toBe("20 MB");
|
|
58
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Katalog avatar bawaan: setiap id preset (dipakai server) wajib punya
|
|
3
|
+
* pemetaan ikon di FE, dan sebaliknya.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { AVATAR_PRESETS } from "@/components/auth/avatar-icons";
|
|
7
|
+
import { AVATAR_PRESET_IDS, isAvatarPresetId } from "../avatar-presets";
|
|
8
|
+
|
|
9
|
+
describe("avatar presets", () => {
|
|
10
|
+
test("id server == pemetaan ikon FE", () => {
|
|
11
|
+
expect(Object.keys(AVATAR_PRESETS).sort()).toEqual([...AVATAR_PRESET_IDS].sort());
|
|
12
|
+
for (const id of AVATAR_PRESET_IDS) {
|
|
13
|
+
expect(typeof AVATAR_PRESETS[id].Icon).toBe("object");
|
|
14
|
+
expect(AVATAR_PRESETS[id].label.length).toBeGreaterThan(0);
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("isAvatarPresetId hanya menerima id katalog", () => {
|
|
19
|
+
expect(isAvatarPresetId("cat")).toBe(true);
|
|
20
|
+
expect(isAvatarPresetId("Cat")).toBe(false);
|
|
21
|
+
expect(isAvatarPresetId("../x")).toBe(false);
|
|
22
|
+
expect(isAvatarPresetId(1)).toBe(false);
|
|
23
|
+
expect(new Set(AVATAR_PRESET_IDS).size).toBe(AVATAR_PRESET_IDS.length);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Property test collapsible thinking block (task 24.8).
|
|
3
|
+
*
|
|
4
|
+
* // Feature: kcg-code, Property 25: Toggle collapsible thinking independen antar pesan
|
|
5
|
+
* // Validates: Requirements 8.3
|
|
6
|
+
*
|
|
7
|
+
* For any himpunan pesan dan sembarang urutan aksi toggle collapsible pada
|
|
8
|
+
* masing-masing pesan, status tampil/tersembunyi setiap pesan setelah seluruh
|
|
9
|
+
* aksi = hasil XOR paritas jumlah toggle yang diterapkan pada pesan itu
|
|
10
|
+
* sendiri terhadap nilai default `true` (expanded), dan tidak terpengaruh
|
|
11
|
+
* oleh toggle pada pesan lain.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, test } from "bun:test";
|
|
14
|
+
import fc from "fast-check";
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
extendCollapsed,
|
|
18
|
+
initialCollapsedState,
|
|
19
|
+
initialCollapsibleState,
|
|
20
|
+
isCollapsibleExpanded,
|
|
21
|
+
toggleCollapsible,
|
|
22
|
+
} from "@/lib/collapsible";
|
|
23
|
+
|
|
24
|
+
describe("collapsible-state — Property 25", () => {
|
|
25
|
+
// Feature: kcg-code, Property 25: Toggle collapsible thinking independen antar pesan
|
|
26
|
+
test("toggle independen antar pesan terhadap default expanded (Req 8.3)", () => {
|
|
27
|
+
fc.assert(
|
|
28
|
+
fc.property(
|
|
29
|
+
// Himpunan identitas pesan unik.
|
|
30
|
+
fc.uniqueArray(fc.string({ maxLength: 12 }), { maxLength: 10 }),
|
|
31
|
+
// Sembarang urutan aksi: indeks pesan yang di-toggle (boleh acak/berulang).
|
|
32
|
+
fc.array(fc.nat({ max: 30 }), { maxLength: 80 }),
|
|
33
|
+
(ids, idxActions) => {
|
|
34
|
+
let state = initialCollapsibleState(ids);
|
|
35
|
+
const toggles = new Map<string, number>();
|
|
36
|
+
|
|
37
|
+
for (const idx of idxActions) {
|
|
38
|
+
if (ids.length === 0) break;
|
|
39
|
+
const id = ids[idx % ids.length];
|
|
40
|
+
if (!id) continue;
|
|
41
|
+
state = toggleCollapsible(state, id);
|
|
42
|
+
toggles.set(id, (toggles.get(id) ?? 0) + 1);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Status akhir tiap pesan hanya bergantung pada paritas toggle
|
|
46
|
+
// milik pesan itu sendiri terhadap default true (Req 8.3,
|
|
47
|
+
// Property 25) — tidak terpengaruh pesan lain.
|
|
48
|
+
for (const id of ids) {
|
|
49
|
+
const k = toggles.get(id) ?? 0;
|
|
50
|
+
expect(isCollapsibleExpanded(state, id)).toBe(k % 2 === 0);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
),
|
|
54
|
+
{ numRuns: 100 },
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("pesan baru tanpa state eksplisit default expanded (Req 8.3)", () => {
|
|
59
|
+
const state = initialCollapsibleState(["a"]);
|
|
60
|
+
expect(isCollapsibleExpanded(state, "a")).toBe(true);
|
|
61
|
+
expect(isCollapsibleExpanded(state, "b")).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Reason: render Thinking sekarang default collapsed (dibatasi tinggi),
|
|
65
|
+
// dapat di-expand per part — perilaku baru dari sesi headless.
|
|
66
|
+
test("initialCollapsedState: seluruh reasoning default tertutup", () => {
|
|
67
|
+
const state = initialCollapsedState(["m1:0", "m1:1"]);
|
|
68
|
+
expect(isCollapsibleExpanded(state, "m1:0")).toBe(false);
|
|
69
|
+
expect(isCollapsibleExpanded(state, "m1:1")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("extendCollapsed: key baru tertutup, key yang sudah ada dipertahankan", () => {
|
|
73
|
+
let state = initialCollapsedState(["m1:0"]);
|
|
74
|
+
state = toggleCollapsible(state, "m1:0"); // user expand m1:0
|
|
75
|
+
expect(isCollapsibleExpanded(state, "m1:0")).toBe(true);
|
|
76
|
+
|
|
77
|
+
// Pesan baru datang -> reasoning-nya collapsed; m1:0 tetap expanded.
|
|
78
|
+
state = extendCollapsed(state, ["m2:0", "m2:1"]);
|
|
79
|
+
expect(isCollapsibleExpanded(state, "m1:0")).toBe(true);
|
|
80
|
+
expect(isCollapsibleExpanded(state, "m2:0")).toBe(false);
|
|
81
|
+
expect(isCollapsibleExpanded(state, "m2:1")).toBe(false);
|
|
82
|
+
expect(isCollapsibleExpanded(state, "m3:0")).toBe(true); // tak dikenal -> default
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("extendCollapsed: daftar kosong tidak mengubah state", () => {
|
|
86
|
+
const state = initialCollapsedState(["a"]);
|
|
87
|
+
expect(extendCollapsed(state, [])).toBe(state);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("urutan toggle sederhana membalik status pesan terkait saja", () => {
|
|
91
|
+
let state = initialCollapsibleState(["a", "b"]);
|
|
92
|
+
state = toggleCollapsible(state, "a");
|
|
93
|
+
expect(isCollapsibleExpanded(state, "a")).toBe(false);
|
|
94
|
+
expect(isCollapsibleExpanded(state, "b")).toBe(true);
|
|
95
|
+
|
|
96
|
+
state = toggleCollapsible(state, "b");
|
|
97
|
+
state = toggleCollapsible(state, "b");
|
|
98
|
+
expect(isCollapsibleExpanded(state, "b")).toBe(true);
|
|
99
|
+
expect(isCollapsibleExpanded(state, "a")).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit test placeholder composer (src/lib/composer.ts).
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import { composerPlaceholder, extractMentionedFiles, splitComposerMentions } from "../composer";
|
|
6
|
+
|
|
7
|
+
describe("composerPlaceholder", () => {
|
|
8
|
+
test("ada lampiran: ajakan singkat (tidak wrap di ruang yang menyempit)", () => {
|
|
9
|
+
for (const compact of [false, true]) {
|
|
10
|
+
expect(
|
|
11
|
+
composerPlaceholder({ busy: false, canInput: true, compact, hasAttachments: true }),
|
|
12
|
+
).toBe("Add a message…");
|
|
13
|
+
}
|
|
14
|
+
// Status sibuk / Session mati tetap lebih penting.
|
|
15
|
+
expect(
|
|
16
|
+
composerPlaceholder({ busy: true, canInput: false, compact: false, hasAttachments: true }),
|
|
17
|
+
).toBe("Model is responding…");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("wide screen: full hint when input is ready", () => {
|
|
21
|
+
expect(composerPlaceholder({ busy: false, canInput: true, compact: false })).toBe(
|
|
22
|
+
"Type a message… type @ for file references, or drop files here",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("narrow screen: short text to avoid truncation", () => {
|
|
27
|
+
expect(composerPlaceholder({ busy: false, canInput: true, compact: true })).toBe(
|
|
28
|
+
"Type a message…",
|
|
29
|
+
);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("model responding: status outranks the typing hint", () => {
|
|
33
|
+
expect(composerPlaceholder({ busy: true, canInput: false, compact: false })).toBe(
|
|
34
|
+
"Model is responding…",
|
|
35
|
+
);
|
|
36
|
+
expect(composerPlaceholder({ busy: true, canInput: false, compact: true })).toBe("Responding…");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("session down: same text at both sizes (already short)", () => {
|
|
40
|
+
expect(composerPlaceholder({ busy: false, canInput: false, compact: false })).toBe(
|
|
41
|
+
"Session is not active",
|
|
42
|
+
);
|
|
43
|
+
expect(composerPlaceholder({ busy: false, canInput: false, compact: true })).toBe(
|
|
44
|
+
"Session is not active",
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("busy is checked before canInput", () => {
|
|
49
|
+
// Unusual combination (busy + canInput) still reports the responding status.
|
|
50
|
+
expect(composerPlaceholder({ busy: true, canInput: true, compact: false })).toBe(
|
|
51
|
+
"Model is responding…",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("extractMentionedFiles", () => {
|
|
57
|
+
test("path yang pernah disarankan autocomplete ikut dikirim", () => {
|
|
58
|
+
const known = new Set(["src/index.ts"]);
|
|
59
|
+
expect(extractMentionedFiles("fix @src/index.ts", known)).toEqual(["src/index.ts"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("path memuat / ikut dikirim walau tidak pernah disarankan", () => {
|
|
63
|
+
expect(extractMentionedFiles("lihat @a/b/c.ts dulu", new Set())).toEqual(["a/b/c.ts"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("kata biasa dengan @ (mis. @user) TIDAK dikirim tanpa slash & tak dikenal", () => {
|
|
67
|
+
expect(extractMentionedFiles("halo @user apa kabar", new Set())).toEqual([]);
|
|
68
|
+
// Kecuali pernah disarankan autocomplete — baru dianggap file sungguhan.
|
|
69
|
+
expect(extractMentionedFiles("halo @user", new Set(["user"]))).toEqual(["user"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("urutan & duplikat kemunculan dipertahankan", () => {
|
|
73
|
+
const known = new Set(["a.ts"]);
|
|
74
|
+
expect(extractMentionedFiles("@a.ts lalu @a.ts lagi", known)).toEqual(["a.ts", "a.ts"]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("tanpa teks atau tanpa @ -> kosong", () => {
|
|
78
|
+
expect(extractMentionedFiles("", new Set())).toEqual([]);
|
|
79
|
+
expect(extractMentionedFiles("tidak ada mention", new Set(["x.ts"]))).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("splitComposerMentions", () => {
|
|
84
|
+
const known = new Set(["skills-lock.json"]);
|
|
85
|
+
const join = (segs: { text: string }[]) => segs.map((x) => x.text).join("");
|
|
86
|
+
|
|
87
|
+
test("memecah teks + mention yang dikenal", () => {
|
|
88
|
+
const text = "lalu apa yang ada pada file @skills-lock.json ya";
|
|
89
|
+
const segs = splitComposerMentions(text, known);
|
|
90
|
+
expect(segs).toEqual([
|
|
91
|
+
{ kind: "text", text: "lalu apa yang ada pada file " },
|
|
92
|
+
{ kind: "mention", text: "@skills-lock.json", path: "skills-lock.json" },
|
|
93
|
+
{ kind: "text", text: " ya" },
|
|
94
|
+
]);
|
|
95
|
+
expect(join(segs)).toBe(text);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("path dengan / dianggap file walau belum disarankan", () => {
|
|
99
|
+
const segs = splitComposerMentions("@src/App.tsx dan @src/lib/x.ts", new Set());
|
|
100
|
+
expect(segs.filter((x) => x.kind === "mention").map((x) => x.text)).toEqual([
|
|
101
|
+
"@src/App.tsx",
|
|
102
|
+
"@src/lib/x.ts",
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("@kata biasa & email tidak disorot", () => {
|
|
107
|
+
const text = "halo @budi, email saya a@b.com";
|
|
108
|
+
const segs = splitComposerMentions(text, known);
|
|
109
|
+
expect(segs.every((x) => x.kind === "text")).toBe(true);
|
|
110
|
+
expect(join(segs)).toBe(text);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("gabungan potongan selalu identik dengan input", () => {
|
|
114
|
+
for (const text of ["", "@", "@skills-lock.json", "\n@src/a.ts\n\nteks", " @x/y "]) {
|
|
115
|
+
expect(join(splitComposerMentions(text, known))).toBe(text);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { highlightCode, resolveLang } from "../highlight";
|
|
3
|
+
|
|
4
|
+
describe("resolveLang", () => {
|
|
5
|
+
test("nama kanonis, alias, prefix language-, huruf besar", () => {
|
|
6
|
+
expect(resolveLang("typescript")).toBe("typescript");
|
|
7
|
+
expect(resolveLang("ts")).toBe("typescript");
|
|
8
|
+
expect(resolveLang("language-tsx")).toBe("tsx");
|
|
9
|
+
expect(resolveLang("Bash")).toBe("shellscript");
|
|
10
|
+
expect(resolveLang("yml")).toBe("yaml");
|
|
11
|
+
expect(resolveLang("Dockerfile")).toBe("docker");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("tak dikenal / kosong -> null (teks polos)", () => {
|
|
15
|
+
expect(resolveLang("brainfuck")).toBeNull();
|
|
16
|
+
expect(resolveLang("")).toBeNull();
|
|
17
|
+
expect(resolveLang(null)).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("highlightCode", () => {
|
|
22
|
+
test("token menyatu kembali persis jadi kode asli", async () => {
|
|
23
|
+
for (const code of ["const a = 1;\nlet b = 2", "x\n", "a\n\n b\t c\n", ""]) {
|
|
24
|
+
const lines = await highlightCode(code, "typescript");
|
|
25
|
+
expect(lines).not.toBeNull();
|
|
26
|
+
const joined = (lines ?? []).map((l) => l.map((t) => t.content).join("")).join("\n");
|
|
27
|
+
expect(joined).toBe(code);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("token membawa warna light & dark sebagai CSS variable", async () => {
|
|
32
|
+
const lines = await highlightCode("const x = 1", "typescript");
|
|
33
|
+
const kw = lines?.[0]?.find((t) => t.content === "const");
|
|
34
|
+
expect(kw?.style["--shiki-light"]).toMatch(/^#[0-9a-f]{6,8}$/i);
|
|
35
|
+
expect(kw?.style["--shiki-dark"]).toMatch(/^#[0-9a-f]{6,8}$/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("bahasa lain dimuat lazy (python, bash)", async () => {
|
|
39
|
+
expect(await highlightCode("def f():\n return 1", "python")).not.toBeNull();
|
|
40
|
+
expect(await highlightCode("echo $HOME | wc -l", "shellscript")).not.toBeNull();
|
|
41
|
+
});
|
|
42
|
+
});
|