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,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ringkasan Session untuk halaman detail Project — logika murni (tanpa DOM /
|
|
3
|
+
* fetch), dipisah agar dapat diuji `bun test` seperti `lib/routes.ts` dan
|
|
4
|
+
* `lib/project-overview.ts`.
|
|
5
|
+
*
|
|
6
|
+
* Dipakai `components/sessions/SessionList.tsx`: mengurutkan Session sehingga
|
|
7
|
+
* yang paling relevan untuk dilanjutkan berada di atas, dan menyediakan
|
|
8
|
+
* ringkasan status untuk header Project.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Session, SessionStatus } from "@/types";
|
|
12
|
+
|
|
13
|
+
/** Ringkasan status seluruh Session milik satu Project. */
|
|
14
|
+
export interface SessionSummary {
|
|
15
|
+
total: number;
|
|
16
|
+
running: number;
|
|
17
|
+
stopped: number;
|
|
18
|
+
crashed: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Prioritas urutan status: yang berjalan paling relevan untuk dilanjutkan. */
|
|
22
|
+
const STATUS_RANK: Record<SessionStatus, number> = {
|
|
23
|
+
running: 0,
|
|
24
|
+
crashed: 1,
|
|
25
|
+
stopped: 2,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Urutkan Session: `running` lebih dulu, lalu `crashed` (perlu perhatian),
|
|
30
|
+
* lalu `stopped`; di dalam tiap kelompok yang terakhir diperbarui di atas.
|
|
31
|
+
* Input tidak dimutasi.
|
|
32
|
+
*/
|
|
33
|
+
export function sortSessions(sessions: readonly Session[]): Session[] {
|
|
34
|
+
return [...sessions].sort((a, b) => {
|
|
35
|
+
const rank = STATUS_RANK[a.status] - STATUS_RANK[b.status];
|
|
36
|
+
if (rank !== 0) return rank;
|
|
37
|
+
if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt;
|
|
38
|
+
return a.id.localeCompare(b.id);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Hitung jumlah Session per status. */
|
|
43
|
+
export function summarizeSessions(sessions: readonly Session[]): SessionSummary {
|
|
44
|
+
const summary: SessionSummary = { total: sessions.length, running: 0, stopped: 0, crashed: 0 };
|
|
45
|
+
for (const s of sessions) {
|
|
46
|
+
if (s.status === "running") summary.running += 1;
|
|
47
|
+
else if (s.status === "stopped") summary.stopped += 1;
|
|
48
|
+
else summary.crashed += 1;
|
|
49
|
+
}
|
|
50
|
+
return summary;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Kalimat ringkas status Project untuk header ("2 sessions · 1 running").
|
|
55
|
+
* Tanpa Session -> teks pengarah, bukan "0 sessions".
|
|
56
|
+
*/
|
|
57
|
+
export function describeSessionSummary(summary: SessionSummary): string {
|
|
58
|
+
if (summary.total === 0) return "No sessions yet";
|
|
59
|
+
const parts = [`${summary.total} session${summary.total === 1 ? "" : "s"}`];
|
|
60
|
+
if (summary.running > 0) parts.push(`${summary.running} running`);
|
|
61
|
+
if (summary.crashed > 0) parts.push(`${summary.crashed} crashed`);
|
|
62
|
+
return parts.join(" · ");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Label model Session untuk baris daftar. */
|
|
66
|
+
export function describeSessionModel(session: Session): string {
|
|
67
|
+
return session.model ? session.model.modelID : "default model";
|
|
68
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper judul Session — dipakai Client & Server agar placeholder
|
|
3
|
+
* konsisten diperlakukan sebagai "belum ada judul".
|
|
4
|
+
*
|
|
5
|
+
* Alur (sama seperti opencode):
|
|
6
|
+
* 1. Session dibuat di opencode TANPA judul -> opencode memberi placeholder
|
|
7
|
+
* `New session - <ISO>`. Hanya judul berpola itu yang memicu opencode
|
|
8
|
+
* meng-generate judul (agent `title`, setelah langkah pertama prompt
|
|
9
|
+
* pertama), lalu dikirim lewat SSE `session.updated`.
|
|
10
|
+
* 2. Sambil menunggu (biasanya beberapa detik), KCG Code menampilkan judul
|
|
11
|
+
* sementara dari baris pertama prompt (`deriveSessionTitle`).
|
|
12
|
+
* 3. Judul hasil opencode selalu menggantikan judul sementara.
|
|
13
|
+
*
|
|
14
|
+
* `SESSION_TITLE_PLACEHOLDERS` = judul lama yang dulu dikirim KCG Code saat
|
|
15
|
+
* membuat Session (opencode tidak pernah meng-generate judul untuk itu).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const SESSION_TITLE_PLACEHOLDERS = new Set<string>([
|
|
19
|
+
"KCG Code Session",
|
|
20
|
+
"KCG Code Session (resumed)",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Judul default opencode (`packages/opencode/src/session/session.ts`,
|
|
25
|
+
* `isDefaultTitle`): `New session - ` / `Child session - ` + ISO timestamp.
|
|
26
|
+
*/
|
|
27
|
+
const OPENCODE_DEFAULT_TITLE =
|
|
28
|
+
/^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
29
|
+
|
|
30
|
+
export function isOpencodeDefaultTitle(title: string): boolean {
|
|
31
|
+
return OPENCODE_DEFAULT_TITLE.test(title.trim());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function isPlaceholderTitle(title: string | null | undefined): boolean {
|
|
35
|
+
if (title === null || title === undefined) return true;
|
|
36
|
+
const t = title.trim();
|
|
37
|
+
if (t === "") return true;
|
|
38
|
+
return SESSION_TITLE_PLACEHOLDERS.has(t) || isOpencodeDefaultTitle(t);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Turunkan judul Session dari teks prompt pertama.
|
|
43
|
+
* Ambil baris pertama, rapikan spasi, potong di batas kata dengan elipsis.
|
|
44
|
+
* @returns judul siap simpan, atau null bila teks kosong.
|
|
45
|
+
*/
|
|
46
|
+
export function deriveSessionTitle(text: string, maxLength = 50): string | null {
|
|
47
|
+
const trimmed = text.trim();
|
|
48
|
+
if (trimmed === "") return null;
|
|
49
|
+
const firstLine = (trimmed.split("\n")[0] ?? "").trim();
|
|
50
|
+
const collapsed = firstLine.replace(/\s+/g, " ").trim();
|
|
51
|
+
if (collapsed === "") return null;
|
|
52
|
+
if (collapsed.length <= maxLength) return collapsed;
|
|
53
|
+
const slice = collapsed.slice(0, maxLength);
|
|
54
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
55
|
+
const cut = lastSpace > maxLength * 0.6 ? slice.slice(0, lastSpace) : slice;
|
|
56
|
+
return `${cut.trim()}…`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Judul untuk ditampilkan di daftar — placeholder difallback ke "New session". */
|
|
60
|
+
export function displaySessionTitle(title: string | null | undefined): string {
|
|
61
|
+
if (isPlaceholderTitle(title ?? null)) return "New session";
|
|
62
|
+
return (title as string).trim();
|
|
63
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pengelompokan Session per Project untuk sidebar aplikasi — logika murni
|
|
3
|
+
* (tanpa DOM / fetch), dipisah agar dapat diuji `bun test` seperti
|
|
4
|
+
* `lib/project-overview.ts` dan `lib/session-summary.ts`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { sortSessions } from "@/lib/session-summary";
|
|
8
|
+
import type { Project, Session } from "@/types";
|
|
9
|
+
|
|
10
|
+
/** Satu grup sidebar: Project + seluruh Session miliknya (terurut). */
|
|
11
|
+
export interface SidebarProjectGroup {
|
|
12
|
+
project: Project;
|
|
13
|
+
/** Running -> crashed -> stopped, terbaru di atas (`sortSessions`). */
|
|
14
|
+
sessions: Session[];
|
|
15
|
+
runningCount: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Kelompokkan Session per Project. Grup diurutkan dari aktivitas terbaru
|
|
20
|
+
* (Session `updatedAt` terbesar, atau `createdAt` Project bila kosong);
|
|
21
|
+
* nama sebagai tie-break agar urutan stabil. Session dengan `projectId`
|
|
22
|
+
* tak dikenal diabaikan. Input tidak dimutasi.
|
|
23
|
+
*/
|
|
24
|
+
export function groupSessionsByProject(
|
|
25
|
+
projects: readonly Project[],
|
|
26
|
+
sessions: readonly Session[],
|
|
27
|
+
): SidebarProjectGroup[] {
|
|
28
|
+
const byProject = new Map<string, Session[]>();
|
|
29
|
+
for (const session of sessions) {
|
|
30
|
+
const bucket = byProject.get(session.projectId);
|
|
31
|
+
if (bucket === undefined) byProject.set(session.projectId, [session]);
|
|
32
|
+
else bucket.push(session);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const groups = projects.map((project) => {
|
|
36
|
+
const own = byProject.get(project.id) ?? [];
|
|
37
|
+
let lastActivity = project.createdAt;
|
|
38
|
+
let runningCount = 0;
|
|
39
|
+
for (const s of own) {
|
|
40
|
+
if (s.status === "running") runningCount += 1;
|
|
41
|
+
if (s.updatedAt > lastActivity) lastActivity = s.updatedAt;
|
|
42
|
+
}
|
|
43
|
+
return { group: { project, sessions: sortSessions(own), runningCount }, lastActivity };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
return groups
|
|
47
|
+
.sort((a, b) => {
|
|
48
|
+
if (b.lastActivity !== a.lastActivity) return b.lastActivity - a.lastActivity;
|
|
49
|
+
return a.group.project.name.localeCompare(b.group.project.name, undefined, {
|
|
50
|
+
sensitivity: "base",
|
|
51
|
+
});
|
|
52
|
+
})
|
|
53
|
+
.map((g) => g.group);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Filter grup berdasarkan kata kunci: Project yang namanya cocok tampil
|
|
58
|
+
* utuh; selain itu hanya Session yang judulnya cocok yang dipertahankan.
|
|
59
|
+
* Grup tanpa kecocokan dibuang. Query kosong -> grup utuh.
|
|
60
|
+
*/
|
|
61
|
+
export function filterSidebarGroups(
|
|
62
|
+
groups: readonly SidebarProjectGroup[],
|
|
63
|
+
query: string,
|
|
64
|
+
titleOf: (session: Session) => string,
|
|
65
|
+
): SidebarProjectGroup[] {
|
|
66
|
+
const needle = query.trim().toLowerCase();
|
|
67
|
+
if (needle === "") return [...groups];
|
|
68
|
+
const out: SidebarProjectGroup[] = [];
|
|
69
|
+
for (const group of groups) {
|
|
70
|
+
if (group.project.name.toLowerCase().includes(needle)) {
|
|
71
|
+
out.push(group);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const sessions = group.sessions.filter((s) => titleOf(s).toLowerCase().includes(needle));
|
|
75
|
+
if (sessions.length > 0) out.push({ ...group, sessions });
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store global job instalasi skill (FE) — sumber data notifikasi instalasi.
|
|
3
|
+
*
|
|
4
|
+
* - `startInstall()` memanggil `POST /api/projects/:id/skills/install` (202)
|
|
5
|
+
* lalu mem-poll `GET /api/skills/installs/:id?from=<n>` sampai selesai;
|
|
6
|
+
* baris log baru ditambahkan bertahap (output "live").
|
|
7
|
+
* - Store hidup di modul (bukan komponen) sehingga instalasi & notifikasinya
|
|
8
|
+
* tetap berjalan saat user pindah halaman atau menutup dialog.
|
|
9
|
+
* - `hydrateInstalls()` memulihkan notifikasi setelah reload: job berjalan
|
|
10
|
+
* DAN job selesai yang belum ditutup (server menyimpannya ~10 menit), lengkap
|
|
11
|
+
* dengan log. Status tampilan (disembunyikan / panel output terbuka) disimpan
|
|
12
|
+
* di localStorage sehingga kembali persis seperti sebelum refresh.
|
|
13
|
+
* - Selesai sukses -> `onInstallFinished` (mis. halaman Skills memuat ulang
|
|
14
|
+
* daftar terpasang).
|
|
15
|
+
* - Sukses tapi belum aktif (`refreshed: false`, ada chat berjalan) -> polling
|
|
16
|
+
* pelan berlanjut sampai server mengaktifkannya otomatis setelah chat selesai.
|
|
17
|
+
*
|
|
18
|
+
* `useSyncExternalStore` membaca snapshot; setiap perubahan membuat objek
|
|
19
|
+
* baru (immutable) agar React me-render ulang.
|
|
20
|
+
*/
|
|
21
|
+
import { useSyncExternalStore } from "react";
|
|
22
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
23
|
+
import type { InstallJob, InstallJobLog } from "@/server/services/skill-install-jobs";
|
|
24
|
+
|
|
25
|
+
/** Interval polling saat job berjalan. */
|
|
26
|
+
const POLL_MS = 700;
|
|
27
|
+
/**
|
|
28
|
+
* Interval polling job sukses yang skill-nya belum aktif (menunggu chat di
|
|
29
|
+
* Project selesai lalu agent memuat ulang skill otomatis).
|
|
30
|
+
*/
|
|
31
|
+
const PENDING_ACTIVATION_POLL_MS = 3000;
|
|
32
|
+
/** Berhenti menunggu aktivasi setelah selama ini (chat sangat panjang). */
|
|
33
|
+
const PENDING_ACTIVATION_MAX_MS = 30 * 60_000;
|
|
34
|
+
/** Batas baris log yang disimpan di client (server juga membatasi). */
|
|
35
|
+
const MAX_CLIENT_LINES = 500;
|
|
36
|
+
|
|
37
|
+
export interface InstallEntry {
|
|
38
|
+
job: InstallJob;
|
|
39
|
+
lines: string[];
|
|
40
|
+
/** Indeks absolut baris berikutnya yang diminta ke server. */
|
|
41
|
+
next: number;
|
|
42
|
+
/** Sebagian baris awal sudah terbuang di server. */
|
|
43
|
+
truncated: boolean;
|
|
44
|
+
/** User menyembunyikan notifikasi ini. */
|
|
45
|
+
dismissed: boolean;
|
|
46
|
+
/** Panel log dibuka. */
|
|
47
|
+
expanded: boolean;
|
|
48
|
+
/** Pesan error jaringan saat polling (job mungkin tetap berjalan). */
|
|
49
|
+
pollError: string | null;
|
|
50
|
+
/** Permintaan pembatalan sudah dikirim (menunggu proses berhenti). */
|
|
51
|
+
cancelling: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Listener = () => void;
|
|
55
|
+
|
|
56
|
+
let entries: InstallEntry[] = [];
|
|
57
|
+
const listeners = new Set<Listener>();
|
|
58
|
+
const finishListeners = new Set<(job: InstallJob) => void>();
|
|
59
|
+
const polling = new Set<string>();
|
|
60
|
+
|
|
61
|
+
function emit(): void {
|
|
62
|
+
for (const l of listeners) l();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function update(jobId: string, fn: (e: InstallEntry) => InstallEntry): void {
|
|
66
|
+
let changed = false;
|
|
67
|
+
entries = entries.map((e) => {
|
|
68
|
+
if (e.job.id !== jobId) return e;
|
|
69
|
+
changed = true;
|
|
70
|
+
return fn(e);
|
|
71
|
+
});
|
|
72
|
+
if (changed) emit();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function upsert(entry: InstallEntry): void {
|
|
76
|
+
const exists = entries.some((e) => e.job.id === entry.job.id);
|
|
77
|
+
entries = exists
|
|
78
|
+
? entries.map((e) => (e.job.id === entry.job.id ? entry : e))
|
|
79
|
+
: [entry, ...entries];
|
|
80
|
+
emit();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function newEntry(job: InstallJob, expanded: boolean, dismissed = false): InstallEntry {
|
|
84
|
+
return {
|
|
85
|
+
job,
|
|
86
|
+
lines: [],
|
|
87
|
+
next: 0,
|
|
88
|
+
truncated: false,
|
|
89
|
+
dismissed,
|
|
90
|
+
expanded,
|
|
91
|
+
pollError: null,
|
|
92
|
+
cancelling: false,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ------------------------------------------------ status tampilan (persist) ----
|
|
97
|
+
|
|
98
|
+
const UI_STATE_KEY = "kcg-skill-installs-ui";
|
|
99
|
+
|
|
100
|
+
/** Status tampilan per job yang bertahan melewati refresh. */
|
|
101
|
+
export interface InstallUiState {
|
|
102
|
+
/** Job yang notifikasinya disembunyikan / ditutup user. */
|
|
103
|
+
dismissed: string[];
|
|
104
|
+
/** Job yang panel output-nya terbuka. */
|
|
105
|
+
expanded: string[];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function readUiState(): InstallUiState {
|
|
109
|
+
try {
|
|
110
|
+
const raw = localStorage.getItem(UI_STATE_KEY);
|
|
111
|
+
if (!raw) return { dismissed: [], expanded: [] };
|
|
112
|
+
const v = JSON.parse(raw) as Partial<InstallUiState>;
|
|
113
|
+
const ids = (x: unknown) => (Array.isArray(x) ? x.filter((i) => typeof i === "string") : []);
|
|
114
|
+
return { dismissed: ids(v.dismissed), expanded: ids(v.expanded) };
|
|
115
|
+
} catch {
|
|
116
|
+
return { dismissed: [], expanded: [] };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Job selesai yang ditutup tidak ada lagi di `entries`; ingat id-nya di sini. */
|
|
121
|
+
let closedIds = new Set<string>(readUiState().dismissed);
|
|
122
|
+
|
|
123
|
+
function saveUiState(): void {
|
|
124
|
+
const state: InstallUiState = {
|
|
125
|
+
dismissed: [
|
|
126
|
+
...new Set([...closedIds, ...entries.filter((e) => e.dismissed).map((e) => e.job.id)]),
|
|
127
|
+
],
|
|
128
|
+
expanded: entries.filter((e) => e.expanded).map((e) => e.job.id),
|
|
129
|
+
};
|
|
130
|
+
try {
|
|
131
|
+
localStorage.setItem(UI_STATE_KEY, JSON.stringify(state));
|
|
132
|
+
} catch {
|
|
133
|
+
// Storage tidak tersedia (mode privat) — status hanya di memori.
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Entry yang dipulihkan dari daftar job server (murni — diuji). Job berjalan
|
|
139
|
+
* selalu dipulihkan (walau disembunyikan, agar kartu tetap tahu statusnya);
|
|
140
|
+
* job selesai hanya bila belum ditutup. Urutan: terbaru dulu.
|
|
141
|
+
*/
|
|
142
|
+
export function hydrationEntries(
|
|
143
|
+
jobs: readonly InstallJob[],
|
|
144
|
+
ui: InstallUiState,
|
|
145
|
+
existingIds: ReadonlySet<string>,
|
|
146
|
+
): InstallEntry[] {
|
|
147
|
+
const dismissed = new Set(ui.dismissed);
|
|
148
|
+
const expanded = new Set(ui.expanded);
|
|
149
|
+
return jobs
|
|
150
|
+
.filter((job) => !existingIds.has(job.id))
|
|
151
|
+
.filter((job) => job.status === "running" || !dismissed.has(job.id))
|
|
152
|
+
.map((job) => newEntry(job, expanded.has(job.id), dismissed.has(job.id)));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Gabungkan potongan log baru ke entry (murni — diuji). */
|
|
156
|
+
export function mergeLog(entry: InstallEntry, log: InstallJobLog): InstallEntry {
|
|
157
|
+
// Hanya ambil baris yang belum kita punya (polling bisa tumpang tindih).
|
|
158
|
+
const skip = Math.max(0, entry.next - log.from);
|
|
159
|
+
const fresh = log.lines.slice(skip);
|
|
160
|
+
let lines = fresh.length > 0 ? [...entry.lines, ...fresh] : entry.lines;
|
|
161
|
+
let truncated = entry.truncated || log.truncated;
|
|
162
|
+
if (lines.length > MAX_CLIENT_LINES) {
|
|
163
|
+
lines = lines.slice(lines.length - MAX_CLIENT_LINES);
|
|
164
|
+
truncated = true;
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
...entry,
|
|
168
|
+
job: log.job,
|
|
169
|
+
lines,
|
|
170
|
+
next: Math.max(entry.next, log.from + log.lines.length),
|
|
171
|
+
truncated,
|
|
172
|
+
pollError: null,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function poll(jobId: string): Promise<void> {
|
|
177
|
+
if (polling.has(jobId)) return;
|
|
178
|
+
polling.add(jobId);
|
|
179
|
+
try {
|
|
180
|
+
for (;;) {
|
|
181
|
+
const cur = entries.find((e) => e.job.id === jobId);
|
|
182
|
+
if (!cur) return;
|
|
183
|
+
try {
|
|
184
|
+
const res = await apiFetch(`/api/skills/installs/${jobId}?from=${cur.next}`);
|
|
185
|
+
const log = (await res.json()) as InstallJobLog;
|
|
186
|
+
const wasRunning = cur.job.status === "running";
|
|
187
|
+
update(jobId, (e) => mergeLog(e, log));
|
|
188
|
+
if (log.job.status !== "running") {
|
|
189
|
+
if (wasRunning) for (const l of finishListeners) l(log.job);
|
|
190
|
+
// Terpasang tapi belum aktif (ada chat berjalan) -> terus pantau
|
|
191
|
+
// pelan sampai server menandai aktif, agar notifikasi ikut berubah.
|
|
192
|
+
const waiting =
|
|
193
|
+
log.job.status === "succeeded" &&
|
|
194
|
+
log.job.refreshed === false &&
|
|
195
|
+
Date.now() - (log.job.finishedAt ?? Date.now()) < PENDING_ACTIVATION_MAX_MS;
|
|
196
|
+
if (!waiting) return;
|
|
197
|
+
await new Promise((r) => setTimeout(r, PENDING_ACTIVATION_POLL_MS));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
} catch (e) {
|
|
201
|
+
if (e instanceof ApiError && e.status === 404) {
|
|
202
|
+
// Job sudah sukses (sedang menunggu aktivasi) lalu kedaluwarsa /
|
|
203
|
+
// server restart: skill tetap terpasang — jangan ubah jadi gagal.
|
|
204
|
+
// Restart server juga memuat ulang skill, jadi anggap aktif.
|
|
205
|
+
if (cur.job.status === "succeeded") {
|
|
206
|
+
update(jobId, (en) => ({ ...en, job: { ...en.job, refreshed: true } }));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
// Server restart saat instalasi berjalan: job hilang bersama prosesnya.
|
|
210
|
+
update(jobId, (en) => ({
|
|
211
|
+
...en,
|
|
212
|
+
job: { ...en.job, status: "failed", error: "INSTALL_JOB_LOST", finishedAt: Date.now() },
|
|
213
|
+
pollError: null,
|
|
214
|
+
}));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
update(jobId, (en) => ({ ...en, pollError: "Connection lost — retrying…" }));
|
|
218
|
+
}
|
|
219
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
polling.delete(jobId);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Mulai instalasi. Melempar `ApiError` bila server menolak sebelum job dibuat
|
|
228
|
+
* (validasi, Project tak dikenal, instalasi lain sedang berjalan).
|
|
229
|
+
*/
|
|
230
|
+
export async function startInstall(
|
|
231
|
+
projectId: string,
|
|
232
|
+
source: string,
|
|
233
|
+
skill: string,
|
|
234
|
+
): Promise<InstallJob> {
|
|
235
|
+
const res = await apiFetch(`/api/projects/${projectId}/skills/install`, {
|
|
236
|
+
method: "POST",
|
|
237
|
+
body: JSON.stringify({ source, skill }),
|
|
238
|
+
});
|
|
239
|
+
const { job } = (await res.json()) as { job: InstallJob };
|
|
240
|
+
upsert(newEntry(job, true));
|
|
241
|
+
saveUiState();
|
|
242
|
+
void poll(job.id);
|
|
243
|
+
return job;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let hydrated = false;
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Pulihkan notifikasi setelah reload (sekali per muat halaman): job berjalan
|
|
250
|
+
* + job selesai yang belum ditutup, beserta status tampilannya. Log diambil
|
|
251
|
+
* ulang dari awal; job selesai cukup satu kali ambil.
|
|
252
|
+
*/
|
|
253
|
+
export async function hydrateInstalls(): Promise<void> {
|
|
254
|
+
if (hydrated) return;
|
|
255
|
+
hydrated = true;
|
|
256
|
+
try {
|
|
257
|
+
const res = await apiFetch("/api/skills/installs");
|
|
258
|
+
const { jobs } = (await res.json()) as { jobs: InstallJob[] };
|
|
259
|
+
const ui = readUiState();
|
|
260
|
+
const restored = hydrationEntries(jobs, ui, new Set(entries.map((e) => e.job.id)));
|
|
261
|
+
// Id yang tidak dikenal server lagi (kedaluwarsa) dibuang dari storage.
|
|
262
|
+
const known = new Set(jobs.map((j) => j.id));
|
|
263
|
+
closedIds = new Set(ui.dismissed.filter((id) => known.has(id)));
|
|
264
|
+
if (restored.length > 0) {
|
|
265
|
+
entries = [...entries, ...restored];
|
|
266
|
+
emit();
|
|
267
|
+
}
|
|
268
|
+
saveUiState();
|
|
269
|
+
for (const e of restored) void poll(e.job.id);
|
|
270
|
+
} catch {
|
|
271
|
+
hydrated = false; // coba lagi di kesempatan berikutnya
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function dismissInstall(jobId: string): void {
|
|
276
|
+
update(jobId, (e) => ({ ...e, dismissed: true }));
|
|
277
|
+
// Job selesai yang ditutup tidak perlu disimpan lagi — cukup ingat id-nya
|
|
278
|
+
// agar tidak muncul kembali setelah refresh.
|
|
279
|
+
const e = entries.find((x) => x.job.id === jobId);
|
|
280
|
+
if (e && e.job.status !== "running") {
|
|
281
|
+
closedIds.add(jobId);
|
|
282
|
+
entries = entries.filter((x) => x.job.id !== jobId);
|
|
283
|
+
emit();
|
|
284
|
+
}
|
|
285
|
+
saveUiState();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Minta server membatalkan job; status akhir datang lewat polling. */
|
|
289
|
+
export async function cancelInstall(jobId: string): Promise<void> {
|
|
290
|
+
update(jobId, (e) => ({ ...e, cancelling: true, expanded: true }));
|
|
291
|
+
saveUiState();
|
|
292
|
+
try {
|
|
293
|
+
await apiFetch(`/api/skills/installs/${jobId}/cancel`, { method: "POST" });
|
|
294
|
+
} catch (e) {
|
|
295
|
+
// 409 = sudah selesai duluan; biarkan polling membawa status akhirnya.
|
|
296
|
+
if (!(e instanceof ApiError && e.status === 409)) {
|
|
297
|
+
update(jobId, (en) => ({
|
|
298
|
+
...en,
|
|
299
|
+
cancelling: false,
|
|
300
|
+
pollError: "Couldn't cancel — try again.",
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function toggleInstallLog(jobId: string): void {
|
|
307
|
+
update(jobId, (e) => ({ ...e, expanded: !e.expanded }));
|
|
308
|
+
saveUiState();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Tampilkan lagi notifikasi job (mis. klik "Installing…" pada kartu). */
|
|
312
|
+
export function revealInstall(jobId: string): void {
|
|
313
|
+
closedIds.delete(jobId);
|
|
314
|
+
update(jobId, (e) => ({ ...e, dismissed: false, expanded: true }));
|
|
315
|
+
saveUiState();
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Berlangganan event "job selesai"; mengembalikan fungsi berhenti. */
|
|
319
|
+
export function onInstallFinished(cb: (job: InstallJob) => void): () => void {
|
|
320
|
+
finishListeners.add(cb);
|
|
321
|
+
return () => finishListeners.delete(cb);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function subscribe(l: Listener): () => void {
|
|
325
|
+
listeners.add(l);
|
|
326
|
+
return () => listeners.delete(l);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function snapshot(): InstallEntry[] {
|
|
330
|
+
return entries;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function useInstalls(): InstallEntry[] {
|
|
334
|
+
return useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Job berjalan untuk Project + skill tertentu (untuk status tombol kartu). */
|
|
338
|
+
export function findRunningInstall(
|
|
339
|
+
list: readonly InstallEntry[],
|
|
340
|
+
projectId: string,
|
|
341
|
+
source: string,
|
|
342
|
+
skillId: string,
|
|
343
|
+
): InstallEntry | undefined {
|
|
344
|
+
return list.find(
|
|
345
|
+
(e) =>
|
|
346
|
+
e.job.status === "running" &&
|
|
347
|
+
e.job.projectId === projectId &&
|
|
348
|
+
e.job.source === source &&
|
|
349
|
+
e.job.skillId === skillId,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Hanya untuk test: kosongkan store. */
|
|
354
|
+
export function __resetInstallsForTest(): void {
|
|
355
|
+
entries = [];
|
|
356
|
+
closedIds = new Set();
|
|
357
|
+
hydrated = false;
|
|
358
|
+
polling.clear();
|
|
359
|
+
emit();
|
|
360
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper tampilan katalog skill (skills.sh) di halaman Skills — logika murni
|
|
3
|
+
* tanpa DOM, dipisah agar dapat diuji `bun test`.
|
|
4
|
+
*/
|
|
5
|
+
import type { SkillInfo } from "@/server/services/opencode-client";
|
|
6
|
+
import type { AuditStatus, RegistrySkill } from "@/server/services/skills-registry";
|
|
7
|
+
import type { Project } from "@/types";
|
|
8
|
+
|
|
9
|
+
/** Kunci localStorage Project terakhir yang dipilih di halaman Skills. */
|
|
10
|
+
export const LAST_SKILLS_PROJECT_KEY = "kcg-skills-project";
|
|
11
|
+
|
|
12
|
+
/** Saran kata kunci saat kolom cari masih kosong (tab Browse). */
|
|
13
|
+
export const SEARCH_SUGGESTIONS = [
|
|
14
|
+
"react",
|
|
15
|
+
"nextjs",
|
|
16
|
+
"testing",
|
|
17
|
+
"typescript",
|
|
18
|
+
"python",
|
|
19
|
+
"design",
|
|
20
|
+
"docs",
|
|
21
|
+
"git",
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Project terpilih di halaman Skills: `?project=` (dari sidebar) menang, lalu
|
|
26
|
+
* pilihan terakhir yang diingat, lalu satu-satunya Project bila hanya ada
|
|
27
|
+
* satu. Id yang tidak dikenal (Project terhapus) dilewati.
|
|
28
|
+
*/
|
|
29
|
+
export function resolveSkillsProject(
|
|
30
|
+
projects: readonly Project[],
|
|
31
|
+
urlId: string | null,
|
|
32
|
+
storedId: string | null,
|
|
33
|
+
): Project | null {
|
|
34
|
+
for (const id of [urlId, storedId]) {
|
|
35
|
+
if (id === null) continue;
|
|
36
|
+
const hit = projects.find((p) => p.id === id);
|
|
37
|
+
if (hit) return hit;
|
|
38
|
+
}
|
|
39
|
+
return projects.length === 1 ? (projects[0] ?? null) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Owner GitHub dari `owner/repo`; `null` untuk sumber well-known (domain). */
|
|
43
|
+
export function githubOwner(source: string): string | null {
|
|
44
|
+
const [owner, repo, ...rest] = source.split("/");
|
|
45
|
+
if (!owner || !repo || rest.length > 0) return null;
|
|
46
|
+
return owner;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Skill terpasang yang relevan untuk halaman Skills: tanpa bawaan opencode,
|
|
51
|
+
* Project dulu lalu global, difilter kata kunci (nama / deskripsi).
|
|
52
|
+
*/
|
|
53
|
+
export function visibleInstalledSkills(skills: readonly SkillInfo[], query: string): SkillInfo[] {
|
|
54
|
+
const q = query.trim().toLowerCase();
|
|
55
|
+
const match = (s: SkillInfo) =>
|
|
56
|
+
q === "" || s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
|
|
57
|
+
return [
|
|
58
|
+
...skills.filter((s) => s.source === "project" && match(s)),
|
|
59
|
+
...skills.filter((s) => s.source === "global" && match(s)),
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Jumlah install ringkas: 950, 1.2K, 752K, 1.3M. */
|
|
64
|
+
export function formatInstalls(n: number): string {
|
|
65
|
+
if (n < 1000) return String(n);
|
|
66
|
+
const fmt = (v: number, unit: string) => {
|
|
67
|
+
const s = v >= 100 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, "");
|
|
68
|
+
return `${s}${unit}`;
|
|
69
|
+
};
|
|
70
|
+
if (n < 1_000_000) return fmt(n / 1000, "K");
|
|
71
|
+
return fmt(n / 1_000_000, "M");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Nama skill yang sudah terpasang di Project (sumber `project`). Skill global
|
|
76
|
+
* sengaja tidak dihitung: instalasi di halaman ini memasang ke folder Project,
|
|
77
|
+
* jadi skill global bernama sama tetap boleh dipasang per Project.
|
|
78
|
+
*/
|
|
79
|
+
export function installedProjectSkillNames(skills: readonly SkillInfo[]): Set<string> {
|
|
80
|
+
return new Set(skills.filter((s) => s.source === "project").map((s) => s.name.toLowerCase()));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Apakah skill katalog sudah terpasang di Project (cocokkan nama folder / nama). */
|
|
84
|
+
export function isInstalled(skill: RegistrySkill, installed: ReadonlySet<string>): boolean {
|
|
85
|
+
return installed.has(skill.skillId.toLowerCase()) || installed.has(skill.name.toLowerCase());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Label & gaya ringkasan audit keamanan. */
|
|
89
|
+
export const AUDIT_LABEL: Record<AuditStatus, string> = {
|
|
90
|
+
pass: "Passed",
|
|
91
|
+
warn: "Review",
|
|
92
|
+
fail: "Flagged",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const AUDIT_BADGE_CLASS: Record<AuditStatus, string> = {
|
|
96
|
+
pass: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
|
|
97
|
+
warn: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
|
|
98
|
+
fail: "bg-destructive/10 text-destructive",
|
|
99
|
+
};
|
package/src/lib/time.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format waktu & durasi untuk label UI (jam footer pesan, durasi thinking).
|
|
3
|
+
* Logika murni agar mudah diuji tanpa DOM.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Jam lokal `HH:MM:SS` untuk footer pesan. */
|
|
7
|
+
export function formatTime(ts: number): string {
|
|
8
|
+
return new Date(ts).toLocaleTimeString(undefined, {
|
|
9
|
+
hour: "2-digit",
|
|
10
|
+
minute: "2-digit",
|
|
11
|
+
second: "2-digit",
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Format durasi ms ke string ringkas: "1s", "12s", "1m 5s". */
|
|
16
|
+
export function formatDuration(ms: number): string {
|
|
17
|
+
const s = Math.round(ms / 1000);
|
|
18
|
+
if (s < 60) return `${s}s`;
|
|
19
|
+
const m = Math.floor(s / 60);
|
|
20
|
+
const rem = s % 60;
|
|
21
|
+
return rem === 0 ? `${m}m` : `${m}m ${rem}s`;
|
|
22
|
+
}
|