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,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satu baris Session di daftar (`SessionList`) — baris boxed list ala
|
|
3
|
+
* AdwActionRow (libadwaita), sama dengan baris panel samping Project: logo
|
|
4
|
+
* agent 18px sebagai ikon, judul + subjudul (status berwarna · model ·
|
|
5
|
+
* waktu), tombol menu bundar sebagai suffix, lalu chevron navigasi.
|
|
6
|
+
* Badan baris adalah tombol buka (target sentuh lebar); aksi sekunder ada di
|
|
7
|
+
* menu "..." — di perangkat dengan pointer halus (mouse) menu baru muncul
|
|
8
|
+
* saat hover/fokus agar daftar tetap bersih, di layar sentuh selalu tampil.
|
|
9
|
+
* Pemanggil membungkusnya dengan `<li>` di dalam `PrefsGroup`.
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
CheckIcon,
|
|
13
|
+
ChevronRightIcon,
|
|
14
|
+
MoreHorizontalIcon,
|
|
15
|
+
PlayIcon,
|
|
16
|
+
SquareIcon,
|
|
17
|
+
Trash2Icon,
|
|
18
|
+
} from "lucide-react";
|
|
19
|
+
import { AgentLogo } from "@/components/sessions/AgentLogo";
|
|
20
|
+
import { Button } from "@/components/ui/button";
|
|
21
|
+
import {
|
|
22
|
+
DropdownMenu,
|
|
23
|
+
DropdownMenuContent,
|
|
24
|
+
DropdownMenuItem,
|
|
25
|
+
DropdownMenuSeparator,
|
|
26
|
+
DropdownMenuTrigger,
|
|
27
|
+
} from "@/components/ui/dropdown-menu";
|
|
28
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
29
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
30
|
+
import { AGENT_TYPE_LABELS } from "@/lib/agent-types";
|
|
31
|
+
import { formatRelativeTime } from "@/lib/project-overview";
|
|
32
|
+
import { SESSION_STATUS_DOT, SESSION_STATUS_LABEL } from "@/lib/session-status";
|
|
33
|
+
import { describeSessionModel } from "@/lib/session-summary";
|
|
34
|
+
import { displaySessionTitle } from "@/lib/session-title";
|
|
35
|
+
import { cn } from "@/lib/utils";
|
|
36
|
+
import type { Session } from "@/types";
|
|
37
|
+
|
|
38
|
+
export interface SessionRowProps {
|
|
39
|
+
session: Session;
|
|
40
|
+
/** Ada aksi berjalan untuk Session ini — kunci baris agar tidak dobel klik. */
|
|
41
|
+
busy: boolean;
|
|
42
|
+
onOpen: () => void;
|
|
43
|
+
onStop: () => void;
|
|
44
|
+
onStart: () => void;
|
|
45
|
+
onDelete: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* Selection mode (hapus massal): kotak centang di depan, klik baris
|
|
48
|
+
* memilih/membatalkan pilihan, menu & chevron disembunyikan.
|
|
49
|
+
*/
|
|
50
|
+
selectionMode?: boolean;
|
|
51
|
+
selected?: boolean;
|
|
52
|
+
onToggleSelect?: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Kotak centang ala GtkCheckButton (GTK4): kotak membulat, terisi aksen. */
|
|
56
|
+
function CheckBox({ checked }: { checked: boolean }) {
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
aria-hidden
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex size-[18px] shrink-0 items-center justify-center rounded-[5px] border-2 transition-colors",
|
|
62
|
+
checked
|
|
63
|
+
? "border-primary bg-primary text-primary-foreground"
|
|
64
|
+
: "border-muted-foreground/45",
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
{checked && <CheckIcon className="size-3" strokeWidth={3.5} />}
|
|
68
|
+
</span>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Warna teks status di subjudul (selaras status MCP di panel samping). */
|
|
73
|
+
const STATUS_TEXT: Record<Session["status"], string> = {
|
|
74
|
+
running: "text-emerald-700 dark:text-emerald-400",
|
|
75
|
+
crashed: "text-destructive",
|
|
76
|
+
stopped: "text-muted-foreground",
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Status di awal subjudul — titik + label berwarna, seperti status MCP.
|
|
81
|
+
* `stopped` (keadaan biasa) tidak ditampilkan agar daftar tidak ramai.
|
|
82
|
+
*/
|
|
83
|
+
function StatusLabel({ session, busy }: { session: Session; busy: boolean }) {
|
|
84
|
+
if (busy) {
|
|
85
|
+
return (
|
|
86
|
+
<span className="inline-flex shrink-0 items-center gap-1.5">
|
|
87
|
+
<Spinner className="size-3" />
|
|
88
|
+
Working…
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (session.status === "stopped") return null;
|
|
93
|
+
const running = session.status === "running";
|
|
94
|
+
return (
|
|
95
|
+
<span className={cn("inline-flex shrink-0 items-center gap-1.5", STATUS_TEXT[session.status])}>
|
|
96
|
+
<span className="relative flex size-1.5" aria-hidden>
|
|
97
|
+
{running && (
|
|
98
|
+
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-60" />
|
|
99
|
+
)}
|
|
100
|
+
<span
|
|
101
|
+
className={cn(
|
|
102
|
+
"relative inline-flex size-1.5 rounded-full",
|
|
103
|
+
SESSION_STATUS_DOT[session.status],
|
|
104
|
+
)}
|
|
105
|
+
/>
|
|
106
|
+
</span>
|
|
107
|
+
{SESSION_STATUS_LABEL[session.status]}
|
|
108
|
+
</span>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Pemisah "·" di subjudul; jarak kiri/kanan datang dari `gap` induk. */
|
|
113
|
+
function Dot() {
|
|
114
|
+
return (
|
|
115
|
+
<span className="opacity-50" aria-hidden>
|
|
116
|
+
·
|
|
117
|
+
</span>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function SessionRow({
|
|
122
|
+
session,
|
|
123
|
+
busy,
|
|
124
|
+
onOpen,
|
|
125
|
+
onStop,
|
|
126
|
+
onStart,
|
|
127
|
+
onDelete,
|
|
128
|
+
selectionMode = false,
|
|
129
|
+
selected = false,
|
|
130
|
+
onToggleSelect,
|
|
131
|
+
}: SessionRowProps) {
|
|
132
|
+
const running = session.status === "running";
|
|
133
|
+
const title = displaySessionTitle(session.title);
|
|
134
|
+
const agentLabel = AGENT_TYPE_LABELS[session.agentType];
|
|
135
|
+
const hasStatus = busy || session.status !== "stopped";
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
// Baris AdwActionRow yang bisa diaktifkan — sama dengan baris di panel
|
|
139
|
+
// samping: ikon simbolik 18px, judul 15px + subjudul 13px, suffix, lalu
|
|
140
|
+
// chevron. Tombol buka "direntangkan" (after:inset-0) sehingga seluruh
|
|
141
|
+
// baris, termasuk chevron, bisa diklik; tombol menu berada di atasnya.
|
|
142
|
+
<div
|
|
143
|
+
className={cn(
|
|
144
|
+
"group relative flex min-h-14 items-center gap-3 pr-4 pl-4 transition-colors hover:bg-muted/60 has-focus-visible:bg-muted/60 has-[button:active]:bg-muted",
|
|
145
|
+
selectionMode && selected && "bg-primary/[0.06]",
|
|
146
|
+
)}
|
|
147
|
+
>
|
|
148
|
+
{selectionMode && <CheckBox checked={selected} />}
|
|
149
|
+
<AgentLogo agentType={session.agentType} className="size-[18px] text-muted-foreground" />
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={selectionMode ? onToggleSelect : onOpen}
|
|
153
|
+
disabled={busy}
|
|
154
|
+
aria-pressed={selectionMode ? selected : undefined}
|
|
155
|
+
title={`${title} — ${agentLabel}`}
|
|
156
|
+
className="flex min-w-0 flex-1 flex-col gap-0.5 py-2.5 text-left outline-none after:absolute after:inset-0 focus-visible:after:ring-[3px] focus-visible:after:ring-ring/40 focus-visible:after:ring-inset disabled:opacity-60"
|
|
157
|
+
aria-label={`${selectionMode ? "Select" : "Open"} session ${title}, ${agentLabel}, ${SESSION_STATUS_LABEL[session.status]}`}
|
|
158
|
+
>
|
|
159
|
+
<span className="truncate text-[15px] leading-5">{title}</span>
|
|
160
|
+
<span className="flex min-w-0 items-center gap-1.5 text-[13px] leading-snug text-muted-foreground">
|
|
161
|
+
<StatusLabel session={session} busy={busy} />
|
|
162
|
+
{/* Layar sempit + ada status: model disembunyikan (terpotong jadi
|
|
163
|
+
tak terbaca); status & waktu lebih penting. */}
|
|
164
|
+
<span className={cn("contents", hasStatus && "max-sm:hidden")}>
|
|
165
|
+
{hasStatus && <Dot />}
|
|
166
|
+
<span className="truncate">{describeSessionModel(session)}</span>
|
|
167
|
+
</span>
|
|
168
|
+
<Dot />
|
|
169
|
+
<span className="shrink-0 tabular-nums">{formatRelativeTime(session.updatedAt)}</span>
|
|
170
|
+
</span>
|
|
171
|
+
</button>
|
|
172
|
+
|
|
173
|
+
{!selectionMode && (
|
|
174
|
+
<>
|
|
175
|
+
<DropdownMenu>
|
|
176
|
+
<DropdownMenuTrigger asChild>
|
|
177
|
+
{/* Suffix: tombol bundar datar. Mouse: muncul saat hover/fokus;
|
|
178
|
+
layar sentuh: selalu tampil. */}
|
|
179
|
+
<Button
|
|
180
|
+
type="button"
|
|
181
|
+
variant="ghost"
|
|
182
|
+
size="icon-sm"
|
|
183
|
+
disabled={busy}
|
|
184
|
+
aria-label={`Session actions ${title}`}
|
|
185
|
+
className="relative z-10 shrink-0 rounded-full text-muted-foreground transition-opacity pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100"
|
|
186
|
+
>
|
|
187
|
+
<MoreHorizontalIcon />
|
|
188
|
+
</Button>
|
|
189
|
+
</DropdownMenuTrigger>
|
|
190
|
+
<DropdownMenuContent align="end">
|
|
191
|
+
{running ? (
|
|
192
|
+
<DropdownMenuItem onSelect={onStop}>
|
|
193
|
+
<SquareIcon />
|
|
194
|
+
Stop
|
|
195
|
+
</DropdownMenuItem>
|
|
196
|
+
) : (
|
|
197
|
+
<DropdownMenuItem onSelect={onStart}>
|
|
198
|
+
<PlayIcon />
|
|
199
|
+
Start
|
|
200
|
+
</DropdownMenuItem>
|
|
201
|
+
)}
|
|
202
|
+
<DropdownMenuSeparator />
|
|
203
|
+
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
|
204
|
+
<Trash2Icon />
|
|
205
|
+
Delete permanently
|
|
206
|
+
</DropdownMenuItem>
|
|
207
|
+
</DropdownMenuContent>
|
|
208
|
+
</DropdownMenu>
|
|
209
|
+
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Placeholder daftar Session saat memuat — bentuk boxed list yang sama. */
|
|
217
|
+
export function SessionListSkeleton() {
|
|
218
|
+
return (
|
|
219
|
+
<div className="flex flex-col gap-2.5" aria-hidden>
|
|
220
|
+
<div className="flex flex-col gap-1.5 px-1">
|
|
221
|
+
<Skeleton className="h-4 w-20" />
|
|
222
|
+
<Skeleton className="h-3 w-36" />
|
|
223
|
+
</div>
|
|
224
|
+
<div className="flex flex-col divide-y overflow-hidden rounded-xl border bg-card">
|
|
225
|
+
{[0, 1, 2].map((i) => (
|
|
226
|
+
<div key={i} className="flex min-h-14 items-center gap-3 px-4 py-2.5">
|
|
227
|
+
<Skeleton className="size-[18px] shrink-0 rounded" />
|
|
228
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
229
|
+
<Skeleton className="h-4" style={{ width: `${55 - i * 10}%` }} />
|
|
230
|
+
<Skeleton className="h-3 w-40" />
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
))}
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timeline percakapan terstruktur: daftar pesan yang dapat di-scroll dengan
|
|
3
|
+
* tombol "kembali ke bawah", maskot global, serta catatan error/empty/status.
|
|
4
|
+
*
|
|
5
|
+
* Dipisah dari `SessionView` agar halaman hanya menyusun area; pemetaan
|
|
6
|
+
* pesan -> grup turn & status maskot dihitung di sini dari `messages` murni
|
|
7
|
+
* (`lib/turns.ts`).
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
10
|
+
import {
|
|
11
|
+
MessageScroller,
|
|
12
|
+
MessageScrollerButton,
|
|
13
|
+
MessageScrollerContent,
|
|
14
|
+
MessageScrollerItem,
|
|
15
|
+
MessageScrollerProvider,
|
|
16
|
+
MessageScrollerViewport,
|
|
17
|
+
useMessageScroller,
|
|
18
|
+
} from "@/components/ui/message-scroller";
|
|
19
|
+
import type { CollapsibleState } from "@/lib/collapsible";
|
|
20
|
+
import { TIMELINE_MAX_W } from "@/lib/layout";
|
|
21
|
+
import { groupTurns, lastAssistantGroup, textOf, turnStatus } from "@/lib/turns";
|
|
22
|
+
import { cn } from "@/lib/utils";
|
|
23
|
+
import type { SessionMessage, SessionStatus } from "@/types";
|
|
24
|
+
import { AssistantTurn } from "./AssistantTurn";
|
|
25
|
+
import { Mascot } from "./Mascot";
|
|
26
|
+
import { MessageRail, type UserMessageMark } from "./MessageRail";
|
|
27
|
+
import { UserMessage } from "./UserMessage";
|
|
28
|
+
|
|
29
|
+
export interface SessionTimelineProps {
|
|
30
|
+
/** Error banner global (bukan error kartu prompt). */
|
|
31
|
+
error: string | null;
|
|
32
|
+
/** Status Session saat ini (catatan "Session stopped…" di bawah). */
|
|
33
|
+
status: SessionStatus;
|
|
34
|
+
messages: SessionMessage[];
|
|
35
|
+
/** Status toggle blok "Thought process" per turn. */
|
|
36
|
+
collapsible: CollapsibleState;
|
|
37
|
+
onToggle: (key: string) => void;
|
|
38
|
+
/** Model sedang merespon (turn aktif + Session running). */
|
|
39
|
+
generating: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function SessionTimeline({
|
|
43
|
+
error,
|
|
44
|
+
status,
|
|
45
|
+
messages,
|
|
46
|
+
collapsible,
|
|
47
|
+
onToggle,
|
|
48
|
+
generating,
|
|
49
|
+
}: SessionTimelineProps) {
|
|
50
|
+
/**
|
|
51
|
+
* Ambang "menempel ke bawah" (dan kemunculan tombol panah) = setinggi SATU
|
|
52
|
+
* viewport. Tinggi area scroll diukur dinamis (bukan angka tetap) agar
|
|
53
|
+
* perilakunya benar di tiap ukuran layar / saat tinggi area berubah (mis.
|
|
54
|
+
* composer membesar).
|
|
55
|
+
*/
|
|
56
|
+
const viewportRef = useRef<HTMLDivElement | null>(null);
|
|
57
|
+
const [viewportHeight, setViewportHeight] = useState(0);
|
|
58
|
+
|
|
59
|
+
useLayoutEffect(() => {
|
|
60
|
+
const viewport = viewportRef.current;
|
|
61
|
+
if (viewport === null) return;
|
|
62
|
+
const update = () => setViewportHeight(viewport.clientHeight);
|
|
63
|
+
update();
|
|
64
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
65
|
+
const observer = new ResizeObserver(update);
|
|
66
|
+
observer.observe(viewport);
|
|
67
|
+
return () => observer.disconnect();
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const groups = useMemo(() => groupTurns(messages), [messages]);
|
|
71
|
+
const lastTurn = lastAssistantGroup(groups);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Penanda rail: satu per pesan user, dengan cuplikan teks untuk tooltip.
|
|
75
|
+
* Pesan tanpa teks (mis. lampiran saja) tetap dapat penanda.
|
|
76
|
+
*/
|
|
77
|
+
const marks = useMemo<UserMessageMark[]>(
|
|
78
|
+
() =>
|
|
79
|
+
messages
|
|
80
|
+
.filter((m) => m.role === "user")
|
|
81
|
+
.map((m) => {
|
|
82
|
+
const text = textOf(m.parts).trim().replace(/\s+/g, " ");
|
|
83
|
+
return {
|
|
84
|
+
id: m.id,
|
|
85
|
+
preview: text === "" ? "Attachment" : text.slice(0, 120),
|
|
86
|
+
};
|
|
87
|
+
}),
|
|
88
|
+
[messages],
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
/** Elemen tiap pesan user (target observer & tujuan lompat). */
|
|
92
|
+
const markEls = useRef(new Map<string, HTMLElement>());
|
|
93
|
+
const [activeMark, setActiveMark] = useState<string | null>(null);
|
|
94
|
+
|
|
95
|
+
// Penanda aktif = pesan user paling bawah yang bagian atasnya sudah lewat
|
|
96
|
+
// (atau dekat) tepi atas viewport — itulah bagian percakapan yang sedang
|
|
97
|
+
// dibaca. Dihitung dari rect, tidak menyimpan state scroll sendiri.
|
|
98
|
+
const syncActive = useCallback(() => {
|
|
99
|
+
const viewport = viewportRef.current;
|
|
100
|
+
if (viewport === null) return;
|
|
101
|
+
const top = viewport.getBoundingClientRect().top;
|
|
102
|
+
let current: string | null = null;
|
|
103
|
+
for (const mark of marks) {
|
|
104
|
+
const el = markEls.current.get(mark.id);
|
|
105
|
+
if (el && el.getBoundingClientRect().top - top <= 24) current = mark.id;
|
|
106
|
+
}
|
|
107
|
+
setActiveMark(current ?? marks[0]?.id ?? null);
|
|
108
|
+
}, [marks]);
|
|
109
|
+
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
const viewport = viewportRef.current;
|
|
112
|
+
if (viewport === null || marks.length < 2) return;
|
|
113
|
+
syncActive();
|
|
114
|
+
// `passive`: handler hanya membaca rect, tidak pernah preventDefault.
|
|
115
|
+
viewport.addEventListener("scroll", syncActive, { passive: true });
|
|
116
|
+
return () => viewport.removeEventListener("scroll", syncActive);
|
|
117
|
+
}, [marks.length, syncActive]);
|
|
118
|
+
|
|
119
|
+
const registerMarkEl = useCallback((id: string, el: HTMLElement | null) => {
|
|
120
|
+
if (el) markEls.current.set(id, el);
|
|
121
|
+
else markEls.current.delete(id);
|
|
122
|
+
}, []);
|
|
123
|
+
|
|
124
|
+
const jumpTo = useCallback((id: string) => {
|
|
125
|
+
const el = markEls.current.get(id);
|
|
126
|
+
const viewport = viewportRef.current;
|
|
127
|
+
if (!el || !viewport) return;
|
|
128
|
+
// Lompat dengan sedikit ruang di atas agar pesan tidak menempel ke tepi.
|
|
129
|
+
const offset = el.getBoundingClientRect().top - viewport.getBoundingClientRect().top;
|
|
130
|
+
viewport.scrollTo({ top: viewport.scrollTop + offset - 16, behavior: "smooth" });
|
|
131
|
+
}, []);
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Maskot GLOBAL (satu instance, bukan per-turn): status dihitung dari grup
|
|
135
|
+
* assistant yang masih LIVE (sedang streaming) dan ditampilkan tetap di atas
|
|
136
|
+
* composer — tidak ikut scroll bersama riwayat percakapan.
|
|
137
|
+
*
|
|
138
|
+
* Turn lama yang sudah selesai TIDAK ikut menentukan status: begitu user
|
|
139
|
+
* mengirim pesan baru, grup assistant terakhir masih milik jawaban
|
|
140
|
+
* sebelumnya — tanpa filter ini maskot langsung menampilkan "Writing…"
|
|
141
|
+
* (dari jawaban lama) padahal model belum mulai merespons. Belum ada
|
|
142
|
+
* konten baru -> "Starting…" (lihat fallback di bawah).
|
|
143
|
+
*/
|
|
144
|
+
const liveTurn = lastTurn?.messages.some((m) => m.streaming === true) ? lastTurn : null;
|
|
145
|
+
const mascotStatus = generating
|
|
146
|
+
? ((liveTurn ? turnStatus(liveTurn.messages, true) : null) ?? "Starting…")
|
|
147
|
+
: null;
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
// `autoScroll` bawaan provider DIMATIKAN: mesinnya menempel ke dasar setiap
|
|
151
|
+
// kali tinggi konten berubah — termasuk saat user membuka/menutup blok
|
|
152
|
+
// "Thought process" (state collapsible) sehingga layar terseret ke bawah.
|
|
153
|
+
// Penggantinya `AutoScrollFollow` di bawah: hanya mengikuti saat isi
|
|
154
|
+
// TIMELINE bertambah (pesan baru/streaming/status/error), bukan saat
|
|
155
|
+
// toggle collapsible. `scrollEdgeThreshold` tetap dipakai untuk ambang
|
|
156
|
+
// tombol panah (1 viewport).
|
|
157
|
+
<MessageScrollerProvider autoScroll={false} scrollEdgeThreshold={viewportHeight}>
|
|
158
|
+
{/* Pengganti auto-follow bawaan — lihat komentar komponen di bawah. */}
|
|
159
|
+
<AutoScrollFollow
|
|
160
|
+
messages={messages}
|
|
161
|
+
status={status}
|
|
162
|
+
error={error}
|
|
163
|
+
viewportRef={viewportRef}
|
|
164
|
+
/>
|
|
165
|
+
<MessageScroller className="min-h-0 flex-1">
|
|
166
|
+
<MessageScrollerViewport ref={viewportRef}>
|
|
167
|
+
{/* `TIMELINE_MAX_W mx-auto`: di layar lebar baris teks yang membentang
|
|
168
|
+
penuh sulit dibaca; kolom percakapan dibatasi dan dipusatkan
|
|
169
|
+
seperti aplikasi chat lain, sementara scrollbar tetap di tepi. */}
|
|
170
|
+
<MessageScrollerContent
|
|
171
|
+
className={cn("mx-auto w-full gap-6 px-3 py-4 sm:gap-8 sm:px-4", TIMELINE_MAX_W)}
|
|
172
|
+
>
|
|
173
|
+
{error && (
|
|
174
|
+
<MessageScrollerItem messageId="error">
|
|
175
|
+
<p className="text-sm text-destructive">{error}</p>
|
|
176
|
+
</MessageScrollerItem>
|
|
177
|
+
)}
|
|
178
|
+
{groups.map((group) =>
|
|
179
|
+
group.kind === "user" ? (
|
|
180
|
+
<div
|
|
181
|
+
key={group.message.id}
|
|
182
|
+
ref={(el) => registerMarkEl(group.message.id, el)}
|
|
183
|
+
// `scroll-mt-4`: cadangan bila lompat dilakukan browser.
|
|
184
|
+
className="scroll-mt-4"
|
|
185
|
+
>
|
|
186
|
+
<UserMessage message={group.message} />
|
|
187
|
+
</div>
|
|
188
|
+
) : (
|
|
189
|
+
<AssistantTurn
|
|
190
|
+
key={group.id}
|
|
191
|
+
group={group}
|
|
192
|
+
collapsible={collapsible}
|
|
193
|
+
onToggle={onToggle}
|
|
194
|
+
/>
|
|
195
|
+
),
|
|
196
|
+
)}
|
|
197
|
+
{/* Maskot GLOBAL — selalu menjadi item paling bawah di scroller,
|
|
198
|
+
ikut scroll bersama konten (ala Claude). Status:
|
|
199
|
+
- "Starting…" saat generating tapi respons belum datang
|
|
200
|
+
- mascotStatus (Working/Thinking/Writing/tool) saat ada turn aktif
|
|
201
|
+
- standby (logo diam) setelah selesai, selama ada riwayat */}
|
|
202
|
+
{messages.length > 0 && (
|
|
203
|
+
<MessageScrollerItem messageId="mascot">
|
|
204
|
+
<Mascot
|
|
205
|
+
label={generating ? (mascotStatus ?? "Starting…") : null}
|
|
206
|
+
active={generating}
|
|
207
|
+
standby
|
|
208
|
+
className="px-1"
|
|
209
|
+
/>
|
|
210
|
+
</MessageScrollerItem>
|
|
211
|
+
)}
|
|
212
|
+
</MessageScrollerContent>
|
|
213
|
+
</MessageScrollerViewport>
|
|
214
|
+
{/* Rail penanda pesan user (tepi kiri area percakapan). */}
|
|
215
|
+
<MessageRail marks={marks} activeId={activeMark} onJump={jumpTo} />
|
|
216
|
+
<MessageScrollerButton />
|
|
217
|
+
</MessageScroller>
|
|
218
|
+
</MessageScrollerProvider>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
interface AutoScrollFollowProps {
|
|
223
|
+
/** Perubahan pada prop berikut yang boleh memicu auto-follow ke bawah. */
|
|
224
|
+
messages: SessionMessage[];
|
|
225
|
+
status: SessionStatus;
|
|
226
|
+
error: string | null;
|
|
227
|
+
viewportRef: { current: HTMLDivElement | null };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Pengganti `autoScroll` bawaan MessageScroller (yang dimatikan di provider).
|
|
232
|
+
*
|
|
233
|
+
* Aturan UX:
|
|
234
|
+
* - Selama pengguna masih kurang dari satu layar di atas dasar percakapan
|
|
235
|
+
* (tombol panah belum muncul), isi timeline yang BERTAMBAH — respon baru /
|
|
236
|
+
* pesan streaming / catatan status — tetap memaksa scroll ke dasar.
|
|
237
|
+
* - Setelah scroll ke atas satu layar penuh atau lebih, konten baru TIDAK
|
|
238
|
+
* menarik layar (tombol panah yang mengembalikannya).
|
|
239
|
+
* - Membuka/menutup blok "Thought process" TIDAK memicu scroll: toggle hanya
|
|
240
|
+
* mengubah state collapsible (bukan `messages`/`status`/`error`), jadi efek
|
|
241
|
+
* ini tidak berjalan dan posisi baca pengguna tetap dipertahankan.
|
|
242
|
+
*
|
|
243
|
+
* Scroll hanya dijalankan bila tinggi konten benar-benar bertambah (perubahan
|
|
244
|
+
* pesan yang tidak mengubah layout — mis. part tool di blok yang masih
|
|
245
|
+
* collapsed — tidak boleh menarik layar).
|
|
246
|
+
*/
|
|
247
|
+
function AutoScrollFollow({ messages, status, error, viewportRef }: AutoScrollFollowProps) {
|
|
248
|
+
const { scrollToEnd } = useMessageScroller();
|
|
249
|
+
/** scrollHeight dari commit terakhir — baseline deteksi konten bertambah. */
|
|
250
|
+
const lastContentHeightRef = useRef<number | null>(null);
|
|
251
|
+
|
|
252
|
+
// Dipicu hanya saat isi timeline berubah (pesan/status/error) — bukan saat
|
|
253
|
+
// toggle collapsible. Diletakkan SEBELUM efek baseline di bawah: di commit
|
|
254
|
+
// yang sama ia membaca tinggi konten dari commit SEBELUMNYA.
|
|
255
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `messages`/`status`/`error` sengaja jadi trigger — keputusan scroll dibaca dari DOM (scrollHeight/scrollTop), bukan dari nilai closure.
|
|
256
|
+
useLayoutEffect(() => {
|
|
257
|
+
const viewport = viewportRef.current;
|
|
258
|
+
if (viewport === null) return;
|
|
259
|
+
const previousHeight = lastContentHeightRef.current;
|
|
260
|
+
const grew = previousHeight === null || viewport.scrollHeight > previousHeight;
|
|
261
|
+
if (!grew) return;
|
|
262
|
+
const distanceToBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
|
263
|
+
if (distanceToBottom <= viewport.clientHeight) scrollToEnd();
|
|
264
|
+
}, [messages, status, error, viewportRef, scrollToEnd]);
|
|
265
|
+
|
|
266
|
+
// Baseline tinggi konten selalu diperbarui di setiap commit — termasuk saat
|
|
267
|
+
// toggle collapsible / resize — agar commit pesan berikutnya dibandingkan
|
|
268
|
+
// dengan tinggi yang benar.
|
|
269
|
+
useLayoutEffect(() => {
|
|
270
|
+
const viewport = viewportRef.current;
|
|
271
|
+
if (viewport !== null) lastContentHeightRef.current = viewport.scrollHeight;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kerangka loading percakapan Session.
|
|
3
|
+
*
|
|
4
|
+
* Dipakai di dua fase berurutan dengan tampilan IDENTIK agar tidak ada
|
|
5
|
+
* kedipan saat fase berganti:
|
|
6
|
+
* 1. `SessionPage` — data Session dimuat (`GET /api/sessions`).
|
|
7
|
+
* 2. `SessionView` — Session sudah ada, menunggu riwayat (`history`) dari
|
|
8
|
+
* WebSocket. Tanpa ini, `messages` kosong sesaat tampil sebagai
|
|
9
|
+
* empty-state "No conversation yet" padahal percakapannya ada.
|
|
10
|
+
*
|
|
11
|
+
* Bentuk meniru timeline asli (gelembung user di kanan, paragraf assistant
|
|
12
|
+
* di kiri) dengan lebar kolom yang sama (`TIMELINE_MAX_W`), lalu memudar
|
|
13
|
+
* masuk setelah jeda singkat — koneksi cepat tidak sempat melihat kilatan
|
|
14
|
+
* kerangka sama sekali.
|
|
15
|
+
*/
|
|
16
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
17
|
+
import { CONVERSATION_MAX_W, TIMELINE_MAX_W } from "@/lib/layout";
|
|
18
|
+
import { cn } from "@/lib/utils";
|
|
19
|
+
|
|
20
|
+
/** Satu turn: gelembung user + beberapa baris jawaban. */
|
|
21
|
+
const TURNS: { user: string; lines: string[] }[] = [
|
|
22
|
+
{ user: "w-[46%]", lines: ["w-[92%]", "w-[84%]", "w-[58%]"] },
|
|
23
|
+
{ user: "w-[32%]", lines: ["w-[88%]", "w-[70%]"] },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function SessionTimelineSkeleton({ className }: { className?: string }) {
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
role="status"
|
|
30
|
+
aria-live="polite"
|
|
31
|
+
aria-label="Loading conversation"
|
|
32
|
+
className={cn(
|
|
33
|
+
"min-h-0 flex-1 overflow-hidden",
|
|
34
|
+
// Muncul setelah 150ms: muatan cepat tidak berkedip.
|
|
35
|
+
"animate-in fade-in fill-mode-both delay-150 duration-300 motion-reduce:animate-none",
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<div
|
|
40
|
+
className={cn(
|
|
41
|
+
"mx-auto flex w-full flex-col gap-6 px-3 py-4 sm:gap-8 sm:px-4",
|
|
42
|
+
TIMELINE_MAX_W,
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{TURNS.map((t, i) => (
|
|
46
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: kerangka statis
|
|
47
|
+
<div key={i} className="flex flex-col gap-4">
|
|
48
|
+
<Skeleton className={cn("ml-auto h-11 rounded-2xl rounded-br-md", t.user)} />
|
|
49
|
+
<div className="flex flex-col gap-2.5 px-1">
|
|
50
|
+
{t.lines.map((w, j) => (
|
|
51
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: kerangka statis
|
|
52
|
+
<Skeleton key={j} className={cn("h-3.5 rounded-full", w)} />
|
|
53
|
+
))}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
<span className="sr-only">Loading conversation…</span>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Kerangka composer. Struktur & ukuran MENIRU `SessionComposer` (footer +
|
|
65
|
+
* kotak `rounded-xl border p-2` + satu baris setinggi `size-9`) sehingga
|
|
66
|
+
* tingginya identik (54px) dan tidak ada lompatan saat composer asli muncul.
|
|
67
|
+
*/
|
|
68
|
+
export function SessionComposerSkeleton() {
|
|
69
|
+
return (
|
|
70
|
+
<div 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">
|
|
71
|
+
<div
|
|
72
|
+
className={cn(
|
|
73
|
+
"mx-auto flex w-full items-center gap-2 rounded-xl border bg-card/80 p-2 shadow-sm",
|
|
74
|
+
CONVERSATION_MAX_W,
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
<Skeleton className="size-9 shrink-0 rounded-md" />
|
|
78
|
+
<Skeleton className="h-3.5 flex-1 rounded-full" />
|
|
79
|
+
<Skeleton className="size-9 shrink-0 rounded-md" />
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|