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
package/src/lib/tone.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nada hover rail pesan (Web Audio) — tangga nada mayor.
|
|
3
|
+
*
|
|
4
|
+
* Penanda paling atas berbunyi paling rendah, makin ke bawah makin tinggi
|
|
5
|
+
* (do re mi fa sol la si do…), berhenti menaik setelah dua oktaf.
|
|
6
|
+
* Frekuensinya dihitung murni (`scaleFrequency`, bisa diuji); pemutarannya
|
|
7
|
+
* memakai satu `AudioContext` bersama.
|
|
8
|
+
*
|
|
9
|
+
* Catatan browser: `AudioContext` baru boleh berbunyi setelah ada interaksi
|
|
10
|
+
* yang dianggap "user activation" (klik / tap / tombol). Hover TIDAK termasuk,
|
|
11
|
+
* jadi nada pertama bisa senyap sampai pengguna mengklik sesuatu di halaman.
|
|
12
|
+
* Karena ini hanya hiasan, kegagalan diabaikan diam-diam.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Jarak semiton nada mayor: do re mi fa sol la si. */
|
|
16
|
+
const MAJOR_STEPS = [0, 2, 4, 5, 7, 9, 11];
|
|
17
|
+
|
|
18
|
+
/** Nada dasar (C4). */
|
|
19
|
+
export const BASE_FREQUENCY = 261.63;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Nada tertinggi = dua oktaf di atas dasar (do), yaitu derajat ke-14 tangga
|
|
23
|
+
* nada. Di atas itu nada DITAHAN (tidak berulang ke bawah): rail panjang
|
|
24
|
+
* tetap terdengar menaik, dan nadanya tidak pernah melengking (maks ±1 kHz).
|
|
25
|
+
*/
|
|
26
|
+
const MAX_DEGREE = 14;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Frekuensi (Hz) untuk penanda ke-`index` (0 = paling atas / paling rendah).
|
|
30
|
+
* Naik satu derajat tangga nada mayor per penanda, ditahan di `MAX_DEGREE`.
|
|
31
|
+
*/
|
|
32
|
+
export function scaleFrequency(index: number, base = BASE_FREQUENCY): number {
|
|
33
|
+
const degree = Math.min(Math.max(0, Math.floor(index)), MAX_DEGREE);
|
|
34
|
+
const step = MAJOR_STEPS[degree % MAJOR_STEPS.length] ?? 0;
|
|
35
|
+
const octave = Math.floor(degree / MAJOR_STEPS.length);
|
|
36
|
+
return base * 2 ** ((step + octave * 12) / 12);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Ctx = AudioContext & { _kcgUnlocked?: boolean };
|
|
40
|
+
let ctx: Ctx | null = null;
|
|
41
|
+
|
|
42
|
+
function audioContext(): Ctx | null {
|
|
43
|
+
if (typeof window === "undefined") return null;
|
|
44
|
+
const Ctor =
|
|
45
|
+
window.AudioContext ??
|
|
46
|
+
(window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
|
47
|
+
if (!Ctor) return null;
|
|
48
|
+
ctx ??= new Ctor();
|
|
49
|
+
return ctx;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bunyikan satu nada pendek & lembut. Tidak pernah melempar: fitur ini
|
|
54
|
+
* sekadar umpan balik, bukan bagian dari alur utama.
|
|
55
|
+
*/
|
|
56
|
+
export function playTone(frequency: number): void {
|
|
57
|
+
try {
|
|
58
|
+
const audio = audioContext();
|
|
59
|
+
if (!audio) return;
|
|
60
|
+
if (audio.state === "suspended") void audio.resume();
|
|
61
|
+
if (audio.state !== "running") return;
|
|
62
|
+
|
|
63
|
+
const now = audio.currentTime;
|
|
64
|
+
const osc = audio.createOscillator();
|
|
65
|
+
const gain = audio.createGain();
|
|
66
|
+
// Triangle: lembut, tanpa harmonik tajam seperti square/saw.
|
|
67
|
+
osc.type = "triangle";
|
|
68
|
+
osc.frequency.value = frequency;
|
|
69
|
+
// Envelope pendek (±0.16 s) dengan attack cepat supaya terasa seperti
|
|
70
|
+
// "tik" bernada, bukan bunyi panjang yang mengganggu saat menyapu rail.
|
|
71
|
+
gain.gain.setValueAtTime(0, now);
|
|
72
|
+
gain.gain.linearRampToValueAtTime(0.06, now + 0.012);
|
|
73
|
+
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16);
|
|
74
|
+
osc.connect(gain).connect(audio.destination);
|
|
75
|
+
osc.start(now);
|
|
76
|
+
osc.stop(now + 0.18);
|
|
77
|
+
} catch {
|
|
78
|
+
/* Web Audio tidak tersedia / diblokir — abaikan. */
|
|
79
|
+
}
|
|
80
|
+
}
|
package/src/lib/turns.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transformasi pesan percakapan terstruktur → model tampilan timeline.
|
|
3
|
+
*
|
|
4
|
+
* Logika murni (tanpa React/DOM) yang sebelumnya tinggal di `SessionView.tsx`,
|
|
5
|
+
* dipisah agar dapat diuji `bun test` tanpa render. Tipe hasil (`TurnSegment`,
|
|
6
|
+
* `TurnBlock`, `MessageGroup`, dst.) ada di `src/types/turns.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
AssistantTurnGroup,
|
|
10
|
+
MessageGroup,
|
|
11
|
+
MessagePart,
|
|
12
|
+
SessionMessage,
|
|
13
|
+
TurnBlock,
|
|
14
|
+
TurnSegment,
|
|
15
|
+
} from "@/types";
|
|
16
|
+
|
|
17
|
+
/** Teks part apa pun yang membawa field `text` (atau null). */
|
|
18
|
+
export function partText(p: MessagePart): string | null {
|
|
19
|
+
return typeof p.text === "string" ? p.text : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Gabungkan teks jawaban dari part `text` saja — reasoning/tool/step tidak
|
|
24
|
+
* ikut, walau part tersebut juga membawa field `text` dari opencode.
|
|
25
|
+
*/
|
|
26
|
+
export function textOf(parts: MessagePart[]): string {
|
|
27
|
+
return parts
|
|
28
|
+
.filter((p) => p.type === "text")
|
|
29
|
+
.map(partText)
|
|
30
|
+
.filter((t): t is string => t !== null)
|
|
31
|
+
.join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Ratakan seluruh parts satu turn (bisa beberapa pesan assistant) menjadi
|
|
36
|
+
* daftar segmen BERURUTAN: setiap part reasoning jadi satu segmen Thinking
|
|
37
|
+
* tersendiri (bisa tampil lebih dari sekali, sesuai urutan berpikir model),
|
|
38
|
+
* tool jadi segmen badge, part `text` berurutan digabung, dan segmen teks
|
|
39
|
+
* non-kosong TERAKHIR ditandai `final` (jawaban akhir — satu-satunya yang
|
|
40
|
+
* dapat efek typewriter).
|
|
41
|
+
*/
|
|
42
|
+
export function turnSegments(messages: readonly SessionMessage[]): TurnSegment[] {
|
|
43
|
+
const segments: TurnSegment[] = [];
|
|
44
|
+
for (const msg of messages) {
|
|
45
|
+
msg.parts.forEach((part, i) => {
|
|
46
|
+
const key = `${msg.id}:${part.id ?? i}`;
|
|
47
|
+
if (part.type === "reasoning" || part.type === "error") {
|
|
48
|
+
segments.push({ kind: part.type, key, part });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (part.type === "tool" || part.type === "shell" || part.type === "file") {
|
|
52
|
+
segments.push({ kind: "tool", key, part });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (part.type !== "text") return;
|
|
56
|
+
const text = partText(part) ?? "";
|
|
57
|
+
const last = segments[segments.length - 1];
|
|
58
|
+
// Part text berdempetan digabung agar tidak pecah jadi beberapa bubble.
|
|
59
|
+
if (last?.kind === "text") last.text = last.text === "" ? text : `${last.text}\n${text}`;
|
|
60
|
+
else segments.push({ kind: "text", key, text, final: false });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
// Segmen teks non-kosong terakhir = jawaban akhir turn.
|
|
64
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
65
|
+
const seg = segments[i];
|
|
66
|
+
if (seg?.kind !== "text") continue;
|
|
67
|
+
if (seg.text.trim() !== "") seg.final = true;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
return segments;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Key kandidat `state.input` tool yang berisi target utama (path/argumen). */
|
|
74
|
+
const TOOL_TARGET_KEYS = [
|
|
75
|
+
"filePath",
|
|
76
|
+
"path",
|
|
77
|
+
"file",
|
|
78
|
+
"dir",
|
|
79
|
+
"directory",
|
|
80
|
+
"pattern",
|
|
81
|
+
"query",
|
|
82
|
+
"url",
|
|
83
|
+
"command",
|
|
84
|
+
"description",
|
|
85
|
+
] as const;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Target utama sebuah tool call (mis. path file yang di-read) — diambil
|
|
89
|
+
* dari `state.input` part tool opencode dengan toleransi beberapa nama key.
|
|
90
|
+
* Part `file` (echo @file) memakai `filename`-nya langsung.
|
|
91
|
+
*/
|
|
92
|
+
export function toolTarget(part: MessagePart): string | null {
|
|
93
|
+
if (part.type === "file" && typeof part.filename === "string" && part.filename.trim() !== "") {
|
|
94
|
+
return part.filename;
|
|
95
|
+
}
|
|
96
|
+
const state = part.state;
|
|
97
|
+
if (typeof state !== "object" || state === null) return null;
|
|
98
|
+
const input = (state as { input?: unknown }).input;
|
|
99
|
+
if (typeof input !== "object" || input === null) return null;
|
|
100
|
+
for (const key of TOOL_TARGET_KEYS) {
|
|
101
|
+
const v = (input as Record<string, unknown>)[key];
|
|
102
|
+
if (typeof v === "string" && v.trim() !== "") return v;
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Label badge tool: nama tool + target (mis. `read package.json`). */
|
|
108
|
+
export function toolLabel(part: MessagePart): string {
|
|
109
|
+
const name = typeof part.tool === "string" && part.tool.trim() !== "" ? part.tool : part.type;
|
|
110
|
+
const target = toolTarget(part);
|
|
111
|
+
return target === null ? name : `${name} ${target}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Kelompokkan pesan berurutan: assistant yang berdempetan (satu turn —
|
|
116
|
+
* sering dipecah opencode menjadi beberapa `msg_...` saat reasoning/tool/
|
|
117
|
+
* sub-agent) menjadi SATU grup sehingga tampil sebagai satu kesatuan;
|
|
118
|
+
* pesan user selalu grup tersendiri.
|
|
119
|
+
*/
|
|
120
|
+
export function groupTurns(messages: readonly SessionMessage[]): MessageGroup[] {
|
|
121
|
+
const groups: MessageGroup[] = [];
|
|
122
|
+
for (const m of messages) {
|
|
123
|
+
const last = groups[groups.length - 1];
|
|
124
|
+
if (m.role === "assistant") {
|
|
125
|
+
if (last?.kind === "assistant") last.messages.push(m);
|
|
126
|
+
else groups.push({ kind: "assistant", id: m.id, messages: [m] });
|
|
127
|
+
} else {
|
|
128
|
+
groups.push({ kind: "user", message: m });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return groups;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Apakah satu turn sudah menampilkan sesuatu ke user: reasoning non-kosong,
|
|
136
|
+
* tool call, error, maupun teks. Dipakai untuk memutuskan kapan indikator
|
|
137
|
+
* "Memproses…" perlu tampil (respon masih kosong sama sekali).
|
|
138
|
+
*/
|
|
139
|
+
export function hasVisibleContent(segments: readonly TurnSegment[]): boolean {
|
|
140
|
+
return segments.some(
|
|
141
|
+
(s) =>
|
|
142
|
+
s.kind === "tool" ||
|
|
143
|
+
s.kind === "error" ||
|
|
144
|
+
(s.kind === "reasoning" && (partText(s.part) ?? "").trim() !== "") ||
|
|
145
|
+
(s.kind === "text" && s.text.trim() !== ""),
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Status LIVE satu turn untuk maskot (murni, diuji):
|
|
151
|
+
* - `null` -> turn belum ada / selesai (maskot disembunyikan).
|
|
152
|
+
* - "Working…" -> belum ada konten apa pun (baru mulai).
|
|
153
|
+
* - "Thinking…" -> reasoning part terakhir masih berjalan.
|
|
154
|
+
* - "Writing…" -> model sedang menulis jawaban (segmen teks terakhir tampil).
|
|
155
|
+
* - lainnya -> label tool yang sedang dijalankan (mis. "read package.json").
|
|
156
|
+
*
|
|
157
|
+
* Catatan: akhir turn ditandai `streaming=false` (pesan final menggantikan
|
|
158
|
+
* versi streaming), jadi penilaian "selesai" cukup dari flag itu.
|
|
159
|
+
*/
|
|
160
|
+
export function turnStatus(messages: readonly SessionMessage[], streaming: boolean): string | null {
|
|
161
|
+
if (!streaming || messages.length === 0) return null;
|
|
162
|
+
const segments = turnSegments(messages);
|
|
163
|
+
if (!hasVisibleContent(segments)) return "Working…";
|
|
164
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
165
|
+
const seg = segments[i];
|
|
166
|
+
if (!seg) continue;
|
|
167
|
+
if (seg.kind === "text") return "Writing…";
|
|
168
|
+
if (seg.kind === "reasoning") return "Thinking…";
|
|
169
|
+
if (seg.kind === "tool") return toolLabel(seg.part);
|
|
170
|
+
// error: proses berhenti di error -> selesai.
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
return "Working…";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Total durasi thinking (ms) dari satu turn — dipakai untuk label "thoughts Xs"
|
|
178
|
+
* di footer setelah turn selesai.
|
|
179
|
+
*
|
|
180
|
+
* Strategi (urut prioritas):
|
|
181
|
+
* 1. Pakai `time.start`/`time.end` dari `ReasoningPart` asli opencode jika ada.
|
|
182
|
+
* 2. Fallback ke `time.start` part pertama vs `time.end` part terakhir dari
|
|
183
|
+
* seluruh parts reasoning yang ada (estimasi kasar).
|
|
184
|
+
* 3. Jika tidak ada timing sama sekali, kembalikan null.
|
|
185
|
+
*/
|
|
186
|
+
export function turnThoughtDuration(messages: readonly SessionMessage[]): number | null {
|
|
187
|
+
let earliest: number | null = null;
|
|
188
|
+
let latest: number | null = null;
|
|
189
|
+
for (const msg of messages) {
|
|
190
|
+
for (const part of msg.parts) {
|
|
191
|
+
if (part.type !== "reasoning") continue;
|
|
192
|
+
const t = part.time;
|
|
193
|
+
if (!t) continue;
|
|
194
|
+
const start = t.start ?? t.created;
|
|
195
|
+
if (typeof start === "number") {
|
|
196
|
+
if (earliest === null || start < earliest) earliest = start;
|
|
197
|
+
}
|
|
198
|
+
const end = t.end;
|
|
199
|
+
if (typeof end === "number") {
|
|
200
|
+
if (latest === null || end > latest) latest = end;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (earliest !== null && latest !== null && latest > earliest) {
|
|
205
|
+
return latest - earliest;
|
|
206
|
+
}
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function turnBlocks(segments: readonly TurnSegment[]): TurnBlock[] {
|
|
211
|
+
const blocks: TurnBlock[] = [];
|
|
212
|
+
let pendingSteps: Extract<TurnSegment, { kind: "reasoning" | "tool" }>[] = [];
|
|
213
|
+
|
|
214
|
+
for (const seg of segments) {
|
|
215
|
+
if (seg.kind === "reasoning" || seg.kind === "tool") {
|
|
216
|
+
pendingSteps.push(seg);
|
|
217
|
+
} else if (seg.kind === "text" || seg.kind === "error") {
|
|
218
|
+
blocks.push({
|
|
219
|
+
key: pendingSteps[0]?.key ?? seg.key,
|
|
220
|
+
steps: pendingSteps,
|
|
221
|
+
content: seg,
|
|
222
|
+
});
|
|
223
|
+
pendingSteps = [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Steps tersisa tanpa teks (masih streaming atau turn selesai tanpa teks akhir)
|
|
227
|
+
const firstPending = pendingSteps[0];
|
|
228
|
+
if (firstPending) {
|
|
229
|
+
blocks.push({ key: firstPending.key, steps: pendingSteps, content: null });
|
|
230
|
+
}
|
|
231
|
+
return blocks;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Upsert satu part ke dalam daftar pesan (streaming, event `message_part`).
|
|
236
|
+
* - Belum ada pesan dengan `messageId` -> buat placeholder assistant streaming.
|
|
237
|
+
* - Sudah ada -> part dengan `id` sama diganti, selainnya ditambahkan.
|
|
238
|
+
* Mengembalikan array baru (immutable) agar mudah diverifikasi.
|
|
239
|
+
*/
|
|
240
|
+
export function upsertMessagePart(
|
|
241
|
+
messages: SessionMessage[],
|
|
242
|
+
sessionId: string,
|
|
243
|
+
messageId: string,
|
|
244
|
+
part: MessagePart,
|
|
245
|
+
): SessionMessage[] {
|
|
246
|
+
const idx = messages.findIndex((m) => m.id === messageId);
|
|
247
|
+
if (idx === -1) {
|
|
248
|
+
return [
|
|
249
|
+
...messages,
|
|
250
|
+
{
|
|
251
|
+
id: messageId,
|
|
252
|
+
sessionId,
|
|
253
|
+
role: "assistant",
|
|
254
|
+
parts: [part],
|
|
255
|
+
createdAt: Date.now(),
|
|
256
|
+
streaming: true,
|
|
257
|
+
},
|
|
258
|
+
];
|
|
259
|
+
}
|
|
260
|
+
const cur = messages[idx];
|
|
261
|
+
if (!cur) return messages;
|
|
262
|
+
const partIdx = cur.parts.findIndex((p) => p.id !== undefined && p.id === part.id);
|
|
263
|
+
const nextParts =
|
|
264
|
+
partIdx === -1 ? [...cur.parts, part] : cur.parts.map((p, i) => (i === partIdx ? part : p));
|
|
265
|
+
const next: SessionMessage = { ...cur, parts: nextParts, streaming: true };
|
|
266
|
+
return messages.map((m, i) => (i === idx ? next : m));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Tambahkan potongan teks streaming (`message_part_delta`) ke part yang sudah
|
|
271
|
+
* ada: `part[field] += delta`. Delta untuk pesan/part yang belum dikenal
|
|
272
|
+
* diabaikan (daftar tidak berubah) — `message_part` final membawa teks utuh.
|
|
273
|
+
* Mengembalikan array yang sama bila tidak ada perubahan (hemat re-render).
|
|
274
|
+
*/
|
|
275
|
+
export function appendMessagePartDelta(
|
|
276
|
+
messages: SessionMessage[],
|
|
277
|
+
messageId: string,
|
|
278
|
+
partId: string,
|
|
279
|
+
field: string,
|
|
280
|
+
delta: string,
|
|
281
|
+
): SessionMessage[] {
|
|
282
|
+
if (delta === "") return messages;
|
|
283
|
+
const idx = messages.findIndex((m) => m.id === messageId);
|
|
284
|
+
const cur = idx === -1 ? undefined : messages[idx];
|
|
285
|
+
if (!cur) return messages;
|
|
286
|
+
const partIdx = cur.parts.findIndex((p) => p.id === partId);
|
|
287
|
+
const part = partIdx === -1 ? undefined : cur.parts[partIdx];
|
|
288
|
+
if (!part) return messages;
|
|
289
|
+
const prev = (part as Record<string, unknown>)[field];
|
|
290
|
+
if (prev !== undefined && typeof prev !== "string") return messages;
|
|
291
|
+
const nextPart: MessagePart = { ...part, [field]: (prev ?? "") + delta };
|
|
292
|
+
const next: SessionMessage = {
|
|
293
|
+
...cur,
|
|
294
|
+
parts: cur.parts.map((p, i) => (i === partIdx ? nextPart : p)),
|
|
295
|
+
streaming: true,
|
|
296
|
+
};
|
|
297
|
+
return messages.map((m, i) => (i === idx ? next : m));
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Ganti pesan final (hasil POST, non-streaming) — replace bila id sama
|
|
302
|
+
* (menutup versi streaming), selainnya tambahkan.
|
|
303
|
+
*/
|
|
304
|
+
export function upsertMessage(
|
|
305
|
+
messages: SessionMessage[],
|
|
306
|
+
message: SessionMessage,
|
|
307
|
+
): SessionMessage[] {
|
|
308
|
+
const idx = messages.findIndex((m) => m.id === message.id);
|
|
309
|
+
if (idx === -1) return [...messages, message];
|
|
310
|
+
return messages.map((m, i) => (i === idx ? message : m));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Grup assistant terakhir dalam daftar grup (dipakai status maskot global). */
|
|
314
|
+
export function lastAssistantGroup(groups: readonly MessageGroup[]): AssistantTurnGroup | null {
|
|
315
|
+
for (let i = groups.length - 1; i >= 0; i--) {
|
|
316
|
+
const g = groups[i];
|
|
317
|
+
if (g?.kind === "assistant") return g;
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
}
|
package/src/lib/utils.ts
ADDED
package/src/logo.svg
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="KCG Code logo">
|
|
2
|
+
<title>KCG Code</title>
|
|
3
|
+
<defs>
|
|
4
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
5
|
+
<stop offset="0" stop-color="#6D5CFB"/>
|
|
6
|
+
<stop offset="1" stop-color="#4C3FD9"/>
|
|
7
|
+
</linearGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
|
|
10
|
+
<path d="
|
|
11
|
+
M 14 74
|
|
12
|
+
L 14 44
|
|
13
|
+
C 14 36 16 29 20 23
|
|
14
|
+
L 31 9
|
|
15
|
+
L 39 24
|
|
16
|
+
C 42.5 22.8 46.2 22 50 22
|
|
17
|
+
C 53.8 22 57.5 22.8 61 24
|
|
18
|
+
L 69 9
|
|
19
|
+
L 80 23
|
|
20
|
+
C 84 29 86 36 86 44
|
|
21
|
+
L 86 62
|
|
22
|
+
C 86 76 76 86 62 86
|
|
23
|
+
L 38 86
|
|
24
|
+
C 27 86 19 81 16 74
|
|
25
|
+
" stroke="url(#g)" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
|
26
|
+
|
|
27
|
+
<circle cx="14" cy="74" r="7" fill="url(#g)"/>
|
|
28
|
+
<circle cx="34" cy="52" r="6" fill="url(#g)"/>
|
|
29
|
+
<circle cx="66" cy="52" r="6" fill="url(#g)"/>
|
|
30
|
+
<circle cx="50" cy="86" r="7" fill="url(#g)"/>
|
|
31
|
+
</svg>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halaman daftar Session milik satu Project (`/projects/:projectId`).
|
|
3
|
+
*
|
|
4
|
+
* - `Project` di-resolve dari id di URL via `GET /api/projects` (Requirement
|
|
5
|
+
* 10.8) sehingga halaman dapat di-refresh / diakses langsung (direct link)
|
|
6
|
+
* tanpa state dari halaman sebelumnya.
|
|
7
|
+
* - Project tidak ditemukan (sudah dihapus / id salah) -> pesan error dengan
|
|
8
|
+
* aksi kembali ke daftar Project.
|
|
9
|
+
* - Komposisi identik dengan `SessionList`: form pembuatan + daftar.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback, useEffect, useState } from "react";
|
|
13
|
+
import { SessionList } from "@/components/sessions/SessionList";
|
|
14
|
+
import { SessionListSkeleton } from "@/components/sessions/SessionRow";
|
|
15
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
16
|
+
import { useRouter } from "@/hooks/useRouter";
|
|
17
|
+
import { ApiError, apiErrorMessage, apiFetch } from "@/lib/api";
|
|
18
|
+
import { projectsPath, sessionPath } from "@/lib/routes";
|
|
19
|
+
import type { Project } from "@/types";
|
|
20
|
+
|
|
21
|
+
export interface ProjectDetailPageProps {
|
|
22
|
+
projectId: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type LoadState =
|
|
26
|
+
| { phase: "loading" }
|
|
27
|
+
| { phase: "error"; code: string }
|
|
28
|
+
| { phase: "ready"; project: Project };
|
|
29
|
+
|
|
30
|
+
export function ProjectDetailPage({ projectId }: ProjectDetailPageProps) {
|
|
31
|
+
const { navigate } = useRouter();
|
|
32
|
+
const [state, setState] = useState<LoadState>({ phase: "loading" });
|
|
33
|
+
|
|
34
|
+
const load = useCallback(async () => {
|
|
35
|
+
setState({ phase: "loading" });
|
|
36
|
+
try {
|
|
37
|
+
const res = await apiFetch("/api/projects");
|
|
38
|
+
const body = (await res.json()) as { projects: Project[] };
|
|
39
|
+
const project = body.projects.find((p) => p.id === projectId);
|
|
40
|
+
if (project === undefined) {
|
|
41
|
+
setState({ phase: "error", code: "PROJECT_NOT_FOUND" });
|
|
42
|
+
} else {
|
|
43
|
+
setState({ phase: "ready", project });
|
|
44
|
+
}
|
|
45
|
+
} catch (e) {
|
|
46
|
+
setState({
|
|
47
|
+
phase: "error",
|
|
48
|
+
code: e instanceof ApiError ? e.code : "INTERNAL_ERROR",
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}, [projectId]);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
void load();
|
|
55
|
+
}, [load]);
|
|
56
|
+
|
|
57
|
+
if (state.phase === "loading") {
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
className="grid grid-cols-1 gap-x-10 gap-y-8 pt-4 sm:pt-8 lg:grid-cols-[minmax(0,1fr)_20rem] xl:grid-cols-[minmax(0,1fr)_22rem]"
|
|
61
|
+
aria-hidden
|
|
62
|
+
>
|
|
63
|
+
{/* Bentuk sama dengan SessionList: kolom Session + panel samping. */}
|
|
64
|
+
<div className="flex flex-col gap-8">
|
|
65
|
+
<div className="flex flex-col gap-4">
|
|
66
|
+
<div className="flex items-start justify-between gap-4">
|
|
67
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
68
|
+
<Skeleton className="h-8 w-48" />
|
|
69
|
+
<Skeleton className="h-3 w-64" />
|
|
70
|
+
</div>
|
|
71
|
+
<Skeleton className="h-8 w-28 rounded-full" />
|
|
72
|
+
</div>
|
|
73
|
+
<Skeleton className="h-4 w-36" />
|
|
74
|
+
</div>
|
|
75
|
+
<SessionListSkeleton />
|
|
76
|
+
</div>
|
|
77
|
+
{/* Sama dengan ProjectSidePanel: tumpukan kartu seksi. */}
|
|
78
|
+
<div className="hidden flex-col gap-3 self-start lg:flex">
|
|
79
|
+
{[2, 1, 3].map((rows, i) => (
|
|
80
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
|
|
81
|
+
<div key={i} className="overflow-hidden rounded-xl border bg-card">
|
|
82
|
+
<div className="flex items-center gap-3 px-3.5 py-3">
|
|
83
|
+
<Skeleton className="size-8 rounded-lg" />
|
|
84
|
+
<div className="flex flex-1 flex-col gap-1.5">
|
|
85
|
+
<Skeleton className="h-3.5 w-24" />
|
|
86
|
+
<Skeleton className="h-3 w-16" />
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
<div className="flex flex-col gap-2 border-t px-3.5 py-3">
|
|
90
|
+
{Array.from({ length: rows }, (_, j) => (
|
|
91
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
|
|
92
|
+
<Skeleton key={j} className="h-3.5" style={{ width: `${80 - j * 15}%` }} />
|
|
93
|
+
))}
|
|
94
|
+
</div>
|
|
95
|
+
</div>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (state.phase === "error") {
|
|
103
|
+
return (
|
|
104
|
+
<div className="flex flex-col items-center gap-4 py-10 text-center">
|
|
105
|
+
<p className="text-sm text-muted-foreground">{apiErrorMessage(state.code)}</p>
|
|
106
|
+
<a
|
|
107
|
+
href={projectsPath()}
|
|
108
|
+
onClick={(e) => {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
navigate(projectsPath());
|
|
111
|
+
}}
|
|
112
|
+
className="text-sm font-medium underline underline-offset-4"
|
|
113
|
+
>
|
|
114
|
+
Back to projects
|
|
115
|
+
</a>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<SessionList
|
|
122
|
+
key={state.project.id}
|
|
123
|
+
project={state.project}
|
|
124
|
+
onOpenSession={(session) => navigate(sessionPath(projectId, session.id))}
|
|
125
|
+
// Project sudah tidak ada — halaman ini tak punya data lagi untuk
|
|
126
|
+
// ditampilkan, jadi langsung kembali ke daftar Project.
|
|
127
|
+
onDeleted={() => navigate(projectsPath())}
|
|
128
|
+
/>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Homepage (`/`).
|
|
3
|
+
*
|
|
4
|
+
* - Sudah ada Project -> chat (`HomeChat`): headline acak + composer dengan
|
|
5
|
+
* pemilih Project di kiri bawah. Kirim = Session baru di Project terpilih.
|
|
6
|
+
* Daftar Project & Session-nya ada di sidebar (`AppSidebar`).
|
|
7
|
+
* - Belum ada Project -> empty state dengan satu CTA "New project".
|
|
8
|
+
*
|
|
9
|
+
* Data dimuat `hooks/useProjects.ts`; ringkasan per Project (urutan aktivitas
|
|
10
|
+
* terbaru) dihitung `lib/project-overview.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { FolderPlusIcon, FoldersIcon, RefreshCwIcon } from "lucide-react";
|
|
14
|
+
import { HomeChat } from "@/components/projects/HomeChat";
|
|
15
|
+
import { NewProjectDialog } from "@/components/projects/NewProjectDialog";
|
|
16
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
17
|
+
import { Button } from "@/components/ui/button";
|
|
18
|
+
import {
|
|
19
|
+
Empty,
|
|
20
|
+
EmptyContent,
|
|
21
|
+
EmptyDescription,
|
|
22
|
+
EmptyHeader,
|
|
23
|
+
EmptyMedia,
|
|
24
|
+
EmptyTitle,
|
|
25
|
+
} from "@/components/ui/empty";
|
|
26
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
27
|
+
import { useProjects } from "@/hooks/useProjects";
|
|
28
|
+
import { notifyDataChanged } from "@/lib/data-events";
|
|
29
|
+
|
|
30
|
+
export function ProjectsPage() {
|
|
31
|
+
const { overviews, loading, loadError, refresh, dialogOpen, setDialogOpen } = useProjects();
|
|
32
|
+
|
|
33
|
+
const onCreated = () => {
|
|
34
|
+
void refresh();
|
|
35
|
+
// Sidebar memuat datanya sendiri — beri tahu agar Project baru muncul.
|
|
36
|
+
notifyDataChanged();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col">
|
|
41
|
+
<NewProjectDialog open={dialogOpen} onOpenChange={setDialogOpen} onCreated={onCreated} />
|
|
42
|
+
|
|
43
|
+
{loading && overviews.length === 0 ? (
|
|
44
|
+
<HomeSkeleton />
|
|
45
|
+
) : loadError ? (
|
|
46
|
+
<div className="flex flex-1 flex-col justify-center">
|
|
47
|
+
<Alert variant="destructive">
|
|
48
|
+
<AlertTitle>Failed to load projects</AlertTitle>
|
|
49
|
+
<AlertDescription className="flex flex-col items-start gap-3">
|
|
50
|
+
{loadError}
|
|
51
|
+
<Button type="button" variant="outline" size="sm" onClick={() => void refresh()}>
|
|
52
|
+
<RefreshCwIcon data-icon="inline-start" />
|
|
53
|
+
Retry
|
|
54
|
+
</Button>
|
|
55
|
+
</AlertDescription>
|
|
56
|
+
</Alert>
|
|
57
|
+
</div>
|
|
58
|
+
) : overviews.length === 0 ? (
|
|
59
|
+
<div className="flex flex-1 flex-col justify-center">
|
|
60
|
+
<Empty className="rounded-xl border border-dashed bg-card">
|
|
61
|
+
<EmptyHeader>
|
|
62
|
+
<EmptyMedia variant="icon">
|
|
63
|
+
<FoldersIcon />
|
|
64
|
+
</EmptyMedia>
|
|
65
|
+
<EmptyTitle>No projects yet</EmptyTitle>
|
|
66
|
+
<EmptyDescription>
|
|
67
|
+
Add a project first — pick a working directory in the Sandbox, then run CLI_Agent
|
|
68
|
+
inside it.
|
|
69
|
+
</EmptyDescription>
|
|
70
|
+
<EmptyContent>
|
|
71
|
+
<Button type="button" onClick={() => setDialogOpen(true)}>
|
|
72
|
+
<FolderPlusIcon data-icon="inline-start" />
|
|
73
|
+
New project
|
|
74
|
+
</Button>
|
|
75
|
+
</EmptyContent>
|
|
76
|
+
</EmptyHeader>
|
|
77
|
+
</Empty>
|
|
78
|
+
</div>
|
|
79
|
+
) : (
|
|
80
|
+
<HomeChat overviews={overviews} onNewProject={() => setDialogOpen(true)} />
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Placeholder chat saat memuat — bentuk serupa agar tidak melompat. */
|
|
87
|
+
function HomeSkeleton() {
|
|
88
|
+
return (
|
|
89
|
+
<div className="flex flex-1 flex-col justify-center gap-6 pb-[10dvh]" aria-hidden>
|
|
90
|
+
<div className="flex items-center justify-center gap-3">
|
|
91
|
+
<Skeleton className="size-12 rounded-full" />
|
|
92
|
+
<Skeleton className="h-8 w-64" />
|
|
93
|
+
</div>
|
|
94
|
+
<Skeleton className="h-32 w-full rounded-2xl" />
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|