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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render Markdown balasan model — `react-markdown` + `remark-gfm`.
|
|
3
|
+
*
|
|
4
|
+
* Balasan opencode berformat Markdown, jadi sebelumnya tampil sebagai teks
|
|
5
|
+
* mentah (`**tebal**`, pipa tabel, fence kode). Komponen ini merendernya jadi
|
|
6
|
+
* elemen HTML bergaya sistem desain aplikasi.
|
|
7
|
+
*
|
|
8
|
+
* Keputusan penting:
|
|
9
|
+
* - **Tanpa `rehype-raw`.** HTML mentah di dalam Markdown TIDAK dirender
|
|
10
|
+
* sebagai DOM (di-escape jadi teks). Balasan model adalah konten tak
|
|
11
|
+
* terpercaya — ia bisa memuat markup dari file/web yang dibacanya —
|
|
12
|
+
* sehingga mengizinkan HTML mentah membuka pintu XSS. `disallowedElements`
|
|
13
|
+
* juga membuang tag pemuat sumber eksternal agar sintaks `![]()` tidak
|
|
14
|
+
* memicu request keluar.
|
|
15
|
+
* - **Tabel dibungkus scroll horizontal.** Tabel lebar tidak boleh memaksa
|
|
16
|
+
* seluruh bubble melebar (dan merusak layout HP); wrapper `overflow-x-auto`
|
|
17
|
+
* membuat hanya tabelnya yang bergeser. Blok kode diperlakukan sama.
|
|
18
|
+
* - **Link buka tab baru** dengan `rel="noopener noreferrer nofollow"`.
|
|
19
|
+
* - **Blok kode di-highlight** lewat `CodeBlock` (Shiki, grammar dimuat
|
|
20
|
+
* lazy per bahasa). Kode inline tetap teks monospace biasa.
|
|
21
|
+
* - Parser remark bersifat recoverable, jadi Markdown setengah jadi saat
|
|
22
|
+
* streaming tetap dirender tanpa error (fence yang belum ditutup tampil
|
|
23
|
+
* sebagai kode berjalan, lalu rapi begitu token penutup tiba).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Children, type ComponentProps, isValidElement, type JSX, type ReactNode } from "react";
|
|
27
|
+
import ReactMarkdown from "react-markdown";
|
|
28
|
+
import remarkGfm from "remark-gfm";
|
|
29
|
+
import { CodeBlock } from "@/components/sessions/CodeBlock";
|
|
30
|
+
import { cn } from "@/lib/utils";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Elemen yang tidak pernah dirender: pemuat sumber eksternal & elemen
|
|
34
|
+
* berpotensi eksekusi.
|
|
35
|
+
*/
|
|
36
|
+
const DISALLOWED = ["img", "iframe", "script", "style", "video", "audio", "embed", "object"];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Props yang diterima komponen kustom react-markdown. Selain atribut HTML,
|
|
40
|
+
* react-markdown menyertakan `node` (AST hast) — prop internal yang HARUS
|
|
41
|
+
* dibuang sebelum di-spread ke elemen DOM, kalau tidak ia bocor jadi atribut
|
|
42
|
+
* `node="[object Object]"` di HTML hasil render.
|
|
43
|
+
*/
|
|
44
|
+
type MarkdownProps<T extends keyof JSX.IntrinsicElements> = ComponentProps<T> & {
|
|
45
|
+
node?: unknown;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Bangun komponen bergaya untuk satu tag HTML: `node` dibuang, `className`
|
|
50
|
+
* dari markdown (mis. `language-ts` pada blok kode) tetap digabung.
|
|
51
|
+
*/
|
|
52
|
+
function styled<T extends keyof JSX.IntrinsicElements>(tag: T, classes: string) {
|
|
53
|
+
return function StyledTag({ node: _node, className, ...props }: MarkdownProps<T>) {
|
|
54
|
+
const Tag = tag as string;
|
|
55
|
+
return <Tag {...props} className={cn(classes, className)} />;
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tautan aman: selalu tab baru, tanpa membocorkan referrer. */
|
|
60
|
+
function MarkdownLink({ node: _node, className, ...props }: MarkdownProps<"a">) {
|
|
61
|
+
return (
|
|
62
|
+
<a
|
|
63
|
+
{...props}
|
|
64
|
+
target="_blank"
|
|
65
|
+
rel="noopener noreferrer nofollow"
|
|
66
|
+
className={cn(
|
|
67
|
+
"font-medium text-primary underline underline-offset-2 hover:no-underline",
|
|
68
|
+
className,
|
|
69
|
+
)}
|
|
70
|
+
/>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `code` melayani dua konteks: di dalam `pre` (blok) dan inline. Di dalam
|
|
76
|
+
* `pre`, gaya latar/padding dilepas agar tidak dobel dengan wrapper `pre`;
|
|
77
|
+
* react-markdown menandai blok lewat kelas `language-*`.
|
|
78
|
+
*/
|
|
79
|
+
function MarkdownCode({ node: _node, className, ...props }: MarkdownProps<"code">) {
|
|
80
|
+
const isBlock = typeof className === "string" && className.includes("language-");
|
|
81
|
+
return (
|
|
82
|
+
<code
|
|
83
|
+
{...props}
|
|
84
|
+
className={cn(
|
|
85
|
+
"font-mono",
|
|
86
|
+
isBlock
|
|
87
|
+
? "bg-transparent p-0 text-base"
|
|
88
|
+
: "rounded bg-muted px-1 py-0.5 text-[0.9em] wrap-anywhere",
|
|
89
|
+
className,
|
|
90
|
+
)}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Gabungkan anak `code` jadi string (react-markdown memberi teks mentah). */
|
|
96
|
+
function textOf(node: ReactNode): string {
|
|
97
|
+
if (typeof node === "string" || typeof node === "number") return String(node);
|
|
98
|
+
if (Array.isArray(node)) return node.map(textOf).join("");
|
|
99
|
+
if (isValidElement<{ children?: ReactNode }>(node)) return textOf(node.props.children);
|
|
100
|
+
return "";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Blok kode: `pre > code.language-*`. Isi & bahasa diambil dari `code`
|
|
105
|
+
* anak lalu dirender `CodeBlock` (highlight + tombol salin). Fence tanpa
|
|
106
|
+
* bahasa tetap lewat `CodeBlock` (teks polos, tetap ada tombol salin).
|
|
107
|
+
*/
|
|
108
|
+
function MarkdownPre({ node: _node, children }: MarkdownProps<"pre">) {
|
|
109
|
+
const child = Children.toArray(children)[0];
|
|
110
|
+
if (isValidElement<{ className?: string; children?: ReactNode }>(child)) {
|
|
111
|
+
const cls = child.props.className ?? "";
|
|
112
|
+
const lang = /(?:^|\s)language-(\S+)/.exec(cls)?.[1] ?? null;
|
|
113
|
+
// Fence selalu diakhiri newline oleh parser — buang agar tidak ada
|
|
114
|
+
// baris kosong ekstra di bawah blok.
|
|
115
|
+
const code = textOf(child.props.children).replace(/\n$/, "");
|
|
116
|
+
return <CodeBlock code={code} language={lang} />;
|
|
117
|
+
}
|
|
118
|
+
return <pre className="my-2 overflow-x-auto rounded-lg border bg-muted/60 p-3">{children}</pre>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Tabel + wrapper scroll horizontal. Wrapper `div` diperlukan karena elemen
|
|
123
|
+
* `table` sendiri tidak dapat menjadi kontainer scroll.
|
|
124
|
+
*/
|
|
125
|
+
function MarkdownTable({ node: _node, className, ...props }: MarkdownProps<"table">) {
|
|
126
|
+
return (
|
|
127
|
+
<div className="my-2 max-w-full overflow-x-auto rounded-lg border first:mt-0 last:mb-0">
|
|
128
|
+
<table {...props} className={cn("w-full border-collapse text-left text-base", className)} />
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Peta komponen: gaya memakai token sistem desain (bukan warna hardcoded)
|
|
135
|
+
* supaya tema terang/gelap ikut menyesuaikan.
|
|
136
|
+
*/
|
|
137
|
+
const COMPONENTS = {
|
|
138
|
+
a: MarkdownLink,
|
|
139
|
+
code: MarkdownCode,
|
|
140
|
+
table: MarkdownTable,
|
|
141
|
+
// Blok kode: highlight + scroll horizontal sendiri (lihat `CodeBlock`).
|
|
142
|
+
pre: MarkdownPre,
|
|
143
|
+
p: styled("p", "my-2 first:mt-0 last:mb-0"),
|
|
144
|
+
h1: styled("h1", "mt-4 mb-2 text-lg font-semibold first:mt-0"),
|
|
145
|
+
h2: styled("h2", "mt-4 mb-2 text-lg font-semibold first:mt-0"),
|
|
146
|
+
h3: styled("h3", "mt-3 mb-1.5 text-base font-semibold first:mt-0"),
|
|
147
|
+
h4: styled("h4", "mt-3 mb-1.5 text-base font-semibold first:mt-0"),
|
|
148
|
+
h5: styled("h5", "mt-3 mb-1 text-base font-semibold first:mt-0"),
|
|
149
|
+
h6: styled("h6", "mt-3 mb-1 text-base font-semibold first:mt-0"),
|
|
150
|
+
ul: styled("ul", "my-2 list-disc space-y-1 ps-5 first:mt-0 last:mb-0"),
|
|
151
|
+
ol: styled("ol", "my-2 list-decimal space-y-1 ps-5 first:mt-0 last:mb-0"),
|
|
152
|
+
li: styled("li", "marker:text-muted-foreground"),
|
|
153
|
+
blockquote: styled(
|
|
154
|
+
"blockquote",
|
|
155
|
+
"my-2 border-s-2 border-border ps-3 text-muted-foreground italic first:mt-0 last:mb-0",
|
|
156
|
+
),
|
|
157
|
+
hr: styled("hr", "my-3 border-border"),
|
|
158
|
+
thead: styled("thead", "bg-muted/60"),
|
|
159
|
+
th: styled("th", "border-b px-3 py-2 font-semibold whitespace-nowrap"),
|
|
160
|
+
td: styled("td", "border-b px-3 py-2 align-top"),
|
|
161
|
+
strong: styled("strong", "font-semibold"),
|
|
162
|
+
em: styled("em", "italic"),
|
|
163
|
+
del: styled("del", "text-muted-foreground line-through"),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export interface MarkdownContentProps {
|
|
167
|
+
/** Sumber Markdown (teks balasan model). */
|
|
168
|
+
children: string;
|
|
169
|
+
className?: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function MarkdownContent({ children, className }: MarkdownContentProps) {
|
|
173
|
+
return (
|
|
174
|
+
// react-markdown v10 tidak lagi menerima prop `className` — kelas dipasang
|
|
175
|
+
// pada elemen pembungkus di sini.
|
|
176
|
+
<div className={cn("min-w-0", className)}>
|
|
177
|
+
<ReactMarkdown
|
|
178
|
+
remarkPlugins={[remarkGfm]}
|
|
179
|
+
disallowedElements={DISALLOWED}
|
|
180
|
+
unwrapDisallowed
|
|
181
|
+
components={COMPONENTS}
|
|
182
|
+
>
|
|
183
|
+
{children}
|
|
184
|
+
</ReactMarkdown>
|
|
185
|
+
</div>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { cn } from "@/lib/utils";
|
|
2
|
+
import logoUrl from "@/logo.svg";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Maskot status turn: logo KCG Code + keterangan proses yang sedang
|
|
6
|
+
* berjalan ("Working…", "Thinking…", "read package.json", "Writing…").
|
|
7
|
+
*
|
|
8
|
+
* Tiga mode:
|
|
9
|
+
* - `active` : logo berdenyut (pulse) + label status (turn masih berjalan).
|
|
10
|
+
* - `standby` : logo diam tanpa label — penanda model "hadir" setelah pesan
|
|
11
|
+
* terakhir selesai (masih dirender, tidak disembunyikan).
|
|
12
|
+
* - keduanya off + label kosong -> tidak dirender (belum ada turn).
|
|
13
|
+
*/
|
|
14
|
+
export function Mascot({
|
|
15
|
+
label,
|
|
16
|
+
active = false,
|
|
17
|
+
standby = false,
|
|
18
|
+
className,
|
|
19
|
+
}: {
|
|
20
|
+
/** Teks status; kosong/null -> mode standby (logo saja) bila `standby`. */
|
|
21
|
+
label: string | null;
|
|
22
|
+
/** true -> logo beranimasi (turn masih berjalan). */
|
|
23
|
+
active?: boolean;
|
|
24
|
+
/** true -> tetap tampil sebagai logo diam walau tanpa label (selesai). */
|
|
25
|
+
standby?: boolean;
|
|
26
|
+
className?: string;
|
|
27
|
+
}) {
|
|
28
|
+
const empty = label === null || label === "";
|
|
29
|
+
// Tanpa label & bukan standby -> tidak ada yang perlu dirender.
|
|
30
|
+
if (empty && !standby) return null;
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div
|
|
34
|
+
className={cn("flex items-center gap-2.5 py-1 text-sm text-muted-foreground", className)}
|
|
35
|
+
role="status"
|
|
36
|
+
aria-live="polite"
|
|
37
|
+
>
|
|
38
|
+
<img
|
|
39
|
+
src={logoUrl}
|
|
40
|
+
alt=""
|
|
41
|
+
aria-hidden
|
|
42
|
+
className={cn("size-5 shrink-0", active && "animate-pulse", empty && "opacity-60")}
|
|
43
|
+
/>
|
|
44
|
+
{!empty && <span className="truncate">{label}</span>}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aksi per pesan di footer (saat ini: Copy).
|
|
3
|
+
*
|
|
4
|
+
* Di perangkat ber-mouse (`pointer-fine`) tombol baru terlihat saat pesan
|
|
5
|
+
* di-hover / difokus agar percakapan tetap bersih; di layar sentuh selalu
|
|
6
|
+
* tampil (tidak ada hover). Pola sama dengan menu "..." di `SessionRow`.
|
|
7
|
+
*/
|
|
8
|
+
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
9
|
+
import { useEffect, useState } from "react";
|
|
10
|
+
import { toast } from "sonner";
|
|
11
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
12
|
+
import { cn } from "@/lib/utils";
|
|
13
|
+
|
|
14
|
+
export function CopyMessageButton({ text, className }: { text: string; className?: string }) {
|
|
15
|
+
const [copied, setCopied] = useState(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!copied) return;
|
|
19
|
+
const t = setTimeout(() => setCopied(false), 1500);
|
|
20
|
+
return () => clearTimeout(t);
|
|
21
|
+
}, [copied]);
|
|
22
|
+
|
|
23
|
+
if (text.trim() === "") return null;
|
|
24
|
+
|
|
25
|
+
const copy = async () => {
|
|
26
|
+
try {
|
|
27
|
+
await navigator.clipboard.writeText(text);
|
|
28
|
+
setCopied(true);
|
|
29
|
+
} catch {
|
|
30
|
+
// Clipboard API butuh konteks aman (HTTPS / localhost).
|
|
31
|
+
toast.error("Couldn't copy. Select the text and copy it manually.");
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<Tooltip>
|
|
37
|
+
<TooltipTrigger asChild>
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={() => void copy()}
|
|
41
|
+
aria-label={copied ? "Copied" : "Copy message"}
|
|
42
|
+
className={cn(
|
|
43
|
+
"flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-[opacity,color,background-color] outline-none hover:bg-muted hover:text-foreground focus-visible:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50",
|
|
44
|
+
// Mouse: tampil saat pesan di-hover / ada fokus di dalamnya.
|
|
45
|
+
"pointer-fine:opacity-0 pointer-fine:group-hover/message:opacity-100 pointer-fine:group-focus-within/message:opacity-100",
|
|
46
|
+
copied && "opacity-100 pointer-fine:opacity-100",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
{copied ? (
|
|
51
|
+
<CheckIcon className="size-3.5 text-emerald-600 dark:text-emerald-400" aria-hidden />
|
|
52
|
+
) : (
|
|
53
|
+
<CopyIcon className="size-3.5" aria-hidden />
|
|
54
|
+
)}
|
|
55
|
+
</button>
|
|
56
|
+
</TooltipTrigger>
|
|
57
|
+
<TooltipContent>{copied ? "Copied" : "Copy"}</TooltipContent>
|
|
58
|
+
</Tooltip>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rail penanda pesan user di tepi kiri timeline (ala Gemini).
|
|
3
|
+
*
|
|
4
|
+
* Satu garis pendek & tebal per pesan user, urut dari atas ke bawah. Diklik
|
|
5
|
+
* -> melompat ke pesan itu; di-hover -> garis memanjang dan berbunyi satu
|
|
6
|
+
* nada. Nadanya menaik dari atas ke bawah (tangga nada mayor: do re mi …),
|
|
7
|
+
* jadi menyapu rail terdengar seperti memainkan tangga nada. Penanda yang
|
|
8
|
+
* sedang tampil di layar dibuat lebih kontras sehingga rail sekaligus jadi
|
|
9
|
+
* indikator posisi baca.
|
|
10
|
+
*
|
|
11
|
+
* Catatan implementasi:
|
|
12
|
+
* - Posisi penanda TIDAK proporsional terhadap tinggi dokumen (bukan
|
|
13
|
+
* minimap): jaraknya tetap, ditempatkan di tengah tepi kiri.
|
|
14
|
+
* - Jumlah penanda tidak dibatasi (tiap pesan user selalu dapat satu).
|
|
15
|
+
* Percakapan panjang -> rail melebihi tinggi maksimumnya lalu bisa
|
|
16
|
+
* di-scroll; rail MENGGESER DIRINYA agar penanda aktif selalu terlihat,
|
|
17
|
+
* sehingga penanda lama tetap terjangkau tanpa perlu dibuang.
|
|
18
|
+
* - Penanda aktif ditentukan `IntersectionObserver` di `SessionTimeline`
|
|
19
|
+
* (elemen pesan user), jadi tidak ada perhitungan scroll manual.
|
|
20
|
+
* - Disembunyikan di layar sempit (`hidden lg:flex`): tidak ada ruang di
|
|
21
|
+
* tepi kiri HP, dan rail bukan satu-satunya cara navigasi.
|
|
22
|
+
* - Hanya muncul bila ada ≥ 2 pesan user (satu penanda tidak berguna).
|
|
23
|
+
*/
|
|
24
|
+
import { useEffect, useRef } from "react";
|
|
25
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
26
|
+
import { playTone, scaleFrequency } from "@/lib/tone";
|
|
27
|
+
import { cn } from "@/lib/utils";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Jeda sebelum tooltip muncul. Provider global memakai 0 (tombol ikon perlu
|
|
31
|
+
* respons cepat), tapi di rail penanda berdempetan: tanpa jeda, menyapu
|
|
32
|
+
* pointer melintasinya memunculkan tooltip berkedip-kedip. Nilai ini
|
|
33
|
+
* menimpa provider hanya untuk rail.
|
|
34
|
+
*/
|
|
35
|
+
const TOOLTIP_DELAY_MS = 450;
|
|
36
|
+
|
|
37
|
+
export interface UserMessageMark {
|
|
38
|
+
id: string;
|
|
39
|
+
/** Cuplikan teks pesan untuk tooltip & label aksesibilitas. */
|
|
40
|
+
preview: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MessageRailProps {
|
|
44
|
+
marks: UserMessageMark[];
|
|
45
|
+
/** Id pesan user yang sedang tampil (penanda aktif). */
|
|
46
|
+
activeId: string | null;
|
|
47
|
+
onJump: (id: string) => void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function MessageRail({ marks, activeId, onJump }: MessageRailProps) {
|
|
51
|
+
const listRef = useRef<HTMLUListElement | null>(null);
|
|
52
|
+
|
|
53
|
+
// Rail lebih tinggi dari batasnya -> jaga penanda aktif tetap dalam
|
|
54
|
+
// pandangan. `block: "nearest"` hanya menggeser bila penanda memang di luar
|
|
55
|
+
// area, jadi rail tidak bergerak-gerak saat penanda aktif sudah terlihat.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const list = listRef.current;
|
|
58
|
+
if (list === null || activeId === null) return;
|
|
59
|
+
if (list.scrollHeight <= list.clientHeight) return;
|
|
60
|
+
// Id pesan berbentuk `usr_<uuid>`, tapi selector tetap di-escape bila
|
|
61
|
+
// `CSS.escape` tersedia — id tak lazim tidak boleh merusak rail.
|
|
62
|
+
const escaped = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(activeId) : activeId;
|
|
63
|
+
const el = list.querySelector<HTMLElement>(`[data-mark-id="${escaped}"]`);
|
|
64
|
+
el?.scrollIntoView({ block: "nearest" });
|
|
65
|
+
}, [activeId]);
|
|
66
|
+
|
|
67
|
+
if (marks.length < 2) return null;
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<nav
|
|
71
|
+
aria-label="Your messages"
|
|
72
|
+
className="pointer-events-none absolute top-1/2 left-4 hidden -translate-y-1/2 lg:flex xl:left-6"
|
|
73
|
+
>
|
|
74
|
+
<ul
|
|
75
|
+
ref={listRef}
|
|
76
|
+
className="pointer-events-auto flex max-h-[60vh] flex-col items-center gap-px overflow-y-auto py-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
|
77
|
+
>
|
|
78
|
+
{marks.map((mark, i) => {
|
|
79
|
+
const active = mark.id === activeId;
|
|
80
|
+
return (
|
|
81
|
+
<li key={mark.id} data-mark-id={mark.id}>
|
|
82
|
+
<Tooltip delayDuration={TOOLTIP_DELAY_MS}>
|
|
83
|
+
<TooltipTrigger asChild>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => onJump(mark.id)}
|
|
87
|
+
// Nada hanya saat pointer masuk (bukan tiap gerakan), dan
|
|
88
|
+
// tidak untuk fokus keyboard agar Tab tidak berbunyi.
|
|
89
|
+
onPointerEnter={(e) => {
|
|
90
|
+
// Sentuhan langsung jadi klik — nada hover tidak relevan.
|
|
91
|
+
if (e.pointerType !== "touch") playTone(scaleFrequency(i));
|
|
92
|
+
}}
|
|
93
|
+
aria-label={`Jump to your message ${i + 1}: ${mark.preview}`}
|
|
94
|
+
aria-current={active ? "true" : undefined}
|
|
95
|
+
// Area klik 24px (nyaman ditunjuk), garisnya sendiri tipis.
|
|
96
|
+
className="group/mark flex h-3 w-7 items-center justify-center rounded-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40"
|
|
97
|
+
>
|
|
98
|
+
<span
|
|
99
|
+
aria-hidden
|
|
100
|
+
className={cn(
|
|
101
|
+
// Pendek & agak tebal; memanjang saat hover / fokus.
|
|
102
|
+
"block h-[3px] rounded-full transition-[width,background-color] duration-200",
|
|
103
|
+
"group-hover/mark:w-7 group-focus-visible/mark:w-7",
|
|
104
|
+
active
|
|
105
|
+
? "w-[18px] bg-foreground/70"
|
|
106
|
+
: "w-3.5 bg-foreground/30 group-hover/mark:bg-foreground/60",
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
</button>
|
|
110
|
+
</TooltipTrigger>
|
|
111
|
+
<TooltipContent side="right" className="max-w-56">
|
|
112
|
+
<span className="line-clamp-2">{mark.preview}</span>
|
|
113
|
+
</TooltipContent>
|
|
114
|
+
</Tooltip>
|
|
115
|
+
</li>
|
|
116
|
+
);
|
|
117
|
+
})}
|
|
118
|
+
</ul>
|
|
119
|
+
</nav>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pemilih model LLM untuk sebuah Session (meniru pilihan model di opencode).
|
|
3
|
+
*
|
|
4
|
+
* Dropdown `Select` sebelumnya menyulitkan saat provider punya puluhan model:
|
|
5
|
+
* tidak ada pencarian, dan di layar HP daftarnya panjang sekali. Sekarang
|
|
6
|
+
* trigger membuka dialog berisi `ModelSearchList` (kolom cari + daftar
|
|
7
|
+
* berkelompok per provider, model aktif ditandai).
|
|
8
|
+
*
|
|
9
|
+
* - Daftar model diambil dari `GET /api/projects/:id/models` — dimuat saat
|
|
10
|
+
* dialog dibuka (fetch ulang tiap kali agar config opencode yang baru
|
|
11
|
+
* ikut terbaca; server headless tidak di-spawn bila user tak pernah
|
|
12
|
+
* membuka picker).
|
|
13
|
+
* - Perubahan dikirim via `PUT /api/sessions/:id` dan berlaku pada prompt
|
|
14
|
+
* berikutnya tanpa restart Session.
|
|
15
|
+
* - Opsi "Model default" mengirim `model: null` (opencode memakai
|
|
16
|
+
* konfigurasi default-nya).
|
|
17
|
+
* - `sessionId: null` (composer homepage, Session belum dibuat): pilihan
|
|
18
|
+
* hanya diteruskan ke `onChanged` tanpa PUT — dipakai saat membuat Session.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { ChevronDownIcon, ChevronsUpDownIcon, SparklesIcon } from "lucide-react";
|
|
22
|
+
import { useEffect, useState } from "react";
|
|
23
|
+
import { ModelSearchList } from "@/components/sessions/ModelSearchList";
|
|
24
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
25
|
+
import { Button } from "@/components/ui/button";
|
|
26
|
+
import {
|
|
27
|
+
Dialog,
|
|
28
|
+
DialogContent,
|
|
29
|
+
DialogDescription,
|
|
30
|
+
DialogHeader,
|
|
31
|
+
DialogTitle,
|
|
32
|
+
} from "@/components/ui/dialog";
|
|
33
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
34
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
35
|
+
import { activeModelLabel, keyOfSessionModel, shortModelName } from "@/lib/model-picker";
|
|
36
|
+
import type { ModelOption } from "@/server/services/opencode-client";
|
|
37
|
+
import type { SessionModel } from "@/types";
|
|
38
|
+
|
|
39
|
+
export interface ModelPickerProps {
|
|
40
|
+
projectId: string;
|
|
41
|
+
/** null = Session belum ada; pilihan tidak disimpan ke server. */
|
|
42
|
+
sessionId: string | null;
|
|
43
|
+
/** Model aktif saat ini; null = default opencode. */
|
|
44
|
+
model: SessionModel | null;
|
|
45
|
+
/** Dipanggil setelah server menerima perubahan model. */
|
|
46
|
+
onChanged: (model: SessionModel | null) => void;
|
|
47
|
+
/**
|
|
48
|
+
* Bentuk trigger:
|
|
49
|
+
* - `heading` (default di header Session): judul besar tanpa border, nama
|
|
50
|
+
* model dipendekkan (`shortModelName`) — pola aplikasi chat mobile.
|
|
51
|
+
* - `chip`: tombol outline ringkas untuk ditempel di dalam toolbar.
|
|
52
|
+
* - `inline`: teks polos + chevron tanpa border/latar (baris di bawah
|
|
53
|
+
* composer homepage).
|
|
54
|
+
*/
|
|
55
|
+
variant?: "heading" | "chip" | "inline";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function ModelPicker({
|
|
59
|
+
projectId,
|
|
60
|
+
sessionId,
|
|
61
|
+
model,
|
|
62
|
+
onChanged,
|
|
63
|
+
variant = "chip",
|
|
64
|
+
}: ModelPickerProps) {
|
|
65
|
+
const [open, setOpen] = useState(false);
|
|
66
|
+
const [models, setModels] = useState<ModelOption[] | null>(null);
|
|
67
|
+
const [loading, setLoading] = useState(false);
|
|
68
|
+
const [saving, setSaving] = useState(false);
|
|
69
|
+
const [error, setError] = useState<string | null>(null);
|
|
70
|
+
|
|
71
|
+
const label = activeModelLabel(model, models ?? []);
|
|
72
|
+
/** Judul header: buang prefiks vendor & potong bila panjang. */
|
|
73
|
+
const headingLabel = shortModelName(label);
|
|
74
|
+
|
|
75
|
+
// Daftar model selalu di-fetch ulang saat dialog dibuka — list di server
|
|
76
|
+
// headless bisa berubah bila user mengedit config opencode (`opencode serve`
|
|
77
|
+
// tidak hot-reload). Lazy: tidak di-spawn saat halaman Session dibuka.
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open) return;
|
|
80
|
+
setLoading(true);
|
|
81
|
+
setError(null);
|
|
82
|
+
apiFetch(`/api/projects/${projectId}/models`)
|
|
83
|
+
.then(async (res) => {
|
|
84
|
+
const body = (await res.json()) as { models: ModelOption[] };
|
|
85
|
+
setModels(body.models);
|
|
86
|
+
})
|
|
87
|
+
.catch((e: unknown) => {
|
|
88
|
+
setError(e instanceof ApiError ? e.message : "Failed to load model list");
|
|
89
|
+
setModels([]);
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
setLoading(false);
|
|
93
|
+
});
|
|
94
|
+
}, [open, projectId]);
|
|
95
|
+
|
|
96
|
+
const choose = async (next: SessionModel | null) => {
|
|
97
|
+
setSaving(true);
|
|
98
|
+
setError(null);
|
|
99
|
+
try {
|
|
100
|
+
if (sessionId !== null) {
|
|
101
|
+
await apiFetch(`/api/sessions/${sessionId}`, {
|
|
102
|
+
method: "PUT",
|
|
103
|
+
body: JSON.stringify({ model: next }),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
onChanged(next);
|
|
107
|
+
setOpen(false);
|
|
108
|
+
} catch (e) {
|
|
109
|
+
setError(e instanceof ApiError ? e.message : "Failed to change model");
|
|
110
|
+
} finally {
|
|
111
|
+
setSaving(false);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<>
|
|
117
|
+
{variant === "heading" ? (
|
|
118
|
+
// Judul header: nama model + chevron sebagai isyarat "bisa diganti".
|
|
119
|
+
<Button
|
|
120
|
+
type="button"
|
|
121
|
+
variant="ghost"
|
|
122
|
+
onClick={() => setOpen(true)}
|
|
123
|
+
disabled={saving}
|
|
124
|
+
className="h-8 max-w-full min-w-0 gap-1 px-2 text-base font-semibold"
|
|
125
|
+
aria-label={`Model: ${label}. Tap to change model`}
|
|
126
|
+
title={`Model: ${label}`}
|
|
127
|
+
>
|
|
128
|
+
<span className="min-w-0 truncate">{headingLabel}</span>
|
|
129
|
+
{saving ? (
|
|
130
|
+
<Spinner className="size-4 shrink-0" />
|
|
131
|
+
) : (
|
|
132
|
+
<ChevronDownIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
133
|
+
)}
|
|
134
|
+
</Button>
|
|
135
|
+
) : variant === "inline" ? (
|
|
136
|
+
<Button
|
|
137
|
+
type="button"
|
|
138
|
+
variant="ghost"
|
|
139
|
+
size="sm"
|
|
140
|
+
onClick={() => setOpen(true)}
|
|
141
|
+
disabled={saving}
|
|
142
|
+
className="h-9 max-w-full min-w-0 gap-1 px-2 font-normal text-muted-foreground hover:text-foreground"
|
|
143
|
+
aria-label={`Model: ${label}. Tap to change`}
|
|
144
|
+
title={label}
|
|
145
|
+
>
|
|
146
|
+
<span className="min-w-0 truncate">{headingLabel}</span>
|
|
147
|
+
{saving ? <Spinner data-icon="inline-end" /> : <ChevronDownIcon data-icon="inline-end" />}
|
|
148
|
+
</Button>
|
|
149
|
+
) : (
|
|
150
|
+
<Button
|
|
151
|
+
type="button"
|
|
152
|
+
variant="outline"
|
|
153
|
+
size="sm"
|
|
154
|
+
onClick={() => setOpen(true)}
|
|
155
|
+
disabled={saving}
|
|
156
|
+
className="h-9 max-w-full min-w-0 justify-between gap-1.5 px-2.5 font-normal"
|
|
157
|
+
aria-label={`Model: ${label}. Tap to change`}
|
|
158
|
+
title={label}
|
|
159
|
+
>
|
|
160
|
+
<SparklesIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
161
|
+
<span className="min-w-0 truncate text-sm">{label}</span>
|
|
162
|
+
{saving ? (
|
|
163
|
+
<Spinner className="size-4 shrink-0" />
|
|
164
|
+
) : (
|
|
165
|
+
<ChevronsUpDownIcon className="size-4 shrink-0 text-muted-foreground" />
|
|
166
|
+
)}
|
|
167
|
+
</Button>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
<Dialog
|
|
171
|
+
open={open}
|
|
172
|
+
onOpenChange={(next) => {
|
|
173
|
+
if (saving) return;
|
|
174
|
+
setOpen(next);
|
|
175
|
+
if (!next) setError(null);
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{/* Tinggi dipatok agar daftar panjang men-scroll di dalam dialog,
|
|
179
|
+
bukan menumbuhkan dialog melewati layar HP. */}
|
|
180
|
+
<DialogContent className="flex h-[70dvh] max-h-[560px] flex-col sm:max-w-md">
|
|
181
|
+
<DialogHeader>
|
|
182
|
+
<DialogTitle>Choose a model</DialogTitle>
|
|
183
|
+
<DialogDescription>
|
|
184
|
+
{sessionId === null
|
|
185
|
+
? "Used by the new session you start from here."
|
|
186
|
+
: "Applies to the next messages — no session restart needed."}
|
|
187
|
+
</DialogDescription>
|
|
188
|
+
</DialogHeader>
|
|
189
|
+
|
|
190
|
+
{error && (
|
|
191
|
+
<Alert variant="destructive">
|
|
192
|
+
<AlertTitle>Failed</AlertTitle>
|
|
193
|
+
<AlertDescription>{error}</AlertDescription>
|
|
194
|
+
</Alert>
|
|
195
|
+
)}
|
|
196
|
+
|
|
197
|
+
<ModelSearchList
|
|
198
|
+
models={models}
|
|
199
|
+
loading={loading}
|
|
200
|
+
activeKey={keyOfSessionModel(model)}
|
|
201
|
+
onSelect={(next) => void choose(next)}
|
|
202
|
+
disabled={saving}
|
|
203
|
+
className="flex-1"
|
|
204
|
+
/>
|
|
205
|
+
</DialogContent>
|
|
206
|
+
</Dialog>
|
|
207
|
+
</>
|
|
208
|
+
);
|
|
209
|
+
}
|