kcg-code 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tampilan Session — versi headless (opencode serve).
|
|
3
|
+
*
|
|
4
|
+
* Berbeda dari versi PTY/TUI (yang me-render chunk byte terminal TUI):
|
|
5
|
+
* - Pesan datang sebagai `SessionMessage` terstruktur (`role` + `parts`):
|
|
6
|
+
* text, reasoning (collapsible per part, urut sesuai alur), tool/step.
|
|
7
|
+
* - Interactive_Prompt (permission/question) dirender via `PromptCard.tsx`.
|
|
8
|
+
* - Saat model merespon (`turn_active` true / pesan streaming) tombol kirim
|
|
9
|
+
* berubah jadi tombol Stop (kirim `{ type: "interrupt" }`) — menghentikan
|
|
10
|
+
* balasan saja ala opencode, Session tetap berjalan. `{ type: "stop" }`
|
|
11
|
+
* (menonaktifkan Session) tetap ada lewat daftar Session / HTTP.
|
|
12
|
+
*
|
|
13
|
+
* File ini sekarang HANYA menyusun area layar dari bagian-bagian yang
|
|
14
|
+
* sudah dipisah:
|
|
15
|
+
* - State & alur WS: `hooks/useSessionChat.ts`.
|
|
16
|
+
* - Logika transformasi pesan murni: `lib/turns.ts` (type di `types/turns.ts`).
|
|
17
|
+
* - Area UI: `SessionHeader`, `SessionTimeline`, `PromptPanel`,
|
|
18
|
+
* `SessionComposer` di folder yang sama.
|
|
19
|
+
* - Drag & drop file: `FileDropOverlay` (portal, menutupi seluruh viewport)
|
|
20
|
+
* meneruskan file ke composer (`SessionComposerHandle.addFiles`).
|
|
21
|
+
* - Kartu konteks kanan (`ContextPanel`): Environment / Task list / MCP /
|
|
22
|
+
* Reference. Desktop: kolom sendiri di kanan (berdampingan) — kolom chat
|
|
23
|
+
* menyempit & percakapan tetap terpusat di sisa ruang, tidak tertutup
|
|
24
|
+
* kartu. Di bawah `lg` (HP & tablet) tampil sebagai bottom sheet.
|
|
25
|
+
*/
|
|
26
|
+
import { useMemo, useRef, useState } from "react";
|
|
27
|
+
import { ConfirmSessionDeleteDialog } from "@/components/sessions/ConfirmSessionDeleteDialog";
|
|
28
|
+
import {
|
|
29
|
+
ContextPanel,
|
|
30
|
+
ContextPanelTrigger,
|
|
31
|
+
ContextSheet,
|
|
32
|
+
} from "@/components/sessions/ContextPanel";
|
|
33
|
+
import { FileDropOverlay } from "@/components/sessions/FileDropOverlay";
|
|
34
|
+
import { SessionComposer, type SessionComposerHandle } from "@/components/sessions/SessionComposer";
|
|
35
|
+
import { SessionHeader } from "@/components/sessions/SessionHeader";
|
|
36
|
+
import { SessionTimeline } from "@/components/sessions/SessionTimeline";
|
|
37
|
+
import {
|
|
38
|
+
SessionComposerSkeleton,
|
|
39
|
+
SessionTimelineSkeleton,
|
|
40
|
+
} from "@/components/sessions/SessionTimelineSkeleton";
|
|
41
|
+
import { useMediaQuery } from "@/hooks/useMediaQuery";
|
|
42
|
+
import { useSessionChat } from "@/hooks/useSessionChat";
|
|
43
|
+
import { useSessionContext } from "@/hooks/useSessionContext";
|
|
44
|
+
import { CONVERSATION_MAX_W } from "@/lib/layout";
|
|
45
|
+
import { collectReferences } from "@/lib/references";
|
|
46
|
+
import { cn } from "@/lib/utils";
|
|
47
|
+
import type { Session } from "@/types";
|
|
48
|
+
import { PromptPanel } from "./PromptPanel";
|
|
49
|
+
|
|
50
|
+
/** Lebar minimum untuk kartu konteks sebagai kolom samping (`lg`). */
|
|
51
|
+
const SIDE_PANEL_QUERY = "(min-width: 1024px)";
|
|
52
|
+
|
|
53
|
+
export interface SessionViewProps {
|
|
54
|
+
session: Session;
|
|
55
|
+
onBack: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Dipanggil setelah Session dihapus dari menu aksi header. Bila tidak
|
|
58
|
+
* diberikan, `onBack` dipakai — halaman ini tidak punya data lagi untuk
|
|
59
|
+
* ditampilkan setelah Session hilang.
|
|
60
|
+
*/
|
|
61
|
+
onDeleted?: () => void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function SessionView({ session, onBack, onDeleted }: SessionViewProps) {
|
|
65
|
+
const chat = useSessionChat({ session, onBack, onDeleted });
|
|
66
|
+
/**
|
|
67
|
+
* Riwayat belum tiba dari WebSocket. `messages` kosong di fase ini BUKAN
|
|
68
|
+
* percakapan kosong — tampilkan kerangka, jangan empty-state. Pengecualian:
|
|
69
|
+
* pesan optimistis (prompt pertama dari homepage) sudah ada sejak frame
|
|
70
|
+
* pertama, jadi langsung tampil sebagai percakapan.
|
|
71
|
+
*/
|
|
72
|
+
const loadingHistory = !chat.historyLoaded && chat.messages.length === 0;
|
|
73
|
+
const empty = !loadingHistory && chat.messages.length === 0;
|
|
74
|
+
const composerRef = useRef<SessionComposerHandle>(null);
|
|
75
|
+
/** Panel konteks kanan; default tertutup agar area chat tetap lega. */
|
|
76
|
+
const [contextOpen, setContextOpen] = useState(false);
|
|
77
|
+
// Kolom konteks berdampingan butuh ruang: di bawah `lg` kolom chat jadi
|
|
78
|
+
// terlalu sempit, jadi kartu tampil sebagai bottom sheet (seperti HP).
|
|
79
|
+
const sidePanel = useMediaQuery(SIDE_PANEL_QUERY);
|
|
80
|
+
const sessionCtx = useSessionContext(session.id, contextOpen, chat.generating);
|
|
81
|
+
/**
|
|
82
|
+
* Reference diturunkan dari parts pesan yang sudah ada di timeline
|
|
83
|
+
* (tanpa request tambahan). `session.cwd` dipakai untuk mengubah path
|
|
84
|
+
* absolut dari tool call menjadi path relatif project.
|
|
85
|
+
*/
|
|
86
|
+
const references = useMemo(
|
|
87
|
+
() => (contextOpen ? collectReferences(chat.messages, session.cwd) : []),
|
|
88
|
+
[contextOpen, chat.messages, session.cwd],
|
|
89
|
+
);
|
|
90
|
+
// Composer tidak dirender saat pertanyaan menunggu jawaban (panel docked)
|
|
91
|
+
// maupun saat riwayat masih dimuat (belum tahu tata letak akhirnya).
|
|
92
|
+
const composerShown = !loadingHistory && (empty || !chat.hasPendingQuestion);
|
|
93
|
+
const acceptingFiles = composerShown && chat.canInput;
|
|
94
|
+
const blockedReason = loadingHistory
|
|
95
|
+
? "Wait for the conversation to load, then drop your files."
|
|
96
|
+
: !composerShown
|
|
97
|
+
? "Answer the question first, then add your files."
|
|
98
|
+
: chat.busy
|
|
99
|
+
? "Wait for the response to finish, then drop your files."
|
|
100
|
+
: "Start the session to add files.";
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
// `data-vt-name`: tujuan transisi "to-session" — composer homepage meluas
|
|
104
|
+
// menjadi seluruh area ini (lihat styles/globals.css).
|
|
105
|
+
<div data-vt-name="session-surface" className="flex h-full min-h-0 flex-col">
|
|
106
|
+
<SessionHeader
|
|
107
|
+
session={session}
|
|
108
|
+
status={chat.status}
|
|
109
|
+
wsStatus={chat.wsStatus}
|
|
110
|
+
onStop={() => void chat.stop()}
|
|
111
|
+
onStart={() => void chat.start()}
|
|
112
|
+
onRequestDelete={chat.beginDelete}
|
|
113
|
+
stopping={chat.stopping}
|
|
114
|
+
starting={chat.starting}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
{/* Percakapan + kartu konteks berdampingan. `relative` jadi acuan tombol
|
|
118
|
+
hamburger (kanan bawah header) dan kartu saat mengapung di layar
|
|
119
|
+
sempit. */}
|
|
120
|
+
<div className="relative flex min-h-0 flex-1">
|
|
121
|
+
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
|
|
122
|
+
{loadingHistory ? (
|
|
123
|
+
// Kerangka sama persis dengan fase `SessionPage` -> tanpa kedipan.
|
|
124
|
+
<>
|
|
125
|
+
<SessionTimelineSkeleton />
|
|
126
|
+
<SessionComposerSkeleton />
|
|
127
|
+
</>
|
|
128
|
+
) : empty ? (
|
|
129
|
+
/* Empty-state ala Gemini: sapaan + composer dipusatkan vertikal.
|
|
130
|
+
Timeline & prompt panel belum dirender (tidak ada isinya); spacer
|
|
131
|
+
flex-1 di atas & bawah menjaga blok ini tepat di tengah kolom di
|
|
132
|
+
bawah header. Begitu pesan pertama masuk (echo WS), layout pindah
|
|
133
|
+
ke varian percakapan di bawah — composer turun ke dasar. */
|
|
134
|
+
<div className="flex min-h-0 flex-1 flex-col px-3 sm:px-4">
|
|
135
|
+
<div className="min-h-0 flex-[2]" />
|
|
136
|
+
<div className={cn("mx-auto w-full", CONVERSATION_MAX_W)}>
|
|
137
|
+
<h1 className="mb-6 text-center text-2xl font-medium tracking-tight sm:text-3xl">
|
|
138
|
+
No conversation yet
|
|
139
|
+
</h1>
|
|
140
|
+
<SessionComposer
|
|
141
|
+
ref={composerRef}
|
|
142
|
+
session={session}
|
|
143
|
+
inputAllowed={chat.canInput}
|
|
144
|
+
busy={chat.busy}
|
|
145
|
+
generating={chat.generating}
|
|
146
|
+
wsStatus={chat.wsStatus}
|
|
147
|
+
send={chat.send}
|
|
148
|
+
reportError={chat.reportError}
|
|
149
|
+
onInterrupt={chat.interrupt}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
<div className="min-h-0 flex-[3]" />
|
|
153
|
+
</div>
|
|
154
|
+
) : (
|
|
155
|
+
<>
|
|
156
|
+
<SessionTimeline
|
|
157
|
+
error={chat.error}
|
|
158
|
+
status={chat.status}
|
|
159
|
+
messages={chat.messages}
|
|
160
|
+
collapsible={chat.collapsible}
|
|
161
|
+
onToggle={chat.toggleBlock}
|
|
162
|
+
generating={chat.generating}
|
|
163
|
+
/>
|
|
164
|
+
|
|
165
|
+
{/* Question pending: panel DOCKED menggantikan composer — jawaban
|
|
166
|
+
pertanyaan adalah satu-satunya input saat ini (ala TUI opencode).
|
|
167
|
+
Hanya area jawaban kartu yang scroll; footer aksi tetap terlihat.
|
|
168
|
+
Permission (bukan question): panel tetap mengambang DI ATAS
|
|
169
|
+
composer seperti biasa. */}
|
|
170
|
+
<PromptPanel
|
|
171
|
+
groups={chat.promptGroups}
|
|
172
|
+
errorSignal={chat.promptError}
|
|
173
|
+
onResolve={chat.resolvePrompt}
|
|
174
|
+
onConsumeError={chat.consumePromptError}
|
|
175
|
+
docked={chat.hasPendingQuestion}
|
|
176
|
+
/>
|
|
177
|
+
|
|
178
|
+
{!chat.hasPendingQuestion && (
|
|
179
|
+
<SessionComposer
|
|
180
|
+
ref={composerRef}
|
|
181
|
+
session={session}
|
|
182
|
+
inputAllowed={chat.canInput}
|
|
183
|
+
busy={chat.busy}
|
|
184
|
+
generating={chat.generating}
|
|
185
|
+
wsStatus={chat.wsStatus}
|
|
186
|
+
send={chat.send}
|
|
187
|
+
reportError={chat.reportError}
|
|
188
|
+
onInterrupt={chat.interrupt}
|
|
189
|
+
/>
|
|
190
|
+
)}
|
|
191
|
+
</>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
|
|
195
|
+
{/* Tertutup: hanya tombol hamburger. Terbuka (desktop): tombol hilang
|
|
196
|
+
& kartu jadi kolom kanan selebar 17rem yang IKUT tata letak — kolom
|
|
197
|
+
chat (flex-1) menyempit, jadi pesan & composer tidak tertutup. */}
|
|
198
|
+
{!sidePanel ? (
|
|
199
|
+
// HP & tablet: bottom sheet. Tombol hamburger tetap terlihat — sheet
|
|
200
|
+
// menutupi layar & punya penutupnya sendiri (tap luar / geser / Esc).
|
|
201
|
+
<>
|
|
202
|
+
<ContextPanelTrigger onOpen={() => setContextOpen(true)} />
|
|
203
|
+
<ContextSheet
|
|
204
|
+
open={contextOpen}
|
|
205
|
+
onOpenChange={setContextOpen}
|
|
206
|
+
context={sessionCtx.context}
|
|
207
|
+
references={references}
|
|
208
|
+
loading={sessionCtx.loading}
|
|
209
|
+
/>
|
|
210
|
+
</>
|
|
211
|
+
) : contextOpen ? (
|
|
212
|
+
<div className="flex w-[17rem] shrink-0 flex-col pt-2 pe-3 pb-3 sm:pe-4">
|
|
213
|
+
<ContextPanel
|
|
214
|
+
context={sessionCtx.context}
|
|
215
|
+
references={references}
|
|
216
|
+
loading={sessionCtx.loading}
|
|
217
|
+
onClose={() => setContextOpen(false)}
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
) : (
|
|
221
|
+
<ContextPanelTrigger onOpen={() => setContextOpen(true)} />
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<FileDropOverlay
|
|
226
|
+
accepting={acceptingFiles}
|
|
227
|
+
blockedReason={blockedReason}
|
|
228
|
+
onDropFiles={(files, from) => composerRef.current?.addFiles(files, from)}
|
|
229
|
+
/>
|
|
230
|
+
|
|
231
|
+
{/* Konfirmasi hapus Session dari menu aksi header */}
|
|
232
|
+
<ConfirmSessionDeleteDialog
|
|
233
|
+
open={chat.deleteOpen}
|
|
234
|
+
onOpenChange={(open) => (open ? chat.beginDelete() : chat.cancelDelete())}
|
|
235
|
+
deleting={chat.deleting}
|
|
236
|
+
onConfirm={() => void chat.confirmDelete()}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
);
|
|
240
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satu blok "Thought process" collapsible di dalam turn assistant.
|
|
3
|
+
*
|
|
4
|
+
* Berisi kluster steps berurutan (reasoning/tool) dengan garis penghubung
|
|
5
|
+
* antar langkah + tanda "Done" setelah blok selesai di-stream. Status toggle
|
|
6
|
+
* dikelola pemilik daftar (key `groupId:blockKey`) agar blok baru saat
|
|
7
|
+
* streaming otomatis masuk keadaan collapsed tanpa menyentuh toggle user.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
ChevronRightIcon,
|
|
11
|
+
CircleCheckBigIcon,
|
|
12
|
+
ClockFadingIcon,
|
|
13
|
+
FileIcon,
|
|
14
|
+
FolderIcon,
|
|
15
|
+
GlobeIcon,
|
|
16
|
+
type LucideIcon,
|
|
17
|
+
PenLineIcon,
|
|
18
|
+
SearchIcon,
|
|
19
|
+
TerminalIcon,
|
|
20
|
+
WrenchIcon,
|
|
21
|
+
} from "lucide-react";
|
|
22
|
+
import type { ReactNode } from "react";
|
|
23
|
+
import { Button } from "@/components/ui/button";
|
|
24
|
+
import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible";
|
|
25
|
+
import { useSmoothText } from "@/hooks/useSmoothText";
|
|
26
|
+
import { type CollapsibleState, isCollapsibleExpanded } from "@/lib/collapsible";
|
|
27
|
+
import { partText, toolLabel } from "@/lib/turns";
|
|
28
|
+
import { cn } from "@/lib/utils";
|
|
29
|
+
import type { MessagePart, TurnSegment } from "@/types";
|
|
30
|
+
|
|
31
|
+
/** Step dalam blok: reasoning atau tool call. */
|
|
32
|
+
export type ThoughtStep = Extract<TurnSegment, { kind: "reasoning" | "tool" }>;
|
|
33
|
+
|
|
34
|
+
export interface ThoughtProcessBlockProps {
|
|
35
|
+
/** Id grup turn (untuk key collapsible `groupId:blockKey`). */
|
|
36
|
+
groupId: string;
|
|
37
|
+
/** Key blok (dari key segmen pertama). */
|
|
38
|
+
blockKey: string;
|
|
39
|
+
steps: ThoughtStep[];
|
|
40
|
+
collapsible: CollapsibleState;
|
|
41
|
+
onToggle: (key: string) => void;
|
|
42
|
+
/** Turn masih streaming — blok terakhir menampilkan indikator aktif. */
|
|
43
|
+
streaming: boolean;
|
|
44
|
+
/** Apakah ini blok terakhir dalam turn (menentukan indikator streaming). */
|
|
45
|
+
isLastBlock: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Ikon badge tool mengikuti nama tool (fallback: kunci inggris).
|
|
50
|
+
*/
|
|
51
|
+
const TOOL_ICONS: Record<string, LucideIcon> = {
|
|
52
|
+
read: FileIcon,
|
|
53
|
+
list: FolderIcon,
|
|
54
|
+
glob: FolderIcon,
|
|
55
|
+
edit: PenLineIcon,
|
|
56
|
+
write: PenLineIcon,
|
|
57
|
+
patch: PenLineIcon,
|
|
58
|
+
bash: TerminalIcon,
|
|
59
|
+
shell: TerminalIcon,
|
|
60
|
+
grep: SearchIcon,
|
|
61
|
+
webfetch: GlobeIcon,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function toolIcon(part: MessagePart): LucideIcon {
|
|
65
|
+
const name = (typeof part.tool === "string" ? part.tool : part.type).toLowerCase();
|
|
66
|
+
return (name !== "" && TOOL_ICONS[name]) || WrenchIcon;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Ikon kecil (14px) untuk langkah timeline & indikator status. */
|
|
70
|
+
function stepIcon(Icon: LucideIcon, className?: string) {
|
|
71
|
+
return <Icon className={cn("size-3.5 shrink-0", className)} data-icon="inline-start" />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Baris ringkas isi reasoning di timeline. Saat masih di-stream (teks belum
|
|
76
|
+
* lengkap) tampil redup; setelah selesai tampil apa adanya — keduanya bisa
|
|
77
|
+
* panjang, jadi tinggi dibatasi + scroll internal.
|
|
78
|
+
*/
|
|
79
|
+
function ReasoningStep({ text, live = false }: { text: string; live?: boolean }) {
|
|
80
|
+
// Reasoning yang masih di-stream ikut dihaluskan (sama seperti jawaban).
|
|
81
|
+
const shown = useSmoothText(text, live);
|
|
82
|
+
const dim = shown.trim() === "";
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
className={cn(
|
|
86
|
+
"max-h-40 overflow-y-auto whitespace-pre-wrap text-[13px] leading-relaxed",
|
|
87
|
+
dim && "text-muted-foreground/50",
|
|
88
|
+
)}
|
|
89
|
+
>
|
|
90
|
+
{dim ? "organizing thoughts…" : shown}
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Satu langkah timeline di dalam blok "Thought process": ikon status di
|
|
97
|
+
* kiri (dengan garis penghubung antar langkah) + isi di kanan.
|
|
98
|
+
*
|
|
99
|
+
* Garis digambar ABSOLUT (bukan flex-1 di kolom stretch): `align-self:
|
|
100
|
+
* stretch` hanya membentang sampai content box sehingga di langkah
|
|
101
|
+
* satu-baris sisa ruang garisnya ~0px (tak terlihat). Absolut terhadap
|
|
102
|
+
* PADDING box -> `bottom-0` menyentuh dasar `pb-5`, artinya garis selalu
|
|
103
|
+
* tersambung ke langkah berikutnya berapa pun tinggi kontennya.
|
|
104
|
+
*/
|
|
105
|
+
function renderTimelineStep(
|
|
106
|
+
icon: ReactNode,
|
|
107
|
+
content: ReactNode,
|
|
108
|
+
opts?: { last?: boolean; streaming?: boolean },
|
|
109
|
+
) {
|
|
110
|
+
return (
|
|
111
|
+
<div className={cn("relative flex gap-2.5", !opts?.last && "pb-5")}>
|
|
112
|
+
{/* Garis penghubung: mulai tepat di bawah ikon (mt-0.5 + size-5 =
|
|
113
|
+
22px) sampai dasar row; x=10px = pusat kolom ikon (size-5). */}
|
|
114
|
+
{!opts?.last && (
|
|
115
|
+
<span className="absolute top-6 bottom-0 left-2.5 -ml-px w-px bg-border" aria-hidden />
|
|
116
|
+
)}
|
|
117
|
+
<span
|
|
118
|
+
className={cn(
|
|
119
|
+
"mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full",
|
|
120
|
+
opts?.streaming && "animate-pulse text-foreground/70",
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
{icon}
|
|
124
|
+
</span>
|
|
125
|
+
<div className="min-w-0 flex-1 text-sm text-muted-foreground">{content}</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function ThoughtProcessBlock({
|
|
131
|
+
groupId,
|
|
132
|
+
blockKey,
|
|
133
|
+
steps,
|
|
134
|
+
collapsible,
|
|
135
|
+
onToggle,
|
|
136
|
+
streaming,
|
|
137
|
+
isLastBlock,
|
|
138
|
+
}: ThoughtProcessBlockProps) {
|
|
139
|
+
if (steps.length === 0) return null;
|
|
140
|
+
const collapsibleKey = `${groupId}:${blockKey}`;
|
|
141
|
+
const expanded = isCollapsibleExpanded(collapsible, collapsibleKey);
|
|
142
|
+
// Blok terakhir yang masih streaming: "Done" belum tampil.
|
|
143
|
+
const blockStreaming = streaming && isLastBlock;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<Collapsible open={expanded}>
|
|
147
|
+
<Button
|
|
148
|
+
type="button"
|
|
149
|
+
variant="ghost"
|
|
150
|
+
size="sm"
|
|
151
|
+
className="h-7 self-start gap-1 px-2 text-xs text-muted-foreground"
|
|
152
|
+
onClick={() => onToggle(collapsibleKey)}
|
|
153
|
+
aria-expanded={expanded}
|
|
154
|
+
>
|
|
155
|
+
Thought process
|
|
156
|
+
{/* Ikon sengaja di KANAN label */}
|
|
157
|
+
<ChevronRightIcon
|
|
158
|
+
data-icon="inline-end"
|
|
159
|
+
className={cn("transition-transform", expanded && "rotate-90")}
|
|
160
|
+
/>
|
|
161
|
+
</Button>
|
|
162
|
+
<CollapsibleContent>
|
|
163
|
+
<div className="flex flex-col px-3 pt-1">
|
|
164
|
+
{steps.map((seg, i) =>
|
|
165
|
+
seg.kind === "reasoning" ? (
|
|
166
|
+
<div key={seg.key}>
|
|
167
|
+
{renderTimelineStep(
|
|
168
|
+
stepIcon(ClockFadingIcon, "text-muted-foreground"),
|
|
169
|
+
<ReasoningStep
|
|
170
|
+
text={partText(seg.part) ?? ""}
|
|
171
|
+
live={blockStreaming && i === steps.length - 1}
|
|
172
|
+
/>,
|
|
173
|
+
{
|
|
174
|
+
last: blockStreaming && i === steps.length - 1,
|
|
175
|
+
streaming: blockStreaming && i === steps.length - 1,
|
|
176
|
+
},
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
) : (
|
|
180
|
+
<div key={seg.key}>
|
|
181
|
+
{renderTimelineStep(
|
|
182
|
+
stepIcon(toolIcon(seg.part), "text-muted-foreground"),
|
|
183
|
+
<span className="font-mono text-[13px]">{toolLabel(seg.part)}</span>,
|
|
184
|
+
{ last: blockStreaming && i === steps.length - 1, streaming: false },
|
|
185
|
+
)}
|
|
186
|
+
</div>
|
|
187
|
+
),
|
|
188
|
+
)}
|
|
189
|
+
{!blockStreaming &&
|
|
190
|
+
renderTimelineStep(
|
|
191
|
+
stepIcon(CircleCheckBigIcon, "text-muted-foreground"),
|
|
192
|
+
<span>Done</span>,
|
|
193
|
+
{ last: true },
|
|
194
|
+
)}
|
|
195
|
+
</div>
|
|
196
|
+
</CollapsibleContent>
|
|
197
|
+
</Collapsible>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render satu pesan user: teks + lampiran gambar (thumbnail) + @file.
|
|
3
|
+
*
|
|
4
|
+
* Part `file` gambar (punya `attachmentId` + mime image) dimuat bytes-nya
|
|
5
|
+
* lewat route HTTP `/api/uploads/...` (`attachmentUrl`); file upload lain
|
|
6
|
+
* tampil sebagai kartu yang bisa diunduh; part `file` tanpa `attachmentId`
|
|
7
|
+
* (echo @file) tampil sebagai chip nama file.
|
|
8
|
+
*/
|
|
9
|
+
import { FileIcon } from "lucide-react";
|
|
10
|
+
import { useState } from "react";
|
|
11
|
+
import { FileTile } from "@/components/sessions/AttachmentTile";
|
|
12
|
+
import { ImageLightbox } from "@/components/sessions/ImageLightbox";
|
|
13
|
+
import { CopyMessageButton } from "@/components/sessions/MessageActions";
|
|
14
|
+
import { Bubble, BubbleContent } from "@/components/ui/bubble";
|
|
15
|
+
import { Message, MessageContent, MessageFooter } from "@/components/ui/message";
|
|
16
|
+
import { attachmentUrl } from "@/lib/api";
|
|
17
|
+
import { isPreviewableImage } from "@/lib/attachments";
|
|
18
|
+
import { splitComposerMentions } from "@/lib/composer";
|
|
19
|
+
import { formatTime } from "@/lib/time";
|
|
20
|
+
import { textOf } from "@/lib/turns";
|
|
21
|
+
import { cn } from "@/lib/utils";
|
|
22
|
+
import type { MessagePart, SessionMessage } from "@/types";
|
|
23
|
+
|
|
24
|
+
export interface UserMessageProps {
|
|
25
|
+
message: SessionMessage;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Part `file` yang merupakan gambar lampiran (punya attachmentId + mime image). */
|
|
29
|
+
function isImageAttachment(
|
|
30
|
+
p: MessagePart,
|
|
31
|
+
): p is MessagePart & { mime: string; attachmentId: string } {
|
|
32
|
+
return (
|
|
33
|
+
p.type === "file" &&
|
|
34
|
+
typeof p.mime === "string" &&
|
|
35
|
+
isPreviewableImage(p.mime) &&
|
|
36
|
+
typeof p.attachmentId === "string"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** File upload non-gambar (dokumen, kode, arsip, …). */
|
|
41
|
+
function isFileAttachment(
|
|
42
|
+
p: MessagePart,
|
|
43
|
+
): p is MessagePart & { attachmentId: string; filename: string } {
|
|
44
|
+
return (
|
|
45
|
+
p.type === "file" &&
|
|
46
|
+
typeof p.attachmentId === "string" &&
|
|
47
|
+
typeof p.filename === "string" &&
|
|
48
|
+
!isImageAttachment(p)
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function UserMessage({ message: m }: UserMessageProps) {
|
|
53
|
+
const [preview, setPreview] = useState<number | null>(null);
|
|
54
|
+
const images = m.parts.filter(isImageAttachment);
|
|
55
|
+
const text = textOf(m.parts);
|
|
56
|
+
const lightboxImages = images.map((p) => ({
|
|
57
|
+
src: attachmentUrl(m.sessionId, p.attachmentId),
|
|
58
|
+
alt: p.filename ?? "Attached image",
|
|
59
|
+
}));
|
|
60
|
+
const uploadedFiles = m.parts.filter(isFileAttachment);
|
|
61
|
+
/** Echo @file: part `file` tanpa `attachmentId` (path relatif project). */
|
|
62
|
+
const isMention = (p: MessagePart) =>
|
|
63
|
+
p.type === "file" && typeof p.attachmentId !== "string" && typeof p.filename === "string";
|
|
64
|
+
const mentionedPaths = new Set(m.parts.filter(isMention).map((p) => p.filename as string));
|
|
65
|
+
const attachedFiles = m.parts
|
|
66
|
+
.filter(isMention)
|
|
67
|
+
.map((p) => ({ part: p, filename: p.filename as string }));
|
|
68
|
+
return (
|
|
69
|
+
<Message align="end">
|
|
70
|
+
<MessageContent>
|
|
71
|
+
{/* Gambar di atas bubble teks (rata kanan), ala aplikasi chat:
|
|
72
|
+
1 gambar = satu kartu besar; 2+ = grid 2 kolom kotak. Klik =
|
|
73
|
+
pratinjau layar penuh. */}
|
|
74
|
+
{images.length > 0 && (
|
|
75
|
+
<ul
|
|
76
|
+
aria-label={`${images.length} attached ${images.length === 1 ? "image" : "images"}`}
|
|
77
|
+
className={cn(
|
|
78
|
+
"grid gap-1.5 self-end",
|
|
79
|
+
images.length === 1 ? "w-full max-w-72 grid-cols-1" : "w-full max-w-80 grid-cols-2",
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{lightboxImages.map((img, i) => (
|
|
83
|
+
<li key={img.src} className={cn(images.length === 3 && i === 0 && "col-span-2")}>
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => setPreview(i)}
|
|
87
|
+
aria-label={`Preview ${img.alt}`}
|
|
88
|
+
className="group/img block w-full overflow-hidden rounded-xl border bg-muted outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
89
|
+
>
|
|
90
|
+
<img
|
|
91
|
+
src={img.src}
|
|
92
|
+
alt={img.alt}
|
|
93
|
+
loading="lazy"
|
|
94
|
+
draggable={false}
|
|
95
|
+
className={cn(
|
|
96
|
+
"w-full object-cover transition-transform duration-200 group-hover/img:scale-[1.02]",
|
|
97
|
+
images.length === 1 ? "max-h-80" : "aspect-square",
|
|
98
|
+
images.length === 3 && i === 0 && "aspect-[2/1]",
|
|
99
|
+
)}
|
|
100
|
+
/>
|
|
101
|
+
</button>
|
|
102
|
+
</li>
|
|
103
|
+
))}
|
|
104
|
+
</ul>
|
|
105
|
+
)}
|
|
106
|
+
|
|
107
|
+
{/* File non-gambar: kartu unduhan di atas bubble (rata kanan). */}
|
|
108
|
+
{uploadedFiles.length > 0 && (
|
|
109
|
+
<ul
|
|
110
|
+
aria-label={`${uploadedFiles.length} attached ${uploadedFiles.length === 1 ? "file" : "files"}`}
|
|
111
|
+
className="flex max-w-full flex-wrap justify-end gap-1.5 self-end"
|
|
112
|
+
>
|
|
113
|
+
{uploadedFiles.map((p) => (
|
|
114
|
+
<li key={p.attachmentId} className="min-w-0">
|
|
115
|
+
<a
|
|
116
|
+
href={attachmentUrl(m.sessionId, p.attachmentId)}
|
|
117
|
+
download={p.filename}
|
|
118
|
+
title={`Download ${p.filename}`}
|
|
119
|
+
className="block rounded-lg outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
120
|
+
>
|
|
121
|
+
<FileTile
|
|
122
|
+
filename={p.filename}
|
|
123
|
+
mime={p.mime ?? ""}
|
|
124
|
+
className="h-14 w-56 max-w-full"
|
|
125
|
+
/>
|
|
126
|
+
</a>
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ul>
|
|
130
|
+
)}
|
|
131
|
+
|
|
132
|
+
{(text !== "" || attachedFiles.length > 0) && (
|
|
133
|
+
<Bubble>
|
|
134
|
+
{text !== "" && (
|
|
135
|
+
<BubbleContent className="whitespace-pre-wrap">
|
|
136
|
+
{/* Mention @file disorot sama seperti di composer. */}
|
|
137
|
+
{splitComposerMentions(text, mentionedPaths).map((seg, i) =>
|
|
138
|
+
seg.kind === "mention" ? (
|
|
139
|
+
<mark
|
|
140
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
|
|
141
|
+
key={i}
|
|
142
|
+
className="rounded-[3px] bg-current/10 text-inherit"
|
|
143
|
+
>
|
|
144
|
+
{seg.text}
|
|
145
|
+
</mark>
|
|
146
|
+
) : (
|
|
147
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: urutan potongan = urutan teks
|
|
148
|
+
<span key={i}>{seg.text}</span>
|
|
149
|
+
),
|
|
150
|
+
)}
|
|
151
|
+
</BubbleContent>
|
|
152
|
+
)}
|
|
153
|
+
{attachedFiles.length > 0 && (
|
|
154
|
+
<div className="mt-1.5 flex flex-wrap gap-1">
|
|
155
|
+
{attachedFiles.map(({ filename }) => (
|
|
156
|
+
<span
|
|
157
|
+
key={filename}
|
|
158
|
+
className="inline-flex max-w-full items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground"
|
|
159
|
+
>
|
|
160
|
+
<FileIcon className="size-3 shrink-0" />
|
|
161
|
+
<span className="truncate">{filename}</span>
|
|
162
|
+
</span>
|
|
163
|
+
))}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</Bubble>
|
|
167
|
+
)}
|
|
168
|
+
{/* Copy di kiri waktu (pesan user rata kanan). */}
|
|
169
|
+
<MessageFooter className="gap-1">
|
|
170
|
+
<CopyMessageButton text={text} />
|
|
171
|
+
<span className="text-muted-foreground/80">{formatTime(m.createdAt)}</span>
|
|
172
|
+
</MessageFooter>
|
|
173
|
+
<ImageLightbox images={lightboxImages} index={preview} onIndexChange={setPreview} />
|
|
174
|
+
</MessageContent>
|
|
175
|
+
</Message>
|
|
176
|
+
);
|
|
177
|
+
}
|