kcg-code 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,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,124 @@
|
|
|
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
|
+
| {
|
|
18
|
+
name: "settings";
|
|
19
|
+
section: SettingsSection;
|
|
20
|
+
anchor?: string;
|
|
21
|
+
/**
|
|
22
|
+
* `/settings` tanpa sub-halaman. Layar lebar: tampil sebagai Profile
|
|
23
|
+
* (daftar navigasi ada di samping); layar sempit: daftar bagian.
|
|
24
|
+
*/
|
|
25
|
+
root?: true;
|
|
26
|
+
}
|
|
27
|
+
| { name: "not-found" };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sub-halaman Settings (`/settings/:section`): empat bagian pengaturan +
|
|
31
|
+
* `about` (tampil terpisah di akhir navigasi, bukan pengaturan).
|
|
32
|
+
*/
|
|
33
|
+
export const SETTINGS_SECTIONS = ["profile", "security", "workspace", "remote", "about"] as const;
|
|
34
|
+
export type SettingsSection = (typeof SETTINGS_SECTIONS)[number];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Alias URL lama -> section + anchor. `devices` kini bagian dari Security
|
|
38
|
+
* (grup "Signed-in devices"); URL lamanya tetap berfungsi.
|
|
39
|
+
*/
|
|
40
|
+
const SETTINGS_ALIASES: Record<string, { section: SettingsSection; anchor: string }> = {
|
|
41
|
+
devices: { section: "security", anchor: "devices" },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function isSettingsSection(value: string): value is SettingsSection {
|
|
45
|
+
return (SETTINGS_SECTIONS as readonly string[]).includes(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** URL daftar Project. */
|
|
49
|
+
export function projectsPath(): string {
|
|
50
|
+
return "/";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** URL daftar Session milik sebuah Project. */
|
|
54
|
+
export function projectPath(projectId: string): string {
|
|
55
|
+
return `/projects/${projectId}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** URL Session view milik sebuah Project. */
|
|
59
|
+
export function sessionPath(projectId: string, sessionId: string): string {
|
|
60
|
+
return `/projects/${projectId}/sessions/${sessionId}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* URL halaman Skills. `projectId` (opsional) jadi query `?project=` agar
|
|
65
|
+
* Project tersebut otomatis terpilih saat halaman dibuka.
|
|
66
|
+
*/
|
|
67
|
+
export function skillsPath(projectId?: string | null): string {
|
|
68
|
+
return projectId ? `/skills?project=${encodeURIComponent(projectId)}` : "/skills";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** URL halaman Settings; tanpa `section` = `/settings` (dibuka sebagai Profile). */
|
|
72
|
+
export function settingsPath(section?: SettingsSection): string {
|
|
73
|
+
return section ? `/settings/${section}` : "/settings";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Ambil id Project dari query string halaman Skills (`?project=`). */
|
|
77
|
+
export function parseSkillsProject(search: string): string | null {
|
|
78
|
+
const id = new URLSearchParams(search).get("project");
|
|
79
|
+
return id !== null && id.length > 0 ? id : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pemetaan `pathname` -> `AppRoute`. Segment id dianggap opaque — id
|
|
84
|
+
* Project/Session berupa UUID sehingga aman tanpa decoding. Trailing slash
|
|
85
|
+
* diabaikan (`/projects/x/` sama dengan `/projects/x`).
|
|
86
|
+
*/
|
|
87
|
+
export function parseRoute(pathname: string): AppRoute {
|
|
88
|
+
const segments = pathname
|
|
89
|
+
.replace(/\/+$/, "")
|
|
90
|
+
.split("/")
|
|
91
|
+
.filter((segment) => segment.length > 0);
|
|
92
|
+
|
|
93
|
+
if (segments.length === 0) return { name: "projects" };
|
|
94
|
+
if (segments[0] === "skills" && segments.length === 1) return { name: "skills" };
|
|
95
|
+
if (segments[0] === "settings") {
|
|
96
|
+
if (segments.length === 1) return { name: "settings", section: "profile", root: true };
|
|
97
|
+
const section = segments[1];
|
|
98
|
+
if (segments.length === 2 && section !== undefined && isSettingsSection(section)) {
|
|
99
|
+
return { name: "settings", section };
|
|
100
|
+
}
|
|
101
|
+
// `Object.hasOwn`: hindari kunci prototipe (mis. "/settings/constructor").
|
|
102
|
+
const alias =
|
|
103
|
+
segments.length === 2 && section !== undefined && Object.hasOwn(SETTINGS_ALIASES, section)
|
|
104
|
+
? SETTINGS_ALIASES[section]
|
|
105
|
+
: undefined;
|
|
106
|
+
if (alias) return { name: "settings", section: alias.section, anchor: alias.anchor };
|
|
107
|
+
return { name: "not-found" };
|
|
108
|
+
}
|
|
109
|
+
if (segments[0] !== "projects") return { name: "not-found" };
|
|
110
|
+
|
|
111
|
+
const projectId = segments[1];
|
|
112
|
+
if (projectId === undefined || projectId.length === 0) return { name: "not-found" };
|
|
113
|
+
|
|
114
|
+
if (segments.length === 2) return { name: "project", projectId };
|
|
115
|
+
|
|
116
|
+
if (segments[2] === "sessions" && segments.length === 4) {
|
|
117
|
+
const sessionId = segments[3];
|
|
118
|
+
if (sessionId !== undefined && sessionId.length > 0) {
|
|
119
|
+
return { name: "session", projectId, sessionId };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { name: "not-found" };
|
|
124
|
+
}
|
|
@@ -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,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Selection mode daftar Session (bulk delete) — logika murni tanpa DOM,
|
|
3
|
+
* dipisah agar dapat diuji `bun test` (pola `lib/session-summary.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Pilihan disimpan sebagai `Set` id; setiap perubahan menghasilkan Set baru
|
|
6
|
+
* (immutable) agar React me-render ulang.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** Tambah / buang satu id dari pilihan. */
|
|
10
|
+
export function toggleSelection(selected: ReadonlySet<string>, id: string): Set<string> {
|
|
11
|
+
const next = new Set(selected);
|
|
12
|
+
if (next.has(id)) next.delete(id);
|
|
13
|
+
else next.add(id);
|
|
14
|
+
return next;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Buang id yang Session-nya sudah tidak ada (dihapus di tab/perangkat lain).
|
|
19
|
+
* Mengembalikan Set yang SAMA bila tidak ada yang dibuang, agar pemanggil
|
|
20
|
+
* bisa menghindari render ulang.
|
|
21
|
+
*/
|
|
22
|
+
export function pruneSelection(
|
|
23
|
+
selected: ReadonlySet<string>,
|
|
24
|
+
sessions: readonly { id: string }[],
|
|
25
|
+
): ReadonlySet<string> {
|
|
26
|
+
const alive = new Set(sessions.map((s) => s.id));
|
|
27
|
+
const next = new Set([...selected].filter((id) => alive.has(id)));
|
|
28
|
+
return next.size === selected.size ? selected : next;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Deskripsi grup saat selection mode ("3 of 7 selected"). */
|
|
32
|
+
export function describeSelection(count: number, total: number): string {
|
|
33
|
+
if (count === 0) return "Tap sessions to select them";
|
|
34
|
+
return `${count} of ${total} selected`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** "1 session" / "3 sessions". */
|
|
38
|
+
export function pluralSessions(count: number): string {
|
|
39
|
+
return `${count} session${count === 1 ? "" : "s"}`;
|
|
40
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|