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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine halaman daftar Session milik satu Project (`SessionList.tsx`).
|
|
3
|
+
*
|
|
4
|
+
* Mengelola pemuatan daftar (`GET /api/sessions` di-filter per Project),
|
|
5
|
+
* aksi per-Session (stop/start/hapus), selection mode untuk hapus massal,
|
|
6
|
+
* serta dialog konfirmasi hapus Project (yang menghapus seluruh Session-nya). Sisa logika form pembuatan Session
|
|
7
|
+
* baru hidup di `NewSessionDialog`.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
10
|
+
import { toast } from "sonner";
|
|
11
|
+
import { useResyncOnReconnect } from "@/hooks/useResyncOnReconnect";
|
|
12
|
+
import { useWebSocket } from "@/hooks/useWebSocket";
|
|
13
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
14
|
+
import { notifyDataChanged } from "@/lib/data-events";
|
|
15
|
+
import { pluralSessions, pruneSelection, toggleSelection } from "@/lib/session-selection";
|
|
16
|
+
import { type SessionSummary, sortSessions, summarizeSessions } from "@/lib/session-summary";
|
|
17
|
+
import type { Session } from "@/types";
|
|
18
|
+
|
|
19
|
+
export interface UseSessionListOptions {
|
|
20
|
+
projectId: string;
|
|
21
|
+
/** Dipanggil setelah Project dihapus (kembali ke daftar Project). */
|
|
22
|
+
onDeleted?: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SessionListEngine {
|
|
26
|
+
sessions: Session[];
|
|
27
|
+
loading: boolean;
|
|
28
|
+
loadError: string | null;
|
|
29
|
+
/** Session terurut: running -> crashed -> stopped, terbaru di atas. */
|
|
30
|
+
ordered: Session[];
|
|
31
|
+
summary: SessionSummary;
|
|
32
|
+
refresh: (opts?: { silent?: boolean }) => Promise<void>;
|
|
33
|
+
|
|
34
|
+
// Aksi per-Session
|
|
35
|
+
stopping: string | null;
|
|
36
|
+
stop: (sessionId: string) => Promise<void>;
|
|
37
|
+
starting: string | null;
|
|
38
|
+
start: (sessionId: string) => Promise<void>;
|
|
39
|
+
/** Session yang sedang dihapus (id), untuk spinner di baris. */
|
|
40
|
+
deleting: string | null;
|
|
41
|
+
pendingDelete: Session | null;
|
|
42
|
+
requestDelete: (session: Session) => void;
|
|
43
|
+
cancelDelete: () => void;
|
|
44
|
+
confirmRemove: () => Promise<void>;
|
|
45
|
+
|
|
46
|
+
// Selection mode (hapus massal)
|
|
47
|
+
selecting: boolean;
|
|
48
|
+
startSelecting: () => void;
|
|
49
|
+
stopSelecting: () => void;
|
|
50
|
+
/** Id Session terpilih (hanya yang masih ada). */
|
|
51
|
+
selected: ReadonlySet<string>;
|
|
52
|
+
toggleSelect: (sessionId: string) => void;
|
|
53
|
+
/** Pilih semua; bila semua sudah terpilih -> kosongkan. */
|
|
54
|
+
toggleSelectAll: () => void;
|
|
55
|
+
bulkDeleteOpen: boolean;
|
|
56
|
+
requestBulkDelete: () => void;
|
|
57
|
+
cancelBulkDelete: () => void;
|
|
58
|
+
/** Progres hapus massal; null = tidak berjalan. */
|
|
59
|
+
bulkProgress: { done: number; total: number } | null;
|
|
60
|
+
confirmBulkRemove: () => Promise<void>;
|
|
61
|
+
|
|
62
|
+
// Hapus Project (beserta seluruh Session-nya)
|
|
63
|
+
projectDeleteOpen: boolean;
|
|
64
|
+
openProjectDelete: () => void;
|
|
65
|
+
closeProjectDelete: () => void;
|
|
66
|
+
deletingProject: boolean;
|
|
67
|
+
confirmRemoveProject: () => Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function useSessionList({ projectId, onDeleted }: UseSessionListOptions): SessionListEngine {
|
|
71
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
72
|
+
const [loading, setLoading] = useState(true);
|
|
73
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
74
|
+
const [stopping, setStopping] = useState<string | null>(null);
|
|
75
|
+
const [starting, setStarting] = useState<string | null>(null);
|
|
76
|
+
const [deleting, setDeleting] = useState<string | null>(null);
|
|
77
|
+
/** Session yang akan dihapus, dikonfirmasi lewat `AlertDialog`. */
|
|
78
|
+
const [pendingDelete, setPendingDelete] = useState<Session | null>(null);
|
|
79
|
+
/** Konfirmasi hapus Project (beserta seluruh Session-nya). */
|
|
80
|
+
const [projectDeleteOpen, setProjectDeleteOpen] = useState(false);
|
|
81
|
+
const [deletingProject, setDeletingProject] = useState(false);
|
|
82
|
+
const [selecting, setSelecting] = useState(false);
|
|
83
|
+
const [rawSelected, setRawSelected] = useState<ReadonlySet<string>>(() => new Set());
|
|
84
|
+
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
|
85
|
+
const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null);
|
|
86
|
+
|
|
87
|
+
/** `silent`: muat ulang di latar (tanpa skeleton) — sinkron dari server. */
|
|
88
|
+
const refresh = useCallback(
|
|
89
|
+
async ({ silent = false }: { silent?: boolean } = {}) => {
|
|
90
|
+
if (!silent) setLoading(true);
|
|
91
|
+
setLoadError(null);
|
|
92
|
+
try {
|
|
93
|
+
const res = await apiFetch("/api/sessions");
|
|
94
|
+
const body = (await res.json()) as { sessions: Session[] };
|
|
95
|
+
setSessions(body.sessions.filter((s) => s.projectId === projectId));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
setLoadError(e instanceof ApiError ? e.message : "Failed to load sessions");
|
|
98
|
+
} finally {
|
|
99
|
+
setLoading(false);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
[projectId],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
void refresh();
|
|
107
|
+
}, [refresh]);
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Judul Session dibuat otomatis oleh opencode SETELAH prompt pertama —
|
|
111
|
+
* daftar yang dimuat saat halaman dibuka belum memuatnya. Satu koneksi WS
|
|
112
|
+
* mode daftar (`attach("")`, tanpa history) menerima broadcast
|
|
113
|
+
* `session_title`, `session_status`, dan `session_deleted` agar baris
|
|
114
|
+
* terkait terbarui live tanpa refresh manual.
|
|
115
|
+
*/
|
|
116
|
+
const ws = useWebSocket({
|
|
117
|
+
onMessage: (msg) => {
|
|
118
|
+
if (msg.type === "session_title") {
|
|
119
|
+
// Update baris terkait saja — state lain (urutan, summary) tidak berubah.
|
|
120
|
+
setSessions((prev) =>
|
|
121
|
+
prev.map((s) => (s.id === msg.sessionId ? { ...s, title: msg.title } : s)),
|
|
122
|
+
);
|
|
123
|
+
} else if (msg.type === "session_status") {
|
|
124
|
+
// Status berubah di tempat lain (Session view, crash, perangkat lain).
|
|
125
|
+
setSessions((prev) =>
|
|
126
|
+
prev.map((s) =>
|
|
127
|
+
s.id === msg.sessionId && s.status !== msg.status ? { ...s, status: msg.status } : s,
|
|
128
|
+
),
|
|
129
|
+
);
|
|
130
|
+
} else if (msg.type === "session_deleted") {
|
|
131
|
+
setSessions((prev) => prev.filter((s) => s.id !== msg.sessionId));
|
|
132
|
+
} else if (msg.type === "data_changed") {
|
|
133
|
+
// Session baru dibuat di perangkat/tab lain -> muat ulang tanpa skeleton.
|
|
134
|
+
void refresh({ silent: true });
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
ws.attach("");
|
|
140
|
+
return () => ws.disconnect();
|
|
141
|
+
}, [ws.attach, ws.disconnect]);
|
|
142
|
+
const resync = useCallback(() => refresh({ silent: true }), [refresh]);
|
|
143
|
+
useResyncOnReconnect(ws.status, resync);
|
|
144
|
+
|
|
145
|
+
// Running -> crashed -> stopped, terbaru di atas (lib/session-summary.ts).
|
|
146
|
+
const ordered = useMemo(() => sortSessions(sessions), [sessions]);
|
|
147
|
+
const summary = useMemo(() => summarizeSessions(sessions), [sessions]);
|
|
148
|
+
|
|
149
|
+
// Session yang hilang (dihapus di tab/perangkat lain) otomatis tidak
|
|
150
|
+
// terpilih lagi.
|
|
151
|
+
const selected = useMemo(() => pruneSelection(rawSelected, sessions), [rawSelected, sessions]);
|
|
152
|
+
|
|
153
|
+
/** Stop ≠ hapus: POST /stop hanya mengubah status; data tetap ada dan
|
|
154
|
+
bisa dihidupkan kembali lewat aksi Hidupkan (resume). */
|
|
155
|
+
const stop = useCallback(
|
|
156
|
+
async (sessionId: string) => {
|
|
157
|
+
setStopping(sessionId);
|
|
158
|
+
try {
|
|
159
|
+
await apiFetch(`/api/sessions/${sessionId}/stop`, { method: "POST" });
|
|
160
|
+
toast.success("Session stopped.");
|
|
161
|
+
await refresh();
|
|
162
|
+
notifyDataChanged();
|
|
163
|
+
} catch (e) {
|
|
164
|
+
toast.error(e instanceof ApiError ? e.message : "Couldn't stop the session.");
|
|
165
|
+
} finally {
|
|
166
|
+
setStopping(null);
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
[refresh],
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
/** Resume Session stopped/crashed: POST /api/sessions/:id. */
|
|
173
|
+
const start = useCallback(
|
|
174
|
+
async (sessionId: string) => {
|
|
175
|
+
setStarting(sessionId);
|
|
176
|
+
try {
|
|
177
|
+
await apiFetch(`/api/sessions/${sessionId}`, { method: "POST" });
|
|
178
|
+
toast.success("Session resumed.");
|
|
179
|
+
await refresh();
|
|
180
|
+
notifyDataChanged();
|
|
181
|
+
} catch (e) {
|
|
182
|
+
toast.error(e instanceof ApiError ? e.message : "Couldn't resume the session.");
|
|
183
|
+
} finally {
|
|
184
|
+
setStarting(null);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
[refresh],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const requestDelete = useCallback((session: Session) => setPendingDelete(session), []);
|
|
191
|
+
const cancelDelete = useCallback(() => setPendingDelete(null), []);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Hapus Session permanen — menghapus juga session (dan riwayat pesannya)
|
|
195
|
+
* di server headless opencode. Dikonfirmasi via `AlertDialog` sebelum
|
|
196
|
+
* eksekusi karena tidak bisa dibatalkan.
|
|
197
|
+
*/
|
|
198
|
+
const confirmRemove = useCallback(async () => {
|
|
199
|
+
const session = pendingDelete;
|
|
200
|
+
if (!session) return;
|
|
201
|
+
setDeleting(session.id);
|
|
202
|
+
try {
|
|
203
|
+
await apiFetch(`/api/sessions/${session.id}`, { method: "DELETE" });
|
|
204
|
+
setPendingDelete(null);
|
|
205
|
+
toast.success("Session deleted.");
|
|
206
|
+
await refresh();
|
|
207
|
+
notifyDataChanged();
|
|
208
|
+
} catch (e) {
|
|
209
|
+
toast.error(e instanceof ApiError ? e.message : "Couldn't delete the session.");
|
|
210
|
+
} finally {
|
|
211
|
+
setDeleting(null);
|
|
212
|
+
}
|
|
213
|
+
}, [pendingDelete, refresh]);
|
|
214
|
+
|
|
215
|
+
const startSelecting = useCallback(() => {
|
|
216
|
+
setRawSelected(new Set());
|
|
217
|
+
setSelecting(true);
|
|
218
|
+
}, []);
|
|
219
|
+
const stopSelecting = useCallback(() => {
|
|
220
|
+
setSelecting(false);
|
|
221
|
+
setRawSelected(new Set());
|
|
222
|
+
}, []);
|
|
223
|
+
const toggleSelect = useCallback(
|
|
224
|
+
(sessionId: string) => setRawSelected((prev) => toggleSelection(prev, sessionId)),
|
|
225
|
+
[],
|
|
226
|
+
);
|
|
227
|
+
const toggleSelectAll = useCallback(() => {
|
|
228
|
+
setRawSelected((prev) =>
|
|
229
|
+
sessions.length > 0 && sessions.every((s) => prev.has(s.id))
|
|
230
|
+
? new Set()
|
|
231
|
+
: new Set(sessions.map((s) => s.id)),
|
|
232
|
+
);
|
|
233
|
+
}, [sessions]);
|
|
234
|
+
const requestBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
|
|
235
|
+
const cancelBulkDelete = useCallback(() => setBulkDeleteOpen(false), []);
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Hapus massal Session terpilih — satu per satu (berurutan, agar server
|
|
239
|
+
* headless tidak dibanjiri), progres tampil di dialog konfirmasi. Yang
|
|
240
|
+
* gagal tetap terpilih supaya bisa dicoba lagi.
|
|
241
|
+
*/
|
|
242
|
+
const confirmBulkRemove = useCallback(async () => {
|
|
243
|
+
const ids = [...selected];
|
|
244
|
+
if (ids.length === 0) return;
|
|
245
|
+
const failed: string[] = [];
|
|
246
|
+
setBulkProgress({ done: 0, total: ids.length });
|
|
247
|
+
for (const [i, id] of ids.entries()) {
|
|
248
|
+
setDeleting(id);
|
|
249
|
+
try {
|
|
250
|
+
await apiFetch(`/api/sessions/${id}`, { method: "DELETE" });
|
|
251
|
+
} catch {
|
|
252
|
+
failed.push(id);
|
|
253
|
+
}
|
|
254
|
+
setBulkProgress({ done: i + 1, total: ids.length });
|
|
255
|
+
}
|
|
256
|
+
setDeleting(null);
|
|
257
|
+
setBulkProgress(null);
|
|
258
|
+
setBulkDeleteOpen(false);
|
|
259
|
+
const removed = ids.length - failed.length;
|
|
260
|
+
if (failed.length === 0) {
|
|
261
|
+
toast.success(`Deleted ${pluralSessions(removed)}.`);
|
|
262
|
+
setSelecting(false);
|
|
263
|
+
setRawSelected(new Set());
|
|
264
|
+
} else {
|
|
265
|
+
toast.error(`Couldn't delete ${failed.length} of ${pluralSessions(ids.length)}.`);
|
|
266
|
+
setRawSelected(new Set(failed));
|
|
267
|
+
}
|
|
268
|
+
await refresh({ silent: true });
|
|
269
|
+
notifyDataChanged();
|
|
270
|
+
}, [selected, refresh]);
|
|
271
|
+
|
|
272
|
+
const openProjectDelete = useCallback(() => setProjectDeleteOpen(true), []);
|
|
273
|
+
const closeProjectDelete = useCallback(() => setProjectDeleteOpen(false), []);
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Hapus pendaftaran Project. Server membersihkan seluruh Session (termasuk
|
|
277
|
+
* sesi remote opencode) lebih dulu; direktori kerja di filesystem tetap ada.
|
|
278
|
+
*/
|
|
279
|
+
const confirmRemoveProject = useCallback(async () => {
|
|
280
|
+
setDeletingProject(true);
|
|
281
|
+
try {
|
|
282
|
+
await apiFetch(`/api/projects/${projectId}`, { method: "DELETE" });
|
|
283
|
+
setProjectDeleteOpen(false);
|
|
284
|
+
toast.success("Project removed.");
|
|
285
|
+
notifyDataChanged();
|
|
286
|
+
onDeleted?.();
|
|
287
|
+
} catch (e) {
|
|
288
|
+
toast.error(e instanceof ApiError ? e.message : "Couldn't remove the project.");
|
|
289
|
+
} finally {
|
|
290
|
+
setDeletingProject(false);
|
|
291
|
+
}
|
|
292
|
+
}, [onDeleted, projectId]);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
sessions,
|
|
296
|
+
loading,
|
|
297
|
+
loadError,
|
|
298
|
+
ordered,
|
|
299
|
+
summary,
|
|
300
|
+
refresh,
|
|
301
|
+
stopping,
|
|
302
|
+
stop,
|
|
303
|
+
starting,
|
|
304
|
+
start,
|
|
305
|
+
deleting,
|
|
306
|
+
pendingDelete,
|
|
307
|
+
requestDelete,
|
|
308
|
+
cancelDelete,
|
|
309
|
+
confirmRemove,
|
|
310
|
+
selecting,
|
|
311
|
+
startSelecting,
|
|
312
|
+
stopSelecting,
|
|
313
|
+
selected,
|
|
314
|
+
toggleSelect,
|
|
315
|
+
toggleSelectAll,
|
|
316
|
+
bulkDeleteOpen,
|
|
317
|
+
requestBulkDelete,
|
|
318
|
+
cancelBulkDelete,
|
|
319
|
+
bulkProgress,
|
|
320
|
+
confirmBulkRemove,
|
|
321
|
+
projectDeleteOpen,
|
|
322
|
+
openProjectDelete,
|
|
323
|
+
closeProjectDelete,
|
|
324
|
+
deletingProject,
|
|
325
|
+
confirmRemoveProject,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data sidebar aplikasi: seluruh Project + Session, digroup per Project.
|
|
3
|
+
*
|
|
4
|
+
* - `GET /api/projects` + `GET /api/sessions` dimuat bersamaan, lalu
|
|
5
|
+
* dikelompokkan `lib/sidebar-groups.ts` (murni, teruji).
|
|
6
|
+
* - Dimuat ulang setiap `pathname` berubah, sehingga Session/Project yang
|
|
7
|
+
* baru dibuat atau dihapus ikut terlihat setelah navigasi.
|
|
8
|
+
* - Diperbarui live lewat WS mode daftar (`attach("")`) yang menerima
|
|
9
|
+
* broadcast global: `session_title` (judul), `session_status` (titik
|
|
10
|
+
* running/stopped/crashed), dan `session_deleted` (baris hilang).
|
|
11
|
+
* - Muat ulang penuh saat: `data_changed` dari server (Project/Session
|
|
12
|
+
* dibuat/dihapus di perangkat mana pun), `notifyDataChanged()` lokal, dan
|
|
13
|
+
* koneksi WS tersambung ulang (kabar yang terlewat selama putus).
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
16
|
+
import { useResyncOnReconnect } from "@/hooks/useResyncOnReconnect";
|
|
17
|
+
import { useWebSocket } from "@/hooks/useWebSocket";
|
|
18
|
+
import { ApiError, apiFetch } from "@/lib/api";
|
|
19
|
+
import { onDataChanged } from "@/lib/data-events";
|
|
20
|
+
import { groupSessionsByProject, type SidebarProjectGroup } from "@/lib/sidebar-groups";
|
|
21
|
+
import type { Project, Session } from "@/types";
|
|
22
|
+
|
|
23
|
+
export interface UseSidebarDataResult {
|
|
24
|
+
/** Seluruh grup Project + Session (pencarian ada di `SearchDialog`). */
|
|
25
|
+
groups: SidebarProjectGroup[];
|
|
26
|
+
/** Total Project sebelum filter — membedakan "kosong" vs "tidak cocok". */
|
|
27
|
+
projectCount: number;
|
|
28
|
+
/** `true` hanya pada pemuatan pertama (refresh berikutnya tanpa skeleton). */
|
|
29
|
+
initialLoading: boolean;
|
|
30
|
+
loadError: string | null;
|
|
31
|
+
refresh: () => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function useSidebarData(pathname: string): UseSidebarDataResult {
|
|
35
|
+
const [projects, setProjects] = useState<Project[]>([]);
|
|
36
|
+
const [sessions, setSessions] = useState<Session[]>([]);
|
|
37
|
+
const [loaded, setLoaded] = useState(false);
|
|
38
|
+
const [loadError, setLoadError] = useState<string | null>(null);
|
|
39
|
+
/** Abaikan respon lama bila refresh baru sudah dimulai (navigasi cepat). */
|
|
40
|
+
const requestSeq = useRef(0);
|
|
41
|
+
|
|
42
|
+
const refresh = useCallback(async () => {
|
|
43
|
+
const seq = ++requestSeq.current;
|
|
44
|
+
try {
|
|
45
|
+
const [projectsRes, sessionsRes] = await Promise.all([
|
|
46
|
+
apiFetch("/api/projects"),
|
|
47
|
+
apiFetch("/api/sessions"),
|
|
48
|
+
]);
|
|
49
|
+
const [p, s] = (await Promise.all([projectsRes.json(), sessionsRes.json()])) as [
|
|
50
|
+
{ projects: Project[] },
|
|
51
|
+
{ sessions: Session[] },
|
|
52
|
+
];
|
|
53
|
+
if (seq !== requestSeq.current) return;
|
|
54
|
+
setProjects(p.projects);
|
|
55
|
+
setSessions(s.sessions);
|
|
56
|
+
setLoadError(null);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (seq !== requestSeq.current) return;
|
|
59
|
+
setLoadError(e instanceof ApiError ? e.message : "Failed to load sessions");
|
|
60
|
+
} finally {
|
|
61
|
+
if (seq === requestSeq.current) setLoaded(true);
|
|
62
|
+
}
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname sengaja jadi trigger refresh
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
void refresh();
|
|
68
|
+
}, [refresh, pathname]);
|
|
69
|
+
|
|
70
|
+
// Perubahan data dari halaman lain (mis. Project baru di homepage).
|
|
71
|
+
useEffect(() => onDataChanged(() => void refresh()), [refresh]);
|
|
72
|
+
|
|
73
|
+
const ws = useWebSocket({
|
|
74
|
+
onMessage: (msg) => {
|
|
75
|
+
if (msg.type === "session_title") {
|
|
76
|
+
setSessions((prev) =>
|
|
77
|
+
prev.map((s) => (s.id === msg.sessionId ? { ...s, title: msg.title } : s)),
|
|
78
|
+
);
|
|
79
|
+
} else if (msg.type === "session_status") {
|
|
80
|
+
setSessions((prev) =>
|
|
81
|
+
prev.map((s) =>
|
|
82
|
+
s.id === msg.sessionId && s.status !== msg.status ? { ...s, status: msg.status } : s,
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
} else if (msg.type === "session_deleted") {
|
|
86
|
+
setSessions((prev) => prev.filter((s) => s.id !== msg.sessionId));
|
|
87
|
+
} else if (msg.type === "data_changed") {
|
|
88
|
+
// Project/Session dibuat atau dihapus (bisa dari perangkat lain).
|
|
89
|
+
void refresh();
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
ws.attach("");
|
|
95
|
+
return () => ws.disconnect();
|
|
96
|
+
}, [ws.attach, ws.disconnect]);
|
|
97
|
+
useResyncOnReconnect(ws.status, refresh);
|
|
98
|
+
|
|
99
|
+
const groups = useMemo(() => groupSessionsByProject(projects, sessions), [projects, sessions]);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
groups,
|
|
103
|
+
projectCount: projects.length,
|
|
104
|
+
initialLoading: !loaded,
|
|
105
|
+
loadError,
|
|
106
|
+
refresh,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useSmoothText` — haluskan teks yang tiba bertahap (streaming token model).
|
|
3
|
+
*
|
|
4
|
+
* Masalah: delta dari opencode datang tidak merata — kadang 2 huruf, kadang
|
|
5
|
+
* satu frasa sekaligus, dengan jeda acak. Menampilkannya apa adanya terasa
|
|
6
|
+
* patah-patah (teks "melompat" per potongan).
|
|
7
|
+
*
|
|
8
|
+
* Solusi: teks target ditampung, lalu yang TAMPIL dikejar per frame
|
|
9
|
+
* (`requestAnimationFrame`) dengan laju adaptif:
|
|
10
|
+
* - Laju dasar ~`BASE_CPS` karakter/detik agar ritme terasa seperti mengetik.
|
|
11
|
+
* - Bila antrean menumpuk, laju naik sebanding sisa antrean: antrean
|
|
12
|
+
* menyusut eksponensial dengan konstanta waktu ~`CATCH_UP_MS` (sekitar 90%
|
|
13
|
+
* lonjakan sudah tampil dalam ~1 detik), lalu ekornya pada laju dasar.
|
|
14
|
+
* Begitu streaming selesai, sisa teks langsung tampil penuh.
|
|
15
|
+
* - Potongan dibulatkan ke batas kata bila dekat, agar tidak memotong kata
|
|
16
|
+
* di tengah terlalu sering (lebih enak dibaca).
|
|
17
|
+
*
|
|
18
|
+
* Aturan lain:
|
|
19
|
+
* - `enabled=false` (pesan selesai / riwayat): tampilkan teks penuh seketika.
|
|
20
|
+
* - Teks target yang bukan kelanjutan dari yang tampil (mis. diganti
|
|
21
|
+
* snapshot berbeda) -> langsung disetel ke target, tanpa animasi mundur.
|
|
22
|
+
* - Reduced motion: tanpa animasi (teks penuh seketika).
|
|
23
|
+
* - Tab di latar belakang: rAF berhenti; saat kembali, sisa antrean dikejar
|
|
24
|
+
* cepat oleh laju adaptif.
|
|
25
|
+
*/
|
|
26
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
27
|
+
|
|
28
|
+
/** Laju dasar (karakter per detik) saat antrean kecil. */
|
|
29
|
+
const BASE_CPS = 90;
|
|
30
|
+
/** Konstanta waktu penyusutan antrean (ms) — makin kecil, makin cepat mengejar. */
|
|
31
|
+
const CATCH_UP_MS = 400;
|
|
32
|
+
/** Jarak maksimum (karakter) untuk "menempel" ke batas kata berikutnya. */
|
|
33
|
+
const WORD_SNAP = 12;
|
|
34
|
+
|
|
35
|
+
function prefersReducedMotion(): boolean {
|
|
36
|
+
return (
|
|
37
|
+
typeof window !== "undefined" &&
|
|
38
|
+
typeof window.matchMedia === "function" &&
|
|
39
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hitung panjang tampilan berikutnya (murni, diuji terpisah).
|
|
45
|
+
* @param shown panjang yang sedang tampil
|
|
46
|
+
* @param target teks target penuh
|
|
47
|
+
* @param dtMs selisih waktu sejak frame sebelumnya
|
|
48
|
+
*/
|
|
49
|
+
export function nextSmoothLength(shown: number, target: string, dtMs: number): number {
|
|
50
|
+
const remaining = target.length - shown;
|
|
51
|
+
if (remaining <= 0) return target.length;
|
|
52
|
+
const dt = Math.max(0, Math.min(dtMs, 100)); // batasi lonjakan (tab kembali aktif)
|
|
53
|
+
const adaptiveCps = (remaining / CATCH_UP_MS) * 1000;
|
|
54
|
+
const cps = Math.max(BASE_CPS, adaptiveCps);
|
|
55
|
+
let next = shown + Math.max(1, Math.round((cps * dt) / 1000));
|
|
56
|
+
if (next >= target.length) return target.length;
|
|
57
|
+
// Tempel ke akhir kata terdekat bila dekat — hindari memotong kata.
|
|
58
|
+
const rest = target.slice(next, next + WORD_SNAP);
|
|
59
|
+
const boundary = rest.search(/\s/);
|
|
60
|
+
if (boundary > 0) next += boundary;
|
|
61
|
+
return Math.min(next, target.length);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function useSmoothText(target: string, enabled: boolean): string {
|
|
65
|
+
const [shown, setShown] = useState(() => (enabled ? "" : target));
|
|
66
|
+
const shownRef = useRef(shown);
|
|
67
|
+
// Target terbaru dibaca loop tiap frame — tidak perlu memasang ulang loop
|
|
68
|
+
// setiap delta tiba.
|
|
69
|
+
const targetRef = useRef(target);
|
|
70
|
+
targetRef.current = target;
|
|
71
|
+
|
|
72
|
+
const commit = useCallback((value: string) => {
|
|
73
|
+
if (shownRef.current === value) return;
|
|
74
|
+
shownRef.current = value;
|
|
75
|
+
setShown(value);
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Satu loop rAF per masa streaming (`enabled`), dimiliki SATU effect dengan
|
|
80
|
+
* cleanup yang membatalkannya. Sebelumnya loop dipicu per perubahan target
|
|
81
|
+
* dan dijaga flag `frameRef`; di StrictMode (effect dijalankan
|
|
82
|
+
* pasang -> bongkar -> pasang) flag itu tertinggal non-null setelah rAF
|
|
83
|
+
* dibatalkan, sehingga loop tak pernah jalan lagi dan teks baru muncul
|
|
84
|
+
* sekaligus saat streaming selesai.
|
|
85
|
+
*
|
|
86
|
+
* Loop berjalan selama `enabled` (hanya blok terakhir yang sedang
|
|
87
|
+
* di-stream); saat sudah mengejar target, frame hanya membandingkan
|
|
88
|
+
* panjang tanpa setState — murah.
|
|
89
|
+
*/
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!enabled || prefersReducedMotion()) return;
|
|
92
|
+
// Selaraskan state dengan ref (ref bisa sudah maju saat nonaktif).
|
|
93
|
+
setShown(shownRef.current);
|
|
94
|
+
let frame = 0;
|
|
95
|
+
let last: number | null = null;
|
|
96
|
+
const tick = (ts: number) => {
|
|
97
|
+
const dt = last === null ? 16 : ts - last;
|
|
98
|
+
last = ts;
|
|
99
|
+
const goal = targetRef.current;
|
|
100
|
+
const cur = shownRef.current;
|
|
101
|
+
if (!goal.startsWith(cur)) {
|
|
102
|
+
// Bukan kelanjutan (teks diganti snapshot lain) -> setel langsung,
|
|
103
|
+
// jangan animasi "mundur".
|
|
104
|
+
commit(goal);
|
|
105
|
+
} else if (cur.length < goal.length) {
|
|
106
|
+
commit(goal.slice(0, nextSmoothLength(cur.length, goal, dt)));
|
|
107
|
+
}
|
|
108
|
+
frame = requestAnimationFrame(tick);
|
|
109
|
+
};
|
|
110
|
+
frame = requestAnimationFrame(tick);
|
|
111
|
+
return () => cancelAnimationFrame(frame);
|
|
112
|
+
}, [enabled, commit]);
|
|
113
|
+
|
|
114
|
+
// Tidak streaming / reduced motion: teks penuh apa adanya. `shownRef`
|
|
115
|
+
// ikut disinkronkan agar bila streaming berlanjut, animasi tidak mengulang
|
|
116
|
+
// dari awal.
|
|
117
|
+
if (!enabled || prefersReducedMotion()) {
|
|
118
|
+
shownRef.current = target;
|
|
119
|
+
return target;
|
|
120
|
+
}
|
|
121
|
+
return shown;
|
|
122
|
+
}
|