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,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Satu baris Session di daftar (`SessionList`).
|
|
3
|
+
*
|
|
4
|
+
* Baris polos tanpa kotak: titik status, judul, meta tipis, waktu di kanan.
|
|
5
|
+
* Badan baris adalah tombol buka (target sentuh lebar); aksi sekunder ada di
|
|
6
|
+
* menu "..." — di perangkat dengan pointer halus (mouse) menu baru muncul
|
|
7
|
+
* saat hover/fokus agar daftar tetap bersih, di layar sentuh selalu tampil.
|
|
8
|
+
*/
|
|
9
|
+
import { MoreHorizontalIcon, PlayIcon, SquareIcon, Trash2Icon } from "lucide-react";
|
|
10
|
+
import { Button } from "@/components/ui/button";
|
|
11
|
+
import {
|
|
12
|
+
DropdownMenu,
|
|
13
|
+
DropdownMenuContent,
|
|
14
|
+
DropdownMenuItem,
|
|
15
|
+
DropdownMenuSeparator,
|
|
16
|
+
DropdownMenuTrigger,
|
|
17
|
+
} from "@/components/ui/dropdown-menu";
|
|
18
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
19
|
+
import { Spinner } from "@/components/ui/spinner";
|
|
20
|
+
import { formatRelativeTime } from "@/lib/project-overview";
|
|
21
|
+
import { SESSION_STATUS_DOT, SESSION_STATUS_LABEL } from "@/lib/session-status";
|
|
22
|
+
import { describeSessionModel } from "@/lib/session-summary";
|
|
23
|
+
import { displaySessionTitle } from "@/lib/session-title";
|
|
24
|
+
import { cn } from "@/lib/utils";
|
|
25
|
+
import type { Session } from "@/types";
|
|
26
|
+
|
|
27
|
+
export interface SessionRowProps {
|
|
28
|
+
session: Session;
|
|
29
|
+
/** Ada aksi berjalan untuk Session ini — kunci baris agar tidak dobel klik. */
|
|
30
|
+
busy: boolean;
|
|
31
|
+
onOpen: () => void;
|
|
32
|
+
onStop: () => void;
|
|
33
|
+
onStart: () => void;
|
|
34
|
+
onDelete: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Titik status; running diberi denyut halus. */
|
|
38
|
+
function StatusDot({ session, busy }: { session: Session; busy: boolean }) {
|
|
39
|
+
if (busy) return <Spinner className="size-3 text-muted-foreground" />;
|
|
40
|
+
const running = session.status === "running";
|
|
41
|
+
return (
|
|
42
|
+
<span className="relative flex size-2" aria-hidden>
|
|
43
|
+
{running && (
|
|
44
|
+
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-500 opacity-50" />
|
|
45
|
+
)}
|
|
46
|
+
<span
|
|
47
|
+
className={cn(
|
|
48
|
+
"relative inline-flex size-2 rounded-full",
|
|
49
|
+
SESSION_STATUS_DOT[session.status],
|
|
50
|
+
)}
|
|
51
|
+
/>
|
|
52
|
+
</span>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function SessionRow({ session, busy, onOpen, onStop, onStart, onDelete }: SessionRowProps) {
|
|
57
|
+
const running = session.status === "running";
|
|
58
|
+
const title = displaySessionTitle(session.title);
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<div className="group relative flex items-center rounded-lg transition-colors hover:bg-muted/50 has-focus-visible:bg-muted/50 has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50">
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
onClick={onOpen}
|
|
65
|
+
disabled={busy}
|
|
66
|
+
className="flex min-w-0 flex-1 items-center gap-3.5 rounded-lg px-3 py-3 text-left focus-visible:outline-none disabled:opacity-60"
|
|
67
|
+
aria-label={`Open session ${title}, ${SESSION_STATUS_LABEL[session.status]}`}
|
|
68
|
+
>
|
|
69
|
+
<span className="flex size-3 shrink-0 items-center justify-center">
|
|
70
|
+
<StatusDot session={session} busy={busy} />
|
|
71
|
+
</span>
|
|
72
|
+
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
73
|
+
<span className="truncate text-[15px] font-medium leading-snug">{title}</span>
|
|
74
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
75
|
+
{session.agentType}
|
|
76
|
+
<span className="mx-1.5 opacity-50">/</span>
|
|
77
|
+
{describeSessionModel(session)}
|
|
78
|
+
</span>
|
|
79
|
+
</span>
|
|
80
|
+
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
|
81
|
+
{formatRelativeTime(session.updatedAt)}
|
|
82
|
+
</span>
|
|
83
|
+
</button>
|
|
84
|
+
|
|
85
|
+
<DropdownMenu>
|
|
86
|
+
<DropdownMenuTrigger asChild>
|
|
87
|
+
<Button
|
|
88
|
+
type="button"
|
|
89
|
+
variant="ghost"
|
|
90
|
+
size="icon-sm"
|
|
91
|
+
disabled={busy}
|
|
92
|
+
aria-label={`Session actions ${title}`}
|
|
93
|
+
className="mr-1.5 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"
|
|
94
|
+
>
|
|
95
|
+
<MoreHorizontalIcon />
|
|
96
|
+
</Button>
|
|
97
|
+
</DropdownMenuTrigger>
|
|
98
|
+
<DropdownMenuContent align="end">
|
|
99
|
+
{running ? (
|
|
100
|
+
<DropdownMenuItem onSelect={onStop}>
|
|
101
|
+
<SquareIcon />
|
|
102
|
+
Stop
|
|
103
|
+
</DropdownMenuItem>
|
|
104
|
+
) : (
|
|
105
|
+
<DropdownMenuItem onSelect={onStart}>
|
|
106
|
+
<PlayIcon />
|
|
107
|
+
Start
|
|
108
|
+
</DropdownMenuItem>
|
|
109
|
+
)}
|
|
110
|
+
<DropdownMenuSeparator />
|
|
111
|
+
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
|
112
|
+
<Trash2Icon />
|
|
113
|
+
Delete permanently
|
|
114
|
+
</DropdownMenuItem>
|
|
115
|
+
</DropdownMenuContent>
|
|
116
|
+
</DropdownMenu>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Placeholder daftar Session saat memuat — menjaga tinggi konten. */
|
|
122
|
+
export function SessionListSkeleton() {
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex flex-col" aria-hidden>
|
|
125
|
+
{[0, 1, 2].map((i) => (
|
|
126
|
+
<div key={i} className="flex items-center gap-3.5 py-3">
|
|
127
|
+
<Skeleton className="size-2 shrink-0 rounded-full" />
|
|
128
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
129
|
+
<Skeleton className="h-4 w-48" />
|
|
130
|
+
<Skeleton className="h-3 w-32" />
|
|
131
|
+
</div>
|
|
132
|
+
<Skeleton className="h-3 w-16" />
|
|
133
|
+
</div>
|
|
134
|
+
))}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -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
|
+
}
|