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,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composer input Session — textarea prompt, lampiran file (gambar maupun
|
|
3
|
+
* file lain; lewat tombol, paste, atau drag & drop), @file autocomplete,
|
|
4
|
+
* mode agent, serta tombol kirim/stop.
|
|
5
|
+
*
|
|
6
|
+
* Drag & drop: overlay "Add anything" dipasang `SessionView` (menutupi
|
|
7
|
+
* seluruh layar Session) dan memanggil `addFiles(files, fromRect)` lewat
|
|
8
|
+
* ref. Dengan `fromRect`, file baru dianimasikan dari kartu overlay ke
|
|
9
|
+
* deretan lampiran (`DropPlate`).
|
|
10
|
+
*
|
|
11
|
+
* Seluruh state input (teks, gambar pending, mention, agent mode) hidup di
|
|
12
|
+
* komponen ini; engine (`useSessionChat`) hanya menyuplai izin input & kanal
|
|
13
|
+
* kirim. Saat model merespon tombol kirim berubah jadi tombol Stop
|
|
14
|
+
* (mengirim `{ type: "interrupt" }`) — menghentikan balasan saja,
|
|
15
|
+
* Session tetap berjalan.
|
|
16
|
+
*
|
|
17
|
+
* UI punya dua varian:
|
|
18
|
+
* - Desktop: attach image | textarea | AgentPicker | kirim/stop.
|
|
19
|
+
* - Mobile: satu tombol aksi (attach image + agent mode) lewat `Sheet`;
|
|
20
|
+
* saat input lebih dari satu baris, textarea pindah ke baris sendiri.
|
|
21
|
+
*/
|
|
22
|
+
import { PaperclipIcon, PlusIcon, SendHorizontalIcon, SquareIcon } from "lucide-react";
|
|
23
|
+
import {
|
|
24
|
+
type Ref,
|
|
25
|
+
useCallback,
|
|
26
|
+
useEffect,
|
|
27
|
+
useImperativeHandle,
|
|
28
|
+
useLayoutEffect,
|
|
29
|
+
useRef,
|
|
30
|
+
useState,
|
|
31
|
+
} from "react";
|
|
32
|
+
import { AgentPicker } from "@/components/sessions/AgentPicker";
|
|
33
|
+
import { ComposerActionSheet } from "@/components/sessions/ComposerActionSheet";
|
|
34
|
+
import { DropPlate } from "@/components/sessions/DropPlate";
|
|
35
|
+
import { FileMentionDropdown } from "@/components/sessions/FileMentionDropdown";
|
|
36
|
+
import {
|
|
37
|
+
type PendingAttachment,
|
|
38
|
+
PendingAttachments,
|
|
39
|
+
} from "@/components/sessions/PendingAttachments";
|
|
40
|
+
import { Button } from "@/components/ui/button";
|
|
41
|
+
import { Sheet, SheetTrigger } from "@/components/ui/sheet";
|
|
42
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
43
|
+
import { useAgentPicker } from "@/hooks/useAgentPicker";
|
|
44
|
+
import { useIsMobile } from "@/hooks/useMediaQuery";
|
|
45
|
+
import { useFileMention } from "@/hooks/useMention";
|
|
46
|
+
import type { WsConnectionStatus } from "@/hooks/useWebSocket";
|
|
47
|
+
import { ApiError, apiErrorMessage, apiUploadFile } from "@/lib/api";
|
|
48
|
+
import { isPreviewableImage, MAX_ATTACHMENT_BYTES } from "@/lib/attachments";
|
|
49
|
+
import { composerPlaceholder, extractMentionedFiles, splitComposerMentions } from "@/lib/composer";
|
|
50
|
+
import { CONVERSATION_MAX_W } from "@/lib/layout";
|
|
51
|
+
import { cn } from "@/lib/utils";
|
|
52
|
+
import type { Session } from "@/types";
|
|
53
|
+
import type { ClientMessage } from "@/ws-protocol";
|
|
54
|
+
|
|
55
|
+
/** Handle imperatif composer (dipakai overlay drag & drop). */
|
|
56
|
+
export interface SessionComposerHandle {
|
|
57
|
+
/** Lampirkan file; `from` = rect asal animasi (kartu overlay). */
|
|
58
|
+
addFiles: (files: File[], from?: DOMRect | null) => void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SessionComposerProps {
|
|
62
|
+
ref?: Ref<SessionComposerHandle>;
|
|
63
|
+
session: Session;
|
|
64
|
+
/** Engine: input boleh dipakai (koneksi + Session running + tidak sibuk). */
|
|
65
|
+
inputAllowed: boolean;
|
|
66
|
+
/** Model sedang merespon (untuk placeholder & tombol Stop). */
|
|
67
|
+
busy: boolean;
|
|
68
|
+
/** Model merespon DAN Session berjalan — kirim berubah jadi Stop. */
|
|
69
|
+
generating: boolean;
|
|
70
|
+
wsStatus: WsConnectionStatus;
|
|
71
|
+
/** Kirim pesan Client -> Server (engine). */
|
|
72
|
+
send: (msg: ClientMessage) => void;
|
|
73
|
+
/** Set error banner global (upload/kirim gagal). */
|
|
74
|
+
reportError: (message: string | null) => void;
|
|
75
|
+
/** Hentikan balasan model (interrupt) — Session tetap aktif. */
|
|
76
|
+
onInterrupt: () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function SessionComposer({
|
|
80
|
+
ref,
|
|
81
|
+
session,
|
|
82
|
+
inputAllowed,
|
|
83
|
+
busy,
|
|
84
|
+
generating,
|
|
85
|
+
wsStatus,
|
|
86
|
+
send,
|
|
87
|
+
reportError,
|
|
88
|
+
onInterrupt,
|
|
89
|
+
}: SessionComposerProps) {
|
|
90
|
+
/** Layar sempit: placeholder composer dipendekkan agar tidak terpotong. */
|
|
91
|
+
const isMobile = useIsMobile();
|
|
92
|
+
const [text, setText] = useState("");
|
|
93
|
+
/**
|
|
94
|
+
* File yang akan dilampirkan ke pesan berikutnya (belum di-upload).
|
|
95
|
+
* Thumbnail gambar memakai object URL lokal; upload terjadi saat kirim.
|
|
96
|
+
*/
|
|
97
|
+
const [pendingImages, setPendingImages] = useState<PendingAttachment[]>([]);
|
|
98
|
+
/**
|
|
99
|
+
* Animasi plate drop yang sedang berjalan: item-nya sudah ada di
|
|
100
|
+
* `pendingImages` (tak terlihat, jadi tujuan animasi) sampai plate
|
|
101
|
+
* mendarat. `targets` diisi setelah layout diukur.
|
|
102
|
+
*/
|
|
103
|
+
const [plate, setPlate] = useState<{
|
|
104
|
+
from: DOMRect;
|
|
105
|
+
keys: string[];
|
|
106
|
+
targets: { item: PendingAttachment; rect: DOMRect }[] | null;
|
|
107
|
+
/** Kotak composer: plate menyusut menjadi kotak ini lalu larut ke dalamnya. */
|
|
108
|
+
box: DOMRect | null;
|
|
109
|
+
} | null>(null);
|
|
110
|
+
/** Kotak composer (tujuan akhir latar plate). */
|
|
111
|
+
const boxRef = useRef<HTMLDivElement | null>(null);
|
|
112
|
+
/** Elemen tiap lampiran (untuk mengukur tujuan animasi plate). */
|
|
113
|
+
const attachmentEls = useRef(new Map<string, HTMLElement>());
|
|
114
|
+
/** Upload lampiran sedang berjalan — cegah kirim ganda. */
|
|
115
|
+
const [sending, setSending] = useState(false);
|
|
116
|
+
/**
|
|
117
|
+
* Input butuh lebih dari satu baris — tombol aksi & kirim/stop pindah ke
|
|
118
|
+
* baris di bawah textarea (ala Gemini), di desktop maupun mobile.
|
|
119
|
+
*/
|
|
120
|
+
const [multiline, setMultiline] = useState(false);
|
|
121
|
+
/** Sheet aksi mobile (attach image + agent mode) di tombol tunggal kiri input. */
|
|
122
|
+
const [actionSheetOpen, setActionSheetOpen] = useState(false);
|
|
123
|
+
/** Agent (mode) pilihan Session — build/plan/agent kustom; null = default. */
|
|
124
|
+
const [agent, setAgent] = useState<string | null>(session.agent);
|
|
125
|
+
/**
|
|
126
|
+
* Fetch + pick daftar agent untuk Sheet aksi mobile — dipakai langsung
|
|
127
|
+
* (tanpa nested popover) supaya tap satu mode langsung menerapkannya.
|
|
128
|
+
*/
|
|
129
|
+
const agentPicker = useAgentPicker(session.projectId, session.id, setAgent);
|
|
130
|
+
// Muat daftar agent begitu Sheet aksi mobile pertama dibuka (lazy, sama
|
|
131
|
+
// seperti popover AgentPicker di desktop).
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
if (actionSheetOpen) agentPicker.load();
|
|
134
|
+
}, [actionSheetOpen, agentPicker.load]);
|
|
135
|
+
/**
|
|
136
|
+
* Autocomplete `@file`: deteksi token @query di sekitar kursor + daftar
|
|
137
|
+
* saran dari server (index file milik opencode, seperti `@` di TUI-nya).
|
|
138
|
+
*/
|
|
139
|
+
const mention = useFileMention({ endpoint: `/api/sessions/${session.id}/files` });
|
|
140
|
+
/**
|
|
141
|
+
* Terapkan teks hasil pemilihan saran (keyboard maupun mouse) ke state
|
|
142
|
+
* input + kembalikan fokus ke textarea.
|
|
143
|
+
*/
|
|
144
|
+
const applyPicked = useCallback((next: string) => {
|
|
145
|
+
setText(next);
|
|
146
|
+
requestAnimationFrame(() => textareaRef.current?.focus());
|
|
147
|
+
}, []);
|
|
148
|
+
useEffect(() => mention.setOnPick(applyPicked), [mention, applyPicked]);
|
|
149
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
150
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
|
151
|
+
/** Lapisan sorot @file di belakang textarea (scroll disinkronkan). */
|
|
152
|
+
const highlightRef = useRef<HTMLDivElement | null>(null);
|
|
153
|
+
const formRef = useRef<HTMLFormElement | null>(null);
|
|
154
|
+
/** Pengukur lebar teks satu baris — font identik dengan textarea. */
|
|
155
|
+
const measureRef = useRef<HTMLSpanElement | null>(null);
|
|
156
|
+
/**
|
|
157
|
+
* Perkiraan lebar kontrol yang berbagi baris dengan textarea saat inline —
|
|
158
|
+
* SENGAJA dilebihkan (attach + agent + kirim di desktop; plus + kirim di
|
|
159
|
+
* mobile) agar teks di dekat batas selalu dianggap multiline, bukan
|
|
160
|
+
* pas-pasan (sumber flip-flop). Mobile lebih kecil karena barisnya hanya
|
|
161
|
+
* dua tombol.
|
|
162
|
+
*/
|
|
163
|
+
const INLINE_CONTROLS_RESERVE_PX = isMobile ? 88 : 192;
|
|
164
|
+
/**
|
|
165
|
+
* Putuskan `multiline` dari LEBAR TEKS terhadap lebar baris inline — BUKAN
|
|
166
|
+
* dari scrollHeight textarea. Mengukur tinggi membuat keputusan bergantung
|
|
167
|
+
* pada layout AKTIF: teks yang wrap di baris inline (sempit, dipotong
|
|
168
|
+
* tombol) bisa muat satu baris saat stacked (lebar penuh) → stacked →
|
|
169
|
+
* remeasure muat → inline → wrap lagi → … (flip-flop tiap ketik). Lebar
|
|
170
|
+
* teks (span tersembunyi) vs lebar form TIDAK berubah saat layout
|
|
171
|
+
* berganti, sehingga keputusannya selalu titik tetap yang stabil.
|
|
172
|
+
*/
|
|
173
|
+
const updateMultiline = useCallback(() => {
|
|
174
|
+
const span = measureRef.current;
|
|
175
|
+
const form = formRef.current;
|
|
176
|
+
if (span === null || form === null) return;
|
|
177
|
+
const inlineTextWidth =
|
|
178
|
+
form.clientWidth -
|
|
179
|
+
16 /* p-2 kontainer */ -
|
|
180
|
+
16 /* px-2 textarea */ -
|
|
181
|
+
INLINE_CONTROLS_RESERVE_PX;
|
|
182
|
+
setMultiline(span.scrollWidth > inlineTextWidth);
|
|
183
|
+
}, [INLINE_CONTROLS_RESERVE_PX]);
|
|
184
|
+
/**
|
|
185
|
+
* Tumbuhkan tinggi textarea mengikuti isi (maks lewat CSS max-h). Textarea
|
|
186
|
+
* KOSONG dikembalikan ke tinggi CSS-nya (satu baris): di beberapa browser
|
|
187
|
+
* `scrollHeight` ikut menghitung placeholder yang wrap, sehingga textarea
|
|
188
|
+
* kosong tumbuh dua baris & jadi scrollable (mis. saat lampiran gambar
|
|
189
|
+
* mempersempit ruang teks).
|
|
190
|
+
*/
|
|
191
|
+
const autoResize = useCallback((el: HTMLTextAreaElement) => {
|
|
192
|
+
el.style.height = "auto";
|
|
193
|
+
if (el.value === "") return;
|
|
194
|
+
el.style.height = `${el.scrollHeight}px`;
|
|
195
|
+
}, []);
|
|
196
|
+
// Sinkronkan pengukur lebar + keputusan stacked + tinggi textarea untuk
|
|
197
|
+
// perubahan `text` apa pun (ketik, autocomplete @file, reset kirim).
|
|
198
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: jumlah lampiran jadi pemicu (tombol kirim muncul -> lebar textarea berubah)
|
|
199
|
+
useLayoutEffect(() => {
|
|
200
|
+
const span = measureRef.current;
|
|
201
|
+
if (span !== null) span.textContent = text;
|
|
202
|
+
updateMultiline();
|
|
203
|
+
if (textareaRef.current) autoResize(textareaRef.current);
|
|
204
|
+
// Lampiran menambah/menghapus tombol kirim -> lebar textarea berubah.
|
|
205
|
+
}, [text, pendingImages.length, updateMultiline, autoResize]);
|
|
206
|
+
// Lebar form berubah (resize window / orientasi HP) → keputusan stacked
|
|
207
|
+
// dihitung ulang terhadap lebar terbaru.
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
const form = formRef.current;
|
|
210
|
+
if (form === null || typeof ResizeObserver === "undefined") return;
|
|
211
|
+
const observer = new ResizeObserver(updateMultiline);
|
|
212
|
+
observer.observe(form);
|
|
213
|
+
return () => observer.disconnect();
|
|
214
|
+
}, [updateMultiline]);
|
|
215
|
+
|
|
216
|
+
/** Cache path yang pernah disarankan autocomplete (validitas @ref saat kirim). */
|
|
217
|
+
const suggestedCacheRef = useRef<Set<string>>(new Set());
|
|
218
|
+
if (mention.suggestions.length > 0) {
|
|
219
|
+
for (const s of mention.suggestions) suggestedCacheRef.current.add(s);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Terima file dari file picker / paste / drag & drop, validasi, lalu
|
|
224
|
+
* simpan ke antrean lampiran. File kosong / folder / terlalu besar ditolak
|
|
225
|
+
* dengan pesan. `from` (rect kartu overlay) -> animasi plate.
|
|
226
|
+
*/
|
|
227
|
+
const addFiles = useCallback(
|
|
228
|
+
(files: Iterable<File>, from?: DOMRect | null) => {
|
|
229
|
+
const picked: PendingAttachment[] = [];
|
|
230
|
+
for (const file of files) {
|
|
231
|
+
// Folder yang di-drop muncul sebagai File 0 byte tanpa tipe.
|
|
232
|
+
if (file.size === 0) {
|
|
233
|
+
reportError(`"${file.name}" is empty or is a folder.`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (file.size > MAX_ATTACHMENT_BYTES) {
|
|
237
|
+
reportError(`"${file.name}" exceeds the 20 MiB limit.`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
picked.push({
|
|
241
|
+
key: crypto.randomUUID(),
|
|
242
|
+
file,
|
|
243
|
+
previewUrl: isPreviewableImage(file.type) ? URL.createObjectURL(file) : null,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
if (picked.length === 0) return;
|
|
247
|
+
setPendingImages((prev) => [...prev, ...picked]);
|
|
248
|
+
if (from) setPlate({ from, keys: picked.map((p) => p.key), targets: null, box: null });
|
|
249
|
+
},
|
|
250
|
+
[reportError],
|
|
251
|
+
);
|
|
252
|
+
useImperativeHandle(ref, () => ({ addFiles }), [addFiles]);
|
|
253
|
+
|
|
254
|
+
// Item plate sudah dirender (tak terlihat) -> ukur posisi akhirnya. Deretan
|
|
255
|
+
// lampiran di-scroll ke item baru dulu agar tujuan animasi terlihat.
|
|
256
|
+
useLayoutEffect(() => {
|
|
257
|
+
if (!plate || plate.targets) return;
|
|
258
|
+
const firstEl = attachmentEls.current.get(plate.keys[0] ?? "");
|
|
259
|
+
firstEl?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
260
|
+
const targets = plate.keys.flatMap((key) => {
|
|
261
|
+
const item = pendingImages.find((p) => p.key === key);
|
|
262
|
+
const el = attachmentEls.current.get(key);
|
|
263
|
+
return item && el ? [{ item, rect: el.getBoundingClientRect() }] : [];
|
|
264
|
+
});
|
|
265
|
+
if (targets.length === 0) setPlate(null);
|
|
266
|
+
else setPlate({ ...plate, targets, box: boxRef.current?.getBoundingClientRect() ?? null });
|
|
267
|
+
}, [plate, pendingImages]);
|
|
268
|
+
|
|
269
|
+
const registerAttachmentEl = useCallback((key: string, el: HTMLElement | null) => {
|
|
270
|
+
if (el) attachmentEls.current.set(key, el);
|
|
271
|
+
else attachmentEls.current.delete(key);
|
|
272
|
+
}, []);
|
|
273
|
+
const endPlate = useCallback(() => setPlate(null), []);
|
|
274
|
+
|
|
275
|
+
const removeAttachment = useCallback((key: string) => {
|
|
276
|
+
setPendingImages((prev) => {
|
|
277
|
+
const target = prev.find((p) => p.key === key);
|
|
278
|
+
if (target?.previewUrl) URL.revokeObjectURL(target.previewUrl);
|
|
279
|
+
return prev.filter((p) => p.key !== key);
|
|
280
|
+
});
|
|
281
|
+
}, []);
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Input dapat dipakai = izin engine (koneksi + running + tidak busy) DAN
|
|
285
|
+
* tidak sedang upload lampiran.
|
|
286
|
+
*/
|
|
287
|
+
const canInput = inputAllowed && !sending;
|
|
288
|
+
const canSubmit = canInput && (text.trim() !== "" || pendingImages.length > 0);
|
|
289
|
+
|
|
290
|
+
const submitText = async (event: React.FormEvent<HTMLFormElement>) => {
|
|
291
|
+
event.preventDefault();
|
|
292
|
+
if (!canInput || sending) return;
|
|
293
|
+
if (text.trim() === "" && pendingImages.length === 0) return;
|
|
294
|
+
setSending(true);
|
|
295
|
+
try {
|
|
296
|
+
// Referensi @path dikenal dijadikan part `file` — teks asli tidak
|
|
297
|
+
// diubah (tetap tampil di bubble); part `file` hanya penanda tambahan
|
|
298
|
+
// agar isi file benar-benar dibaca opencode.
|
|
299
|
+
const files = extractMentionedFiles(text, suggestedCacheRef.current);
|
|
300
|
+
// Upload gambar dulu; id lampiran dipakai pesan WS berikutnya.
|
|
301
|
+
const images: string[] = [];
|
|
302
|
+
for (const img of pendingImages) {
|
|
303
|
+
const up = await apiUploadFile(`/api/sessions/${session.id}/uploads`, img.file);
|
|
304
|
+
images.push(up.id);
|
|
305
|
+
}
|
|
306
|
+
send({ type: "input", sessionId: session.id, text: text.trim(), files, images });
|
|
307
|
+
for (const img of pendingImages) if (img.previewUrl) URL.revokeObjectURL(img.previewUrl);
|
|
308
|
+
setPendingImages([]);
|
|
309
|
+
setText("");
|
|
310
|
+
mention.close();
|
|
311
|
+
} catch (e) {
|
|
312
|
+
reportError(e instanceof ApiError ? e.message : apiErrorMessage("ERROR"));
|
|
313
|
+
} finally {
|
|
314
|
+
setSending(false);
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** Lampirkan file yang di-paste (screenshot, file dari file manager). */
|
|
319
|
+
const handlePaste = useCallback(
|
|
320
|
+
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
|
321
|
+
const files = [...e.clipboardData.files];
|
|
322
|
+
if (files.length === 0) return;
|
|
323
|
+
// Menyalin file dari file manager juga menaruh path/URI-nya sebagai
|
|
324
|
+
// teks di clipboard — cegah paste bawaan agar path tidak ikut masuk
|
|
325
|
+
// ke input; filenya cukup jadi lampiran.
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
addFiles(files);
|
|
328
|
+
},
|
|
329
|
+
[addFiles],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
/* Input bebas. `pb-[env(safe-area-inset-bottom)]` menjaga composer tidak
|
|
334
|
+
tertutup home indicator saat dipasang sebagai PWA di HP. */
|
|
335
|
+
// `overflow-y-hidden` + `scrollbar-gutter-stable`: footer mencadangkan
|
|
336
|
+
// lebar scrollbar yang SAMA dengan area scroll timeline di atasnya,
|
|
337
|
+
// sehingga keduanya punya titik tengah horizontal yang sama. Tanpa ini
|
|
338
|
+
// composer tampak bergeser ke kanan dari kolom percakapan.
|
|
339
|
+
<footer className="shrink-0 scrollbar-gutter-stable overflow-y-hidden px-3 py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] sm:px-4">
|
|
340
|
+
{/* Pemilih model kini jadi judul header — composer fokus ke input saja
|
|
341
|
+
supaya area mengetik di HP tidak terpotong baris tambahan. */}
|
|
342
|
+
{/* Lebar = lebar TEKS percakapan (`CONVERSATION_MAX_W`), bukan kotak
|
|
343
|
+
timeline — tepi kiri-kanan input sejajar dengan isi chat. */}
|
|
344
|
+
<form
|
|
345
|
+
ref={formRef}
|
|
346
|
+
onSubmit={submitText}
|
|
347
|
+
className={cn("relative mx-auto flex w-full flex-col", CONVERSATION_MAX_W)}
|
|
348
|
+
>
|
|
349
|
+
{/* Pengukur lebar teks satu baris — font identik dengan textarea,
|
|
350
|
+
tak terlihat & tanpa wrap (whitespace-pre) sehingga scrollWidth =
|
|
351
|
+
lebar alami teks. Dipakai keputusan stacked yang stabil. */}
|
|
352
|
+
<span
|
|
353
|
+
ref={measureRef}
|
|
354
|
+
aria-hidden
|
|
355
|
+
className="pointer-events-none absolute top-0 invisible whitespace-pre text-base leading-6"
|
|
356
|
+
/>
|
|
357
|
+
{/* Composer compact: image picker di kiri, input prompt di tengah,
|
|
358
|
+
lalu mode agent di kanan. Model tetap dipilih dari header. */}
|
|
359
|
+
<div
|
|
360
|
+
ref={boxRef}
|
|
361
|
+
className={cn(
|
|
362
|
+
"rounded-xl border bg-card/80 p-2 shadow-sm transition-colors",
|
|
363
|
+
"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/20",
|
|
364
|
+
!canInput && "opacity-70",
|
|
365
|
+
)}
|
|
366
|
+
>
|
|
367
|
+
{/* Lampiran gambar: di dalam kotak composer, di atas input —
|
|
368
|
+
terbaca sebagai bagian dari pesan yang akan dikirim. */}
|
|
369
|
+
{pendingImages.length > 0 && (
|
|
370
|
+
<PendingAttachments
|
|
371
|
+
items={pendingImages}
|
|
372
|
+
onRemove={removeAttachment}
|
|
373
|
+
hiddenKeys={plate ? new Set(plate.keys) : undefined}
|
|
374
|
+
registerEl={registerAttachmentEl}
|
|
375
|
+
/>
|
|
376
|
+
)}
|
|
377
|
+
{plate?.targets && (
|
|
378
|
+
<DropPlate from={plate.from} items={plate.targets} box={plate.box} onDone={endPlate} />
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{/* Dropdown saran @file (muncul di atas input saat token @ aktif). */}
|
|
382
|
+
{mention.mention !== null && (mention.suggestions.length > 0 || mention.loading) && (
|
|
383
|
+
<FileMentionDropdown
|
|
384
|
+
loading={mention.loading}
|
|
385
|
+
error={mention.error}
|
|
386
|
+
suggestions={mention.suggestions}
|
|
387
|
+
highlighted={mention.highlighted}
|
|
388
|
+
onPick={(i) => {
|
|
389
|
+
const next = mention.pick(i);
|
|
390
|
+
if (next !== null) applyPicked(next);
|
|
391
|
+
}}
|
|
392
|
+
/>
|
|
393
|
+
)}
|
|
394
|
+
|
|
395
|
+
{(() => {
|
|
396
|
+
// Ala Gemini: begitu input tumbuh ke 2 baris (multiline), tombol
|
|
397
|
+
// aksi & kirim/stop pindah ke baris di BAWAH textarea — berlaku
|
|
398
|
+
// di desktop maupun mobile. Satu baris: semuanya dalam satu baris.
|
|
399
|
+
const stacked = multiline;
|
|
400
|
+
|
|
401
|
+
// Mention @file yang dikenali -> latar tipis transparan (tanpa
|
|
402
|
+
// bingkai / garis bawah — garis bawah mirip penanda autocorrect
|
|
403
|
+
// keyboard HP) lewat lapisan di BELAKANG textarea (teks & metrik
|
|
404
|
+
// identik, warna teks transparan; hanya latarnya yang terlihat).
|
|
405
|
+
// Sengaja bukan warna teks: textarea harus tetap menggambar
|
|
406
|
+
// teksnya sendiri agar teks komposisi IME tetap terlihat. Textarea tetap input aslinya: kursor, seleksi,
|
|
407
|
+
// undo, IME, dan autocomplete tidak berubah. Scroll disinkronkan.
|
|
408
|
+
const segments = splitComposerMentions(text, suggestedCacheRef.current);
|
|
409
|
+
const hasMention = segments.some((seg) => seg.kind === "mention");
|
|
410
|
+
const textareaEl = (
|
|
411
|
+
<div
|
|
412
|
+
key="composer-textarea"
|
|
413
|
+
className={cn("relative min-w-0", stacked ? "w-full" : "flex-1 self-center")}
|
|
414
|
+
>
|
|
415
|
+
{hasMention && (
|
|
416
|
+
<div
|
|
417
|
+
ref={highlightRef}
|
|
418
|
+
aria-hidden
|
|
419
|
+
className="pointer-events-none absolute inset-0 overflow-hidden px-2 py-1.5 text-base leading-6 break-words whitespace-pre-wrap text-transparent"
|
|
420
|
+
>
|
|
421
|
+
{segments.map((seg, i) =>
|
|
422
|
+
seg.kind === "mention" ? (
|
|
423
|
+
<mark
|
|
424
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
|
|
425
|
+
key={i}
|
|
426
|
+
className="rounded-[3px] bg-primary/10 text-transparent dark:bg-primary/15"
|
|
427
|
+
>
|
|
428
|
+
{seg.text}
|
|
429
|
+
</mark>
|
|
430
|
+
) : (
|
|
431
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
|
|
432
|
+
<span key={i}>{seg.text}</span>
|
|
433
|
+
),
|
|
434
|
+
)}
|
|
435
|
+
{/* Baris kosong di akhir tetap punya tinggi (sama seperti textarea). */}
|
|
436
|
+
{"\u200b"}
|
|
437
|
+
</div>
|
|
438
|
+
)}
|
|
439
|
+
<textarea
|
|
440
|
+
ref={textareaRef}
|
|
441
|
+
value={text}
|
|
442
|
+
onChange={(e) => {
|
|
443
|
+
setText(e.target.value);
|
|
444
|
+
mention.onInputChange(
|
|
445
|
+
e.target.value,
|
|
446
|
+
e.target.selectionStart ?? e.target.value.length,
|
|
447
|
+
);
|
|
448
|
+
}}
|
|
449
|
+
onKeyDown={(e) => {
|
|
450
|
+
// Dropdown aktif: panah/enter/tab/escape dikelola autocomplete.
|
|
451
|
+
if (mention.handleKeyDown(e)) return;
|
|
452
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
453
|
+
e.preventDefault();
|
|
454
|
+
e.currentTarget.form?.requestSubmit();
|
|
455
|
+
}
|
|
456
|
+
}}
|
|
457
|
+
onPaste={handlePaste}
|
|
458
|
+
onScroll={(e) => {
|
|
459
|
+
if (highlightRef.current) {
|
|
460
|
+
highlightRef.current.scrollTop = e.currentTarget.scrollTop;
|
|
461
|
+
}
|
|
462
|
+
}}
|
|
463
|
+
onBlur={() => mention.close()}
|
|
464
|
+
placeholder={composerPlaceholder({
|
|
465
|
+
busy,
|
|
466
|
+
canInput,
|
|
467
|
+
compact: isMobile,
|
|
468
|
+
hasAttachments: pendingImages.length > 0,
|
|
469
|
+
})}
|
|
470
|
+
aria-label="Free-form input"
|
|
471
|
+
disabled={!canInput}
|
|
472
|
+
rows={1}
|
|
473
|
+
// `relative` = di atas lapisan sorot; latar transparan agar
|
|
474
|
+
// sorotan di belakangnya terlihat.
|
|
475
|
+
// Placeholder selalu satu baris (dipotong "…"), tidak pernah wrap:
|
|
476
|
+
// textarea kosong tidak boleh tumbuh / jadi scrollable hanya
|
|
477
|
+
// karena teks petunjuk (mis. saat lampiran mempersempit ruang).
|
|
478
|
+
className="relative block max-h-40 min-h-9 w-full min-w-0 resize-none border-0 bg-transparent px-2 py-1.5 text-base leading-6 break-words shadow-none outline-none placeholder:truncate placeholder:text-muted-foreground focus-visible:ring-0 disabled:cursor-not-allowed placeholder-shown:overflow-hidden"
|
|
479
|
+
/>
|
|
480
|
+
</div>
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
const sendOrStopEl = generating ? (
|
|
484
|
+
// Saat model merespon: tombol kirim berubah jadi tombol Stop
|
|
485
|
+
// (hentikan balasan saja, Session tetap aktif ala opencode).
|
|
486
|
+
<Button
|
|
487
|
+
key="composer-stop"
|
|
488
|
+
type="button"
|
|
489
|
+
variant="outline"
|
|
490
|
+
size="icon"
|
|
491
|
+
onClick={onInterrupt}
|
|
492
|
+
disabled={wsStatus !== "connected"}
|
|
493
|
+
aria-label="Stop response"
|
|
494
|
+
title="Stop the model response"
|
|
495
|
+
className="size-9 shrink-0 border-destructive/60 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
496
|
+
>
|
|
497
|
+
<SquareIcon className="size-3.5" />
|
|
498
|
+
</Button>
|
|
499
|
+
) : (
|
|
500
|
+
// Tombol kirim muncul setelah ada prompt atau lampiran gambar.
|
|
501
|
+
(text.trim() !== "" || pendingImages.length > 0) && (
|
|
502
|
+
<Button
|
|
503
|
+
key="composer-send"
|
|
504
|
+
type="submit"
|
|
505
|
+
size="icon"
|
|
506
|
+
disabled={!canSubmit}
|
|
507
|
+
aria-label="Send message"
|
|
508
|
+
className="size-9 shrink-0"
|
|
509
|
+
>
|
|
510
|
+
{sending ? <Spinner className="size-4" /> : <SendHorizontalIcon />}
|
|
511
|
+
</Button>
|
|
512
|
+
)
|
|
513
|
+
);
|
|
514
|
+
|
|
515
|
+
// Mobile: satu tombol aksi (attach image + agent mode) lewat Sheet,
|
|
516
|
+
// supaya baris composer HP tetap ringkas (3 elemen saja).
|
|
517
|
+
if (isMobile) {
|
|
518
|
+
const actionTrigger = (
|
|
519
|
+
<SheetTrigger asChild key="composer-actions-trigger">
|
|
520
|
+
<Button
|
|
521
|
+
type="button"
|
|
522
|
+
variant="ghost"
|
|
523
|
+
size="icon"
|
|
524
|
+
disabled={!canInput}
|
|
525
|
+
aria-label="More actions"
|
|
526
|
+
title="Attach files or change agent mode"
|
|
527
|
+
className="size-9 shrink-0 text-muted-foreground hover:text-foreground"
|
|
528
|
+
>
|
|
529
|
+
<PlusIcon data-icon="inline-start" />
|
|
530
|
+
</Button>
|
|
531
|
+
</SheetTrigger>
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<Sheet open={actionSheetOpen} onOpenChange={setActionSheetOpen}>
|
|
536
|
+
<div className={cn("flex gap-1", stacked ? "flex-col" : "items-center")}>
|
|
537
|
+
{stacked ? (
|
|
538
|
+
<>
|
|
539
|
+
{textareaEl}
|
|
540
|
+
<div className="flex items-center justify-between gap-1">
|
|
541
|
+
{actionTrigger}
|
|
542
|
+
{sendOrStopEl}
|
|
543
|
+
</div>
|
|
544
|
+
</>
|
|
545
|
+
) : (
|
|
546
|
+
<>
|
|
547
|
+
{actionTrigger}
|
|
548
|
+
{textareaEl}
|
|
549
|
+
{sendOrStopEl}
|
|
550
|
+
</>
|
|
551
|
+
)}
|
|
552
|
+
</div>
|
|
553
|
+
<ComposerActionSheet
|
|
554
|
+
disabled={!canInput}
|
|
555
|
+
onPickImage={() => {
|
|
556
|
+
setActionSheetOpen(false);
|
|
557
|
+
fileInputRef.current?.click();
|
|
558
|
+
}}
|
|
559
|
+
agentPicker={agentPicker}
|
|
560
|
+
activeAgent={agent}
|
|
561
|
+
onPickAgent={(name) => {
|
|
562
|
+
setActionSheetOpen(false);
|
|
563
|
+
void agentPicker.pick(name);
|
|
564
|
+
}}
|
|
565
|
+
/>
|
|
566
|
+
</Sheet>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Desktop: attach image, agent mode, dan kirim/stop tetap tampil
|
|
571
|
+
// langsung sebagai kontrol terpisah di toolbar composer. Satu
|
|
572
|
+
// baris: semuanya sejajar; multiline: kontrol turun ke bawah
|
|
573
|
+
// textarea (ala Gemini).
|
|
574
|
+
const attachImageEl = (
|
|
575
|
+
<Button
|
|
576
|
+
type="button"
|
|
577
|
+
variant="ghost"
|
|
578
|
+
size="icon"
|
|
579
|
+
onClick={() => fileInputRef.current?.click()}
|
|
580
|
+
disabled={!canInput}
|
|
581
|
+
aria-label="Attach files"
|
|
582
|
+
title="Attach files (or drop them here, max 20 MiB each)"
|
|
583
|
+
className="size-9 shrink-0 text-muted-foreground hover:text-foreground"
|
|
584
|
+
>
|
|
585
|
+
<PaperclipIcon data-icon="inline-start" />
|
|
586
|
+
</Button>
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const agentPickerEl = (
|
|
590
|
+
<AgentPicker
|
|
591
|
+
projectId={session.projectId}
|
|
592
|
+
sessionId={session.id}
|
|
593
|
+
agent={agent}
|
|
594
|
+
onChanged={setAgent}
|
|
595
|
+
disabled={!canInput}
|
|
596
|
+
/>
|
|
597
|
+
);
|
|
598
|
+
|
|
599
|
+
if (stacked) {
|
|
600
|
+
return (
|
|
601
|
+
<div className="flex flex-col gap-1">
|
|
602
|
+
{textareaEl}
|
|
603
|
+
<div className="flex items-center justify-between gap-1">
|
|
604
|
+
{attachImageEl}
|
|
605
|
+
<div className="flex items-center gap-1">
|
|
606
|
+
{agentPickerEl}
|
|
607
|
+
{sendOrStopEl}
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
</div>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return (
|
|
615
|
+
<div className="flex items-center gap-1">
|
|
616
|
+
{attachImageEl}
|
|
617
|
+
{textareaEl}
|
|
618
|
+
{agentPickerEl}
|
|
619
|
+
{sendOrStopEl}
|
|
620
|
+
</div>
|
|
621
|
+
);
|
|
622
|
+
})()}
|
|
623
|
+
</div>
|
|
624
|
+
<input
|
|
625
|
+
ref={fileInputRef}
|
|
626
|
+
type="file"
|
|
627
|
+
multiple
|
|
628
|
+
className="sr-only"
|
|
629
|
+
tabIndex={-1}
|
|
630
|
+
aria-hidden
|
|
631
|
+
onChange={(e) => {
|
|
632
|
+
const files = e.target.files ? [...e.target.files] : [];
|
|
633
|
+
addFiles(files);
|
|
634
|
+
e.target.value = ""; // izinkan memilih file yang sama lagi
|
|
635
|
+
}}
|
|
636
|
+
/>
|
|
637
|
+
</form>
|
|
638
|
+
</footer>
|
|
639
|
+
);
|
|
640
|
+
}
|