@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.
- package/.claude/agent-memory/tester/MEMORY.md +3 -0
- package/.claude/agent-memory/tester/project-ppm-test-conventions.md +32 -0
- package/.env.example +1 -0
- package/.github/workflows/release.yml +46 -0
- package/README.md +349 -0
- package/bun.lock +1217 -0
- package/components.json +21 -0
- package/docs/code-standards.md +574 -0
- package/docs/codebase-summary.md +294 -0
- package/docs/deployment-guide.md +631 -0
- package/docs/design-guidelines.md +661 -0
- package/docs/project-overview-pdr.md +142 -0
- package/docs/project-roadmap.md +400 -0
- package/docs/system-architecture.md +459 -0
- package/package.json +68 -0
- package/plans/260314-2009-ppm-implementation/phase-01-project-skeleton.md +81 -0
- package/plans/260314-2009-ppm-implementation/phase-02-backend-core.md +148 -0
- package/plans/260314-2009-ppm-implementation/phase-03-frontend-shell.md +256 -0
- package/plans/260314-2009-ppm-implementation/phase-04-file-explorer-editor.md +120 -0
- package/plans/260314-2009-ppm-implementation/phase-05-web-terminal.md +174 -0
- package/plans/260314-2009-ppm-implementation/phase-06-git-integration.md +244 -0
- package/plans/260314-2009-ppm-implementation/phase-07-ai-chat.md +242 -0
- package/plans/260314-2009-ppm-implementation/phase-08-cli-commands.md +143 -0
- package/plans/260314-2009-ppm-implementation/phase-09-pwa-build-deploy.md +209 -0
- package/plans/260314-2009-ppm-implementation/phase-10-testing.md +311 -0
- package/plans/260314-2009-ppm-implementation/plan.md +202 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-01-backend-project-router.md +145 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-02-frontend-api-migration.md +107 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-03-per-project-tabs.md +100 -0
- package/plans/260315-0356-project-scoped-api-refactor/phase-04-websocket-migration.md +66 -0
- package/plans/260315-0356-project-scoped-api-refactor/plan.md +87 -0
- package/plans/reports/brainstorm-260314-1938-final-techstack.md +342 -0
- package/plans/reports/docs-manager-260315-1314-documentation-creation.md +386 -0
- package/plans/reports/fullstack-developer-260314-2252-phase-02-backend-core.md +57 -0
- package/plans/reports/fullstack-developer-260314-2253-phase-03-frontend-shell.md +70 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-api-terminal-ws.md +49 -0
- package/plans/reports/fullstack-developer-260314-2300-phase-04-05-file-explorer-editor-terminal.md +52 -0
- package/plans/reports/fullstack-developer-260314-2307-ai-chat-phase7.md +58 -0
- package/plans/reports/fullstack-developer-260314-2307-phase-06-git-integration.md +33 -0
- package/plans/reports/research-260314-1911-ppm-tech-stack.md +318 -0
- package/plans/reports/research-260314-1930-claude-code-integration.md +293 -0
- package/plans/reports/researcher-260314-2232-node-pty-bun-crash-analysis.md +305 -0
- package/plans/reports/researcher-260314-2232-ui-style.md +942 -0
- package/plans/reports/researcher-260315-0300-opcode-claude-interaction.md +745 -0
- package/plans/reports/researcher-260315-0303-opcode-deep-analysis.md +742 -0
- package/plans/reports/researcher-260315-0305-claude-agent-sdk-github-research.md +423 -0
- package/plans/reports/tester-260314-2053-initial-test-suite.md +81 -0
- package/ppm.example.yaml +14 -0
- package/repomix-output.xml +23745 -0
- package/scripts/build.ts +13 -0
- package/src/cli/commands/chat-cmd.ts +259 -0
- package/src/cli/commands/config-cmd.ts +121 -0
- package/src/cli/commands/git-cmd.ts +315 -0
- package/src/cli/commands/init.ts +57 -0
- package/src/cli/commands/open.ts +19 -0
- package/src/cli/commands/projects.ts +100 -0
- package/src/cli/commands/start.ts +3 -0
- package/src/cli/commands/stop.ts +33 -0
- package/src/cli/utils/project-resolver.ts +27 -0
- package/src/index.ts +59 -0
- package/src/providers/claude-agent-sdk.ts +499 -0
- package/src/providers/claude-binary-finder.ts +256 -0
- package/src/providers/claude-code-cli.ts +413 -0
- package/src/providers/claude-process-registry.ts +106 -0
- package/src/providers/mock-provider.ts +171 -0
- package/src/providers/provider.interface.ts +10 -0
- package/src/providers/registry.ts +45 -0
- package/src/server/helpers/resolve-project.ts +22 -0
- package/src/server/index.ts +181 -0
- package/src/server/middleware/auth.ts +30 -0
- package/src/server/routes/chat.ts +153 -0
- package/src/server/routes/files.ts +168 -0
- package/src/server/routes/git.ts +261 -0
- package/src/server/routes/project-scoped.ts +27 -0
- package/src/server/routes/projects.ts +57 -0
- package/src/server/routes/static.ts +26 -0
- package/src/server/ws/chat.ts +130 -0
- package/src/server/ws/terminal.ts +89 -0
- package/src/services/chat.service.ts +110 -0
- package/src/services/claude-usage.service.ts +113 -0
- package/src/services/config.service.ts +90 -0
- package/src/services/file.service.ts +261 -0
- package/src/services/git-dirs.service.ts +112 -0
- package/src/services/git.service.ts +372 -0
- package/src/services/project.service.ts +107 -0
- package/src/services/slash-items.service.ts +184 -0
- package/src/services/terminal.service.ts +212 -0
- package/src/types/api.ts +37 -0
- package/src/types/chat.ts +92 -0
- package/src/types/config.ts +41 -0
- package/src/types/git.ts +50 -0
- package/src/types/project.ts +18 -0
- package/src/types/terminal.ts +20 -0
- package/src/web/app.tsx +168 -0
- package/src/web/components/auth/login-screen.tsx +88 -0
- package/src/web/components/chat/attachment-chips.tsx +55 -0
- package/src/web/components/chat/chat-placeholder.tsx +10 -0
- package/src/web/components/chat/chat-tab.tsx +301 -0
- package/src/web/components/chat/file-picker.tsx +126 -0
- package/src/web/components/chat/message-input.tsx +420 -0
- package/src/web/components/chat/message-list.tsx +838 -0
- package/src/web/components/chat/session-picker.tsx +139 -0
- package/src/web/components/chat/slash-command-picker.tsx +135 -0
- package/src/web/components/chat/usage-badge.tsx +186 -0
- package/src/web/components/editor/code-editor.tsx +329 -0
- package/src/web/components/editor/diff-viewer.tsx +276 -0
- package/src/web/components/editor/editor-placeholder.tsx +10 -0
- package/src/web/components/explorer/file-actions.tsx +191 -0
- package/src/web/components/explorer/file-tree.tsx +298 -0
- package/src/web/components/git/git-graph.tsx +727 -0
- package/src/web/components/git/git-placeholder.tsx +55 -0
- package/src/web/components/git/git-status-panel.tsx +850 -0
- package/src/web/components/layout/mobile-drawer.tsx +137 -0
- package/src/web/components/layout/mobile-nav.tsx +103 -0
- package/src/web/components/layout/sidebar.tsx +90 -0
- package/src/web/components/layout/tab-bar.tsx +152 -0
- package/src/web/components/layout/tab-content.tsx +85 -0
- package/src/web/components/projects/dir-suggest.tsx +152 -0
- package/src/web/components/projects/project-list.tsx +187 -0
- package/src/web/components/settings/settings-tab.tsx +57 -0
- package/src/web/components/terminal/terminal-placeholder.tsx +10 -0
- package/src/web/components/terminal/terminal-tab.tsx +133 -0
- package/src/web/components/ui/button.tsx +64 -0
- package/src/web/components/ui/context-menu.tsx +250 -0
- package/src/web/components/ui/dialog.tsx +156 -0
- package/src/web/components/ui/dropdown-menu.tsx +257 -0
- package/src/web/components/ui/input.tsx +21 -0
- package/src/web/components/ui/scroll-area.tsx +56 -0
- package/src/web/components/ui/separator.tsx +26 -0
- package/src/web/components/ui/sonner.tsx +40 -0
- package/src/web/components/ui/tabs.tsx +91 -0
- package/src/web/components/ui/tooltip.tsx +57 -0
- package/src/web/hooks/use-chat.ts +420 -0
- package/src/web/hooks/use-terminal.ts +182 -0
- package/src/web/hooks/use-url-sync.ts +66 -0
- package/src/web/hooks/use-websocket.ts +48 -0
- package/src/web/index.html +16 -0
- package/src/web/lib/api-client.ts +90 -0
- package/src/web/lib/file-support.ts +68 -0
- package/src/web/lib/utils.ts +6 -0
- package/src/web/lib/ws-client.ts +100 -0
- package/src/web/main.tsx +10 -0
- package/src/web/public/icon-192.svg +5 -0
- package/src/web/public/icon-512.svg +5 -0
- package/src/web/stores/file-store.ts +81 -0
- package/src/web/stores/project-store.ts +50 -0
- package/src/web/stores/settings-store.ts +65 -0
- package/src/web/stores/tab-store.ts +187 -0
- package/src/web/styles/globals.css +227 -0
- package/src/web/vite-env.d.ts +1 -0
- package/tests/integration/api/chat-routes.test.ts +95 -0
- package/tests/integration/claude-agent-sdk-integration.test.ts +228 -0
- package/tests/integration/ws/chat-websocket.test.ts +312 -0
- package/tests/test-setup.ts +5 -0
- package/tests/unit/providers/claude-agent-sdk.test.ts +339 -0
- package/tests/unit/providers/mock-provider.test.ts +143 -0
- package/tests/unit/services/chat-service.test.ts +100 -0
- package/tsconfig.json +32 -0
- 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
|
+
}
|