@hienlh/ppm 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.
Files changed (159) hide show
  1. package/.claude/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/.env.example +1 -0
  4. package/.github/workflows/release.yml +46 -0
  5. package/README.md +349 -0
  6. package/bun.lock +1217 -0
  7. package/components.json +21 -0
  8. package/docs/code-standards.md +574 -0
  9. package/docs/codebase-summary.md +294 -0
  10. package/docs/deployment-guide.md +631 -0
  11. package/docs/design-guidelines.md +661 -0
  12. package/docs/project-overview-pdr.md +142 -0
  13. package/docs/project-roadmap.md +400 -0
  14. package/docs/system-architecture.md +459 -0
  15. package/package.json +68 -0
  16. package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
  17. package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
  18. package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
  19. package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
  20. package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
  21. package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
  22. package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
  23. package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
  24. package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
  25. package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
  26. package/plans/260314-2009-ppm-implementation/plan.md +202 -0
  27. package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
  28. package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
  29. package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
  30. package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
  31. package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
  32. package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
  33. package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
  34. package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
  35. package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
  36. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
  37. package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
  38. package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
  39. package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
  40. package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
  41. package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
  42. package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
  43. package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
  44. package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
  45. package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
  46. package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
  47. package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
  48. package/ppm.example.yaml +14 -0
  49. package/repomix-output.xml +23745 -0
  50. package/scripts/build.ts +13 -0
  51. package/src/cli/commands/chat-cmd.ts +259 -0
  52. package/src/cli/commands/config-cmd.ts +121 -0
  53. package/src/cli/commands/git-cmd.ts +315 -0
  54. package/src/cli/commands/init.ts +57 -0
  55. package/src/cli/commands/open.ts +19 -0
  56. package/src/cli/commands/projects.ts +100 -0
  57. package/src/cli/commands/start.ts +3 -0
  58. package/src/cli/commands/stop.ts +33 -0
  59. package/src/cli/utils/project-resolver.ts +27 -0
  60. package/src/index.ts +59 -0
  61. package/src/providers/claude-agent-sdk.ts +499 -0
  62. package/src/providers/claude-binary-finder.ts +256 -0
  63. package/src/providers/claude-code-cli.ts +413 -0
  64. package/src/providers/claude-process-registry.ts +106 -0
  65. package/src/providers/mock-provider.ts +171 -0
  66. package/src/providers/provider.interface.ts +10 -0
  67. package/src/providers/registry.ts +45 -0
  68. package/src/server/helpers/resolve-project.ts +22 -0
  69. package/src/server/index.ts +181 -0
  70. package/src/server/middleware/auth.ts +30 -0
  71. package/src/server/routes/chat.ts +153 -0
  72. package/src/server/routes/files.ts +168 -0
  73. package/src/server/routes/git.ts +261 -0
  74. package/src/server/routes/project-scoped.ts +27 -0
  75. package/src/server/routes/projects.ts +57 -0
  76. package/src/server/routes/static.ts +26 -0
  77. package/src/server/ws/chat.ts +130 -0
  78. package/src/server/ws/terminal.ts +89 -0
  79. package/src/services/chat.service.ts +110 -0
  80. package/src/services/claude-usage.service.ts +113 -0
  81. package/src/services/config.service.ts +90 -0
  82. package/src/services/file.service.ts +261 -0
  83. package/src/services/git-dirs.service.ts +112 -0
  84. package/src/services/git.service.ts +372 -0
  85. package/src/services/project.service.ts +107 -0
  86. package/src/services/slash-items.service.ts +184 -0
  87. package/src/services/terminal.service.ts +212 -0
  88. package/src/types/api.ts +37 -0
  89. package/src/types/chat.ts +92 -0
  90. package/src/types/config.ts +41 -0
  91. package/src/types/git.ts +50 -0
  92. package/src/types/project.ts +18 -0
  93. package/src/types/terminal.ts +20 -0
  94. package/src/web/app.tsx +168 -0
  95. package/src/web/components/auth/login-screen.tsx +88 -0
  96. package/src/web/components/chat/attachment-chips.tsx +55 -0
  97. package/src/web/components/chat/chat-placeholder.tsx +10 -0
  98. package/src/web/components/chat/chat-tab.tsx +301 -0
  99. package/src/web/components/chat/file-picker.tsx +126 -0
  100. package/src/web/components/chat/message-input.tsx +420 -0
  101. package/src/web/components/chat/message-list.tsx +838 -0
  102. package/src/web/components/chat/session-picker.tsx +139 -0
  103. package/src/web/components/chat/slash-command-picker.tsx +135 -0
  104. package/src/web/components/chat/usage-badge.tsx +186 -0
  105. package/src/web/components/editor/code-editor.tsx +329 -0
  106. package/src/web/components/editor/diff-viewer.tsx +276 -0
  107. package/src/web/components/editor/editor-placeholder.tsx +10 -0
  108. package/src/web/components/explorer/file-actions.tsx +191 -0
  109. package/src/web/components/explorer/file-tree.tsx +298 -0
  110. package/src/web/components/git/git-graph.tsx +727 -0
  111. package/src/web/components/git/git-placeholder.tsx +55 -0
  112. package/src/web/components/git/git-status-panel.tsx +850 -0
  113. package/src/web/components/layout/mobile-drawer.tsx +137 -0
  114. package/src/web/components/layout/mobile-nav.tsx +103 -0
  115. package/src/web/components/layout/sidebar.tsx +90 -0
  116. package/src/web/components/layout/tab-bar.tsx +152 -0
  117. package/src/web/components/layout/tab-content.tsx +85 -0
  118. package/src/web/components/projects/dir-suggest.tsx +152 -0
  119. package/src/web/components/projects/project-list.tsx +187 -0
  120. package/src/web/components/settings/settings-tab.tsx +57 -0
  121. package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
  122. package/src/web/components/terminal/terminal-tab.tsx +133 -0
  123. package/src/web/components/ui/button.tsx +64 -0
  124. package/src/web/components/ui/context-menu.tsx +250 -0
  125. package/src/web/components/ui/dialog.tsx +156 -0
  126. package/src/web/components/ui/dropdown-menu.tsx +257 -0
  127. package/src/web/components/ui/input.tsx +21 -0
  128. package/src/web/components/ui/scroll-area.tsx +56 -0
  129. package/src/web/components/ui/separator.tsx +26 -0
  130. package/src/web/components/ui/sonner.tsx +40 -0
  131. package/src/web/components/ui/tabs.tsx +91 -0
  132. package/src/web/components/ui/tooltip.tsx +57 -0
  133. package/src/web/hooks/use-chat.ts +420 -0
  134. package/src/web/hooks/use-terminal.ts +182 -0
  135. package/src/web/hooks/use-url-sync.ts +66 -0
  136. package/src/web/hooks/use-websocket.ts +48 -0
  137. package/src/web/index.html +16 -0
  138. package/src/web/lib/api-client.ts +90 -0
  139. package/src/web/lib/file-support.ts +68 -0
  140. package/src/web/lib/utils.ts +6 -0
  141. package/src/web/lib/ws-client.ts +100 -0
  142. package/src/web/main.tsx +10 -0
  143. package/src/web/public/icon-192.svg +5 -0
  144. package/src/web/public/icon-512.svg +5 -0
  145. package/src/web/stores/file-store.ts +81 -0
  146. package/src/web/stores/project-store.ts +50 -0
  147. package/src/web/stores/settings-store.ts +65 -0
  148. package/src/web/stores/tab-store.ts +187 -0
  149. package/src/web/styles/globals.css +227 -0
  150. package/src/web/vite-env.d.ts +1 -0
  151. package/tests/integration/api/chat-routes.test.ts +95 -0
  152. package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
  153. package/tests/integration/ws/chat-websocket.test.ts +312 -0
  154. package/tests/test-setup.ts +5 -0
  155. package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
  156. package/tests/unit/providers/mock-provider.test.ts +143 -0
  157. package/tests/unit/services/chat-service.test.ts +100 -0
  158. package/tsconfig.json +32 -0
  159. package/vite.config.ts +62 -0
@@ -0,0 +1,56 @@
1
+ import * as React from "react"
2
+ import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function ScrollArea({
7
+ className,
8
+ children,
9
+ ...props
10
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
11
+ return (
12
+ <ScrollAreaPrimitive.Root
13
+ data-slot="scroll-area"
14
+ className={cn("relative", className)}
15
+ {...props}
16
+ >
17
+ <ScrollAreaPrimitive.Viewport
18
+ data-slot="scroll-area-viewport"
19
+ className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 [&>div]:!block"
20
+ >
21
+ {children}
22
+ </ScrollAreaPrimitive.Viewport>
23
+ <ScrollBar />
24
+ <ScrollAreaPrimitive.Corner />
25
+ </ScrollAreaPrimitive.Root>
26
+ )
27
+ }
28
+
29
+ function ScrollBar({
30
+ className,
31
+ orientation = "vertical",
32
+ ...props
33
+ }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
34
+ return (
35
+ <ScrollAreaPrimitive.ScrollAreaScrollbar
36
+ data-slot="scroll-area-scrollbar"
37
+ orientation={orientation}
38
+ className={cn(
39
+ "flex touch-none p-px transition-colors select-none",
40
+ orientation === "vertical" &&
41
+ "h-full w-2.5 border-l border-l-transparent",
42
+ orientation === "horizontal" &&
43
+ "h-2.5 flex-col border-t border-t-transparent",
44
+ className
45
+ )}
46
+ {...props}
47
+ >
48
+ <ScrollAreaPrimitive.ScrollAreaThumb
49
+ data-slot="scroll-area-thumb"
50
+ className="relative flex-1 rounded-full bg-border"
51
+ />
52
+ </ScrollAreaPrimitive.ScrollAreaScrollbar>
53
+ )
54
+ }
55
+
56
+ export { ScrollArea, ScrollBar }
@@ -0,0 +1,26 @@
1
+ import * as React from "react"
2
+ import { Separator as SeparatorPrimitive } from "radix-ui"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ function Separator({
7
+ className,
8
+ orientation = "horizontal",
9
+ decorative = true,
10
+ ...props
11
+ }: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
12
+ return (
13
+ <SeparatorPrimitive.Root
14
+ data-slot="separator"
15
+ decorative={decorative}
16
+ orientation={orientation}
17
+ className={cn(
18
+ "shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
19
+ className
20
+ )}
21
+ {...props}
22
+ />
23
+ )
24
+ }
25
+
26
+ export { Separator }
@@ -0,0 +1,40 @@
1
+ "use client"
2
+
3
+ import {
4
+ CircleCheckIcon,
5
+ InfoIcon,
6
+ Loader2Icon,
7
+ OctagonXIcon,
8
+ TriangleAlertIcon,
9
+ } from "lucide-react"
10
+ import { useTheme } from "next-themes"
11
+ import { Toaster as Sonner, type ToasterProps } from "sonner"
12
+
13
+ const Toaster = ({ ...props }: ToasterProps) => {
14
+ const { theme = "system" } = useTheme()
15
+
16
+ return (
17
+ <Sonner
18
+ theme={theme as ToasterProps["theme"]}
19
+ className="toaster group"
20
+ icons={{
21
+ success: <CircleCheckIcon className="size-4" />,
22
+ info: <InfoIcon className="size-4" />,
23
+ warning: <TriangleAlertIcon className="size-4" />,
24
+ error: <OctagonXIcon className="size-4" />,
25
+ loading: <Loader2Icon className="size-4 animate-spin" />,
26
+ }}
27
+ style={
28
+ {
29
+ "--normal-bg": "var(--popover)",
30
+ "--normal-text": "var(--popover-foreground)",
31
+ "--normal-border": "var(--border)",
32
+ "--border-radius": "var(--radius)",
33
+ } as React.CSSProperties
34
+ }
35
+ {...props}
36
+ />
37
+ )
38
+ }
39
+
40
+ export { Toaster }
@@ -0,0 +1,91 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { cva, type VariantProps } from "class-variance-authority"
5
+ import { Tabs as TabsPrimitive } from "radix-ui"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ function Tabs({
10
+ className,
11
+ orientation = "horizontal",
12
+ ...props
13
+ }: React.ComponentProps<typeof TabsPrimitive.Root>) {
14
+ return (
15
+ <TabsPrimitive.Root
16
+ data-slot="tabs"
17
+ data-orientation={orientation}
18
+ orientation={orientation}
19
+ className={cn(
20
+ "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
21
+ className
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ }
27
+
28
+ const tabsListVariants = cva(
29
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
30
+ {
31
+ variants: {
32
+ variant: {
33
+ default: "bg-muted",
34
+ line: "gap-1 bg-transparent",
35
+ },
36
+ },
37
+ defaultVariants: {
38
+ variant: "default",
39
+ },
40
+ }
41
+ )
42
+
43
+ function TabsList({
44
+ className,
45
+ variant = "default",
46
+ ...props
47
+ }: React.ComponentProps<typeof TabsPrimitive.List> &
48
+ VariantProps<typeof tabsListVariants>) {
49
+ return (
50
+ <TabsPrimitive.List
51
+ data-slot="tabs-list"
52
+ data-variant={variant}
53
+ className={cn(tabsListVariants({ variant }), className)}
54
+ {...props}
55
+ />
56
+ )
57
+ }
58
+
59
+ function TabsTrigger({
60
+ className,
61
+ ...props
62
+ }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
63
+ return (
64
+ <TabsPrimitive.Trigger
65
+ data-slot="tabs-trigger"
66
+ className={cn(
67
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
68
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
69
+ "data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
70
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
71
+ className
72
+ )}
73
+ {...props}
74
+ />
75
+ )
76
+ }
77
+
78
+ function TabsContent({
79
+ className,
80
+ ...props
81
+ }: React.ComponentProps<typeof TabsPrimitive.Content>) {
82
+ return (
83
+ <TabsPrimitive.Content
84
+ data-slot="tabs-content"
85
+ className={cn("flex-1 outline-none", className)}
86
+ {...props}
87
+ />
88
+ )
89
+ }
90
+
91
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -0,0 +1,57 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import { Tooltip as TooltipPrimitive } from "radix-ui"
5
+
6
+ import { cn } from "@/lib/utils"
7
+
8
+ function TooltipProvider({
9
+ delayDuration = 0,
10
+ ...props
11
+ }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
12
+ return (
13
+ <TooltipPrimitive.Provider
14
+ data-slot="tooltip-provider"
15
+ delayDuration={delayDuration}
16
+ {...props}
17
+ />
18
+ )
19
+ }
20
+
21
+ function Tooltip({
22
+ ...props
23
+ }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
24
+ return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
25
+ }
26
+
27
+ function TooltipTrigger({
28
+ ...props
29
+ }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
30
+ return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
31
+ }
32
+
33
+ function TooltipContent({
34
+ className,
35
+ sideOffset = 0,
36
+ children,
37
+ ...props
38
+ }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
39
+ return (
40
+ <TooltipPrimitive.Portal>
41
+ <TooltipPrimitive.Content
42
+ data-slot="tooltip-content"
43
+ sideOffset={sideOffset}
44
+ className={cn(
45
+ "z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
46
+ className
47
+ )}
48
+ {...props}
49
+ >
50
+ {children}
51
+ <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
52
+ </TooltipPrimitive.Content>
53
+ </TooltipPrimitive.Portal>
54
+ )
55
+ }
56
+
57
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
@@ -0,0 +1,420 @@
1
+ import { useState, useCallback, useRef, useEffect } from "react";
2
+ import { useWebSocket } from "./use-websocket";
3
+ import { getAuthToken, projectUrl } from "@/lib/api-client";
4
+ import type { ChatMessage, ChatEvent, UsageInfo } from "../../types/chat";
5
+ import type { ChatWsServerMessage } from "../../types/api";
6
+
7
+ interface ApprovalRequest {
8
+ requestId: string;
9
+ tool: string;
10
+ input: unknown;
11
+ }
12
+
13
+ interface UseChatReturn {
14
+ messages: ChatMessage[];
15
+ messagesLoading: boolean;
16
+ isStreaming: boolean;
17
+ pendingApproval: ApprovalRequest | null;
18
+ usageInfo: UsageInfo;
19
+ usageLoading: boolean;
20
+ sendMessage: (content: string) => void;
21
+ respondToApproval: (requestId: string, approved: boolean, data?: unknown) => void;
22
+ cancelStreaming: () => void;
23
+ refreshUsage: () => void;
24
+ isConnected: boolean;
25
+ }
26
+
27
+ export function useChat(sessionId: string | null, providerId = "claude-sdk", projectName = ""): UseChatReturn {
28
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
29
+ const [messagesLoading, setMessagesLoading] = useState(false);
30
+ const [isStreaming, setIsStreaming] = useState(false);
31
+ const [pendingApproval, setPendingApproval] = useState<ApprovalRequest | null>(null);
32
+ const [isConnected, setIsConnected] = useState(false);
33
+ const [usageInfo, setUsageInfo] = useState<UsageInfo>({});
34
+ const [usageLoading, setUsageLoading] = useState(false);
35
+ const streamingContentRef = useRef("");
36
+ const streamingEventsRef = useRef<ChatEvent[]>([]);
37
+ const isStreamingRef = useRef(false);
38
+ const pendingMessageRef = useRef<string | null>(null);
39
+ const sendRef = useRef<(data: string) => void>(() => {});
40
+
41
+ const handleMessage = useCallback((event: MessageEvent) => {
42
+ let data: ChatWsServerMessage;
43
+ try {
44
+ data = JSON.parse(event.data as string) as ChatWsServerMessage;
45
+ } catch {
46
+ return;
47
+ }
48
+
49
+ // Handle connected event (custom, not in type)
50
+ if ((data as any).type === "connected") {
51
+ setIsConnected(true);
52
+ return;
53
+ }
54
+
55
+ switch (data.type) {
56
+ case "text": {
57
+ streamingContentRef.current += data.content;
58
+ streamingEventsRef.current.push(data);
59
+ // Snapshot BEFORE queueing setState — React 18 batching may delay updater execution
60
+ const txtContent = streamingContentRef.current;
61
+ const txtEvents = [...streamingEventsRef.current];
62
+ setMessages((prev) => {
63
+ const last = prev[prev.length - 1];
64
+ if (last?.role === "assistant" && !last.id.startsWith("final-")) {
65
+ return [
66
+ ...prev.slice(0, -1),
67
+ { ...last, content: txtContent, events: txtEvents },
68
+ ];
69
+ }
70
+ return [
71
+ ...prev,
72
+ {
73
+ id: `streaming-${Date.now()}`,
74
+ role: "assistant" as const,
75
+ content: txtContent,
76
+ events: txtEvents,
77
+ timestamp: new Date().toISOString(),
78
+ },
79
+ ];
80
+ });
81
+ break;
82
+ }
83
+
84
+ case "tool_use": {
85
+ streamingEventsRef.current.push(data);
86
+ const tuContent = streamingContentRef.current;
87
+ const tuEvents = [...streamingEventsRef.current];
88
+ setMessages((prev) => {
89
+ const last = prev[prev.length - 1];
90
+ if (last?.role === "assistant") {
91
+ return [
92
+ ...prev.slice(0, -1),
93
+ { ...last, events: tuEvents },
94
+ ];
95
+ }
96
+ return [
97
+ ...prev,
98
+ {
99
+ id: `streaming-${Date.now()}`,
100
+ role: "assistant" as const,
101
+ content: tuContent,
102
+ events: tuEvents,
103
+ timestamp: new Date().toISOString(),
104
+ },
105
+ ];
106
+ });
107
+ break;
108
+ }
109
+
110
+ case "tool_result": {
111
+ streamingEventsRef.current.push(data);
112
+ const trEvents = [...streamingEventsRef.current];
113
+ setMessages((prev) => {
114
+ const last = prev[prev.length - 1];
115
+ if (last?.role === "assistant") {
116
+ return [
117
+ ...prev.slice(0, -1),
118
+ { ...last, events: trEvents },
119
+ ];
120
+ }
121
+ return prev;
122
+ });
123
+ break;
124
+ }
125
+
126
+ case "approval_request": {
127
+ streamingEventsRef.current.push(data);
128
+ setPendingApproval({
129
+ requestId: data.requestId,
130
+ tool: data.tool,
131
+ input: data.input,
132
+ });
133
+ break;
134
+ }
135
+
136
+ case "usage": {
137
+ // Merge usage info — accumulate totalCostUsd, track queryCostUsd
138
+ setUsageInfo((prev) => {
139
+ const next = { ...prev, ...data.usage };
140
+ if (data.usage.totalCostUsd != null) {
141
+ next.queryCostUsd = data.usage.totalCostUsd;
142
+ next.totalCostUsd = (prev.totalCostUsd ?? 0) + data.usage.totalCostUsd;
143
+ }
144
+ return next;
145
+ });
146
+ break;
147
+ }
148
+
149
+ case "error": {
150
+ streamingEventsRef.current.push(data);
151
+ const errEvents = [...streamingEventsRef.current];
152
+ setMessages((prev) => {
153
+ const last = prev[prev.length - 1];
154
+ if (last?.role === "assistant") {
155
+ return [
156
+ ...prev.slice(0, -1),
157
+ { ...last, events: errEvents },
158
+ ];
159
+ }
160
+ return [
161
+ ...prev,
162
+ {
163
+ id: `error-${Date.now()}`,
164
+ role: "system" as const,
165
+ content: data.message,
166
+ events: [data],
167
+ timestamp: new Date().toISOString(),
168
+ },
169
+ ];
170
+ });
171
+ isStreamingRef.current = false;
172
+ setIsStreaming(false);
173
+ break;
174
+ }
175
+
176
+ case "done": {
177
+ // Finalize the streaming message — capture refs before clearing
178
+ const finalContent = streamingContentRef.current;
179
+ const finalEvents = [...streamingEventsRef.current];
180
+ setMessages((prev) => {
181
+ const last = prev[prev.length - 1];
182
+ if (last?.role === "assistant") {
183
+ return [
184
+ ...prev.slice(0, -1),
185
+ {
186
+ ...last,
187
+ id: `final-${Date.now()}`,
188
+ content: finalContent || last.content,
189
+ events: finalEvents.length > 0 ? finalEvents : last.events,
190
+ },
191
+ ];
192
+ }
193
+ return prev;
194
+ });
195
+ streamingContentRef.current = "";
196
+ streamingEventsRef.current = [];
197
+
198
+ // Flush queued message if user typed while streaming
199
+ const queued = pendingMessageRef.current;
200
+ if (queued) {
201
+ pendingMessageRef.current = null;
202
+ // Add user message to list
203
+ setMessages((prev2) => [
204
+ ...prev2,
205
+ {
206
+ id: `user-${Date.now()}`,
207
+ role: "user" as const,
208
+ content: queued,
209
+ timestamp: new Date().toISOString(),
210
+ },
211
+ ]);
212
+ streamingContentRef.current = "";
213
+ streamingEventsRef.current = [];
214
+ isStreamingRef.current = true;
215
+ setIsStreaming(true);
216
+ sendRef.current(JSON.stringify({ type: "message", content: queued }));
217
+ } else {
218
+ isStreamingRef.current = false;
219
+ setIsStreaming(false);
220
+ }
221
+ break;
222
+ }
223
+ }
224
+ }, []);
225
+
226
+ const wsUrl = sessionId && projectName
227
+ ? `/ws/project/${encodeURIComponent(projectName)}/chat/${sessionId}`
228
+ : "";
229
+
230
+ const { send } = useWebSocket({
231
+ url: wsUrl,
232
+ onMessage: handleMessage,
233
+ autoConnect: !!sessionId && !!projectName,
234
+ });
235
+
236
+ // Keep sendRef in sync so handleMessage can flush queued messages
237
+ sendRef.current = send;
238
+
239
+ // Load history and reset state when session changes
240
+ useEffect(() => {
241
+ let cancelled = false;
242
+
243
+ setIsStreaming(false);
244
+ setPendingApproval(null);
245
+ streamingContentRef.current = "";
246
+ streamingEventsRef.current = [];
247
+ setIsConnected(false);
248
+
249
+ if (projectName) {
250
+ // Load cached usage/rate-limit info immediately
251
+ fetch(`${projectUrl(projectName)}/chat/usage?providerId=${providerId}`, {
252
+ headers: { Authorization: `Bearer ${getAuthToken()}` },
253
+ })
254
+ .then((r) => r.json())
255
+ .then((json: any) => {
256
+ if (!cancelled && json.ok && json.data) {
257
+ setUsageInfo((prev) => ({ ...prev, ...json.data }));
258
+ }
259
+ })
260
+ .catch(() => {});
261
+ }
262
+
263
+ if (sessionId && projectName) {
264
+ // Load message history
265
+ setMessagesLoading(true);
266
+ fetch(`${projectUrl(projectName)}/chat/sessions/${sessionId}/messages?providerId=${providerId}`, {
267
+ headers: { Authorization: `Bearer ${getAuthToken()}` },
268
+ })
269
+ .then((r) => r.json())
270
+ .then((json: any) => {
271
+ if (cancelled || isStreamingRef.current) return;
272
+ if (json.ok && Array.isArray(json.data) && json.data.length > 0) {
273
+ setMessages(json.data);
274
+ } else {
275
+ setMessages([]);
276
+ }
277
+ })
278
+ .catch(() => {
279
+ if (!cancelled && !isStreamingRef.current) setMessages([]);
280
+ })
281
+ .finally(() => {
282
+ if (!cancelled) setMessagesLoading(false);
283
+ });
284
+ } else {
285
+ setMessages([]);
286
+ }
287
+
288
+ return () => {
289
+ cancelled = true;
290
+ };
291
+ }, [sessionId, providerId, projectName]);
292
+
293
+ const sendMessage = useCallback(
294
+ (content: string) => {
295
+ if (!content.trim()) return;
296
+
297
+ // If streaming, queue message to send after current stream finishes
298
+ if (isStreaming) {
299
+ pendingMessageRef.current = content;
300
+ return;
301
+ }
302
+
303
+ // Add user message
304
+ setMessages((prev) => [
305
+ ...prev,
306
+ {
307
+ id: `user-${Date.now()}`,
308
+ role: "user" as const,
309
+ content,
310
+ timestamp: new Date().toISOString(),
311
+ },
312
+ ]);
313
+
314
+ // Reset streaming state
315
+ streamingContentRef.current = "";
316
+ streamingEventsRef.current = [];
317
+ isStreamingRef.current = true;
318
+ setIsStreaming(true);
319
+
320
+ send(JSON.stringify({ type: "message", content }));
321
+ },
322
+ [send, isStreaming],
323
+ );
324
+
325
+ const respondToApproval = useCallback(
326
+ (requestId: string, approved: boolean, data?: unknown) => {
327
+ send(
328
+ JSON.stringify({
329
+ type: "approval_response",
330
+ requestId,
331
+ approved,
332
+ data,
333
+ }),
334
+ );
335
+
336
+ // Merge answers into the AskUserQuestion tool_use event so FE shows selected answers
337
+ if (approved && data) {
338
+ const evts = streamingEventsRef.current;
339
+ const askEvt = evts.find(
340
+ (e: ChatEvent) =>
341
+ e.type === "approval_request" &&
342
+ (e as any).requestId === requestId &&
343
+ (e as any).tool === "AskUserQuestion",
344
+ );
345
+ if (askEvt) {
346
+ // Mutate input to include answers — this updates the rendered ToolCard
347
+ const inp = (askEvt as any).input;
348
+ if (inp && typeof inp === "object") {
349
+ (inp as Record<string, unknown>).answers = data;
350
+ }
351
+ }
352
+ // Force re-render messages
353
+ setMessages((prev) => [...prev]);
354
+ }
355
+
356
+ setPendingApproval(null);
357
+ },
358
+ [send],
359
+ );
360
+
361
+ const cancelStreaming = useCallback(() => {
362
+ if (!isStreamingRef.current) return;
363
+ // Tell backend to abort
364
+ send(JSON.stringify({ type: "cancel" }));
365
+ // Finalize current message on FE
366
+ const finalContent = streamingContentRef.current;
367
+ const finalEvents = [...streamingEventsRef.current];
368
+ setMessages((prev) => {
369
+ const last = prev[prev.length - 1];
370
+ if (last?.role === "assistant") {
371
+ return [
372
+ ...prev.slice(0, -1),
373
+ {
374
+ ...last,
375
+ id: `final-${Date.now()}`,
376
+ content: finalContent || last.content,
377
+ events: finalEvents.length > 0 ? finalEvents : last.events,
378
+ },
379
+ ];
380
+ }
381
+ return prev;
382
+ });
383
+ streamingContentRef.current = "";
384
+ streamingEventsRef.current = [];
385
+ pendingMessageRef.current = null;
386
+ isStreamingRef.current = false;
387
+ setIsStreaming(false);
388
+ setPendingApproval(null);
389
+ }, [send]);
390
+
391
+ const refreshUsage = useCallback(() => {
392
+ if (!projectName) return;
393
+ setUsageLoading(true);
394
+ fetch(`${projectUrl(projectName)}/chat/usage?providerId=${providerId}&_t=${Date.now()}`, {
395
+ headers: { Authorization: `Bearer ${getAuthToken()}` },
396
+ })
397
+ .then((r) => r.json())
398
+ .then((json: any) => {
399
+ if (json.ok && json.data) {
400
+ setUsageInfo((prev) => ({ ...prev, ...json.data }));
401
+ }
402
+ })
403
+ .catch(() => {})
404
+ .finally(() => setUsageLoading(false));
405
+ }, [projectName, providerId]);
406
+
407
+ return {
408
+ messages,
409
+ messagesLoading,
410
+ isStreaming,
411
+ pendingApproval,
412
+ usageInfo,
413
+ usageLoading,
414
+ sendMessage,
415
+ respondToApproval,
416
+ cancelStreaming,
417
+ refreshUsage,
418
+ isConnected,
419
+ };
420
+ }