@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,88 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Button } from "@/components/ui/button";
|
|
3
|
+
import { Input } from "@/components/ui/input";
|
|
4
|
+
import { setAuthToken } from "@/lib/api-client";
|
|
5
|
+
import { Lock, AlertCircle } from "lucide-react";
|
|
6
|
+
|
|
7
|
+
interface LoginScreenProps {
|
|
8
|
+
onSuccess: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function LoginScreen({ onSuccess }: LoginScreenProps) {
|
|
12
|
+
const [token, setToken] = useState("");
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
const [loading, setLoading] = useState(false);
|
|
15
|
+
|
|
16
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
17
|
+
e.preventDefault();
|
|
18
|
+
if (!token.trim()) return;
|
|
19
|
+
|
|
20
|
+
setLoading(true);
|
|
21
|
+
setError(null);
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Store token first, then validate via auth check
|
|
25
|
+
setAuthToken(token.trim());
|
|
26
|
+
const res = await fetch("/api/auth/check", {
|
|
27
|
+
headers: { Authorization: `Bearer ${token.trim()}` },
|
|
28
|
+
});
|
|
29
|
+
const json = await res.json();
|
|
30
|
+
|
|
31
|
+
if (!json.ok) {
|
|
32
|
+
throw new Error(json.error ?? "Invalid token");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onSuccess();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
setError(err instanceof Error ? err.message : "Authentication failed");
|
|
38
|
+
// Clear invalid token
|
|
39
|
+
localStorage.removeItem("ppm-auth-token");
|
|
40
|
+
} finally {
|
|
41
|
+
setLoading(false);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
47
|
+
<div className="w-full max-w-sm bg-surface rounded-lg border border-border p-6 space-y-6">
|
|
48
|
+
<div className="text-center space-y-2">
|
|
49
|
+
<div className="flex items-center justify-center size-12 mx-auto rounded-full bg-surface-elevated">
|
|
50
|
+
<Lock className="size-6 text-primary" />
|
|
51
|
+
</div>
|
|
52
|
+
<h1 className="text-xl font-semibold text-foreground">PPM</h1>
|
|
53
|
+
<p className="text-sm text-text-secondary">
|
|
54
|
+
Enter your auth token to unlock
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
59
|
+
<div>
|
|
60
|
+
<Input
|
|
61
|
+
type="password"
|
|
62
|
+
placeholder="Auth token"
|
|
63
|
+
value={token}
|
|
64
|
+
onChange={(e) => setToken(e.target.value)}
|
|
65
|
+
className="h-11 bg-background border-border"
|
|
66
|
+
autoFocus
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
{error && (
|
|
71
|
+
<div className="flex items-center gap-2 text-error text-sm">
|
|
72
|
+
<AlertCircle className="size-4 shrink-0" />
|
|
73
|
+
<span>{error}</span>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
|
|
77
|
+
<Button
|
|
78
|
+
type="submit"
|
|
79
|
+
disabled={loading || !token.trim()}
|
|
80
|
+
className="w-full h-11"
|
|
81
|
+
>
|
|
82
|
+
{loading ? "Checking..." : "Unlock"}
|
|
83
|
+
</Button>
|
|
84
|
+
</form>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { X, FileText, Image as ImageIcon, Loader2 } from "lucide-react";
|
|
2
|
+
import type { ChatAttachment } from "./message-input";
|
|
3
|
+
|
|
4
|
+
interface AttachmentChipsProps {
|
|
5
|
+
attachments: ChatAttachment[];
|
|
6
|
+
onRemove: (id: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function AttachmentChips({ attachments, onRemove }: AttachmentChipsProps) {
|
|
10
|
+
if (attachments.length === 0) return null;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-wrap gap-1.5 px-3 pt-2">
|
|
14
|
+
{attachments.map((att) => (
|
|
15
|
+
<div
|
|
16
|
+
key={att.id}
|
|
17
|
+
className="flex items-center gap-1.5 rounded-md border border-border bg-surface px-2 py-1 text-xs text-text-secondary max-w-48"
|
|
18
|
+
>
|
|
19
|
+
{/* Thumbnail or icon */}
|
|
20
|
+
{att.previewUrl ? (
|
|
21
|
+
<img
|
|
22
|
+
src={att.previewUrl}
|
|
23
|
+
alt={att.name}
|
|
24
|
+
className="size-5 rounded object-cover shrink-0"
|
|
25
|
+
/>
|
|
26
|
+
) : att.isImage ? (
|
|
27
|
+
<ImageIcon className="size-3.5 shrink-0 text-text-subtle" />
|
|
28
|
+
) : (
|
|
29
|
+
<FileText className="size-3.5 shrink-0 text-text-subtle" />
|
|
30
|
+
)}
|
|
31
|
+
|
|
32
|
+
{/* File name */}
|
|
33
|
+
<span className="truncate">{att.name}</span>
|
|
34
|
+
|
|
35
|
+
{/* Status indicator */}
|
|
36
|
+
{att.status === "uploading" ? (
|
|
37
|
+
<Loader2 className="size-3 shrink-0 animate-spin text-text-subtle" />
|
|
38
|
+
) : att.status === "error" ? (
|
|
39
|
+
<span className="text-red-500 shrink-0" title="Upload failed">!</span>
|
|
40
|
+
) : null}
|
|
41
|
+
|
|
42
|
+
{/* Remove button */}
|
|
43
|
+
<button
|
|
44
|
+
type="button"
|
|
45
|
+
onClick={() => onRemove(att.id)}
|
|
46
|
+
className="shrink-0 rounded-sm p-0.5 hover:bg-border/50 transition-colors"
|
|
47
|
+
aria-label={`Remove ${att.name}`}
|
|
48
|
+
>
|
|
49
|
+
<X className="size-3" />
|
|
50
|
+
</button>
|
|
51
|
+
</div>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { MessageSquare } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export function ChatPlaceholder() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
|
|
6
|
+
<MessageSquare className="size-10 text-text-subtle" />
|
|
7
|
+
<p className="text-sm">AI Chat — coming in Phase 7</p>
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, type DragEvent } from "react";
|
|
2
|
+
import { Upload } from "lucide-react";
|
|
3
|
+
import { api, projectUrl } from "@/lib/api-client";
|
|
4
|
+
import { useChat } from "@/hooks/use-chat";
|
|
5
|
+
import { useTabStore } from "@/stores/tab-store";
|
|
6
|
+
import { useProjectStore } from "@/stores/project-store";
|
|
7
|
+
import { MessageList } from "./message-list";
|
|
8
|
+
import { MessageInput, type ChatAttachment } from "./message-input";
|
|
9
|
+
import { SessionPicker } from "./session-picker";
|
|
10
|
+
import { SlashCommandPicker, type SlashItem } from "./slash-command-picker";
|
|
11
|
+
import { FilePicker } from "./file-picker";
|
|
12
|
+
import { UsageBadge, UsageDetailPanel } from "./usage-badge";
|
|
13
|
+
import type { FileNode } from "../../../types/project";
|
|
14
|
+
import type { Session, SessionInfo } from "../../../types/chat";
|
|
15
|
+
|
|
16
|
+
interface ChatTabProps {
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
tabId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ChatTab({ metadata, tabId }: ChatTabProps) {
|
|
22
|
+
const [sessionId, setSessionId] = useState<string | null>(
|
|
23
|
+
(metadata?.sessionId as string) ?? null,
|
|
24
|
+
);
|
|
25
|
+
const [providerId, setProviderId] = useState<string>(
|
|
26
|
+
(metadata?.providerId as string) ?? "claude-sdk",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Slash picker state
|
|
30
|
+
const [slashItems, setSlashItems] = useState<SlashItem[]>([]);
|
|
31
|
+
const [showSlashPicker, setShowSlashPicker] = useState(false);
|
|
32
|
+
const [slashFilter, setSlashFilter] = useState("");
|
|
33
|
+
const [slashSelected, setSlashSelected] = useState<SlashItem | null>(null);
|
|
34
|
+
|
|
35
|
+
// File picker state
|
|
36
|
+
const [fileItems, setFileItems] = useState<FileNode[]>([]);
|
|
37
|
+
const [showFilePicker, setShowFilePicker] = useState(false);
|
|
38
|
+
const [fileFilter, setFileFilter] = useState("");
|
|
39
|
+
const [fileSelected, setFileSelected] = useState<FileNode | null>(null);
|
|
40
|
+
|
|
41
|
+
// Usage detail panel
|
|
42
|
+
const [showUsageDetail, setShowUsageDetail] = useState(false);
|
|
43
|
+
|
|
44
|
+
// Drag-and-drop state
|
|
45
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
46
|
+
const [externalFiles, setExternalFiles] = useState<File[] | null>(null);
|
|
47
|
+
const dragCounterRef = useRef(0);
|
|
48
|
+
|
|
49
|
+
const activeProject = useProjectStore((s) => s.activeProject);
|
|
50
|
+
const updateTab = useTabStore((s) => s.updateTab);
|
|
51
|
+
|
|
52
|
+
// Persist sessionId and providerId to tab metadata so reload restores the session
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!tabId || !sessionId) return;
|
|
55
|
+
updateTab(tabId, {
|
|
56
|
+
metadata: { ...metadata, sessionId, providerId },
|
|
57
|
+
});
|
|
58
|
+
}, [sessionId, providerId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
59
|
+
|
|
60
|
+
const {
|
|
61
|
+
messages,
|
|
62
|
+
messagesLoading,
|
|
63
|
+
isStreaming,
|
|
64
|
+
pendingApproval,
|
|
65
|
+
usageInfo,
|
|
66
|
+
usageLoading,
|
|
67
|
+
sendMessage,
|
|
68
|
+
respondToApproval,
|
|
69
|
+
cancelStreaming,
|
|
70
|
+
refreshUsage,
|
|
71
|
+
isConnected,
|
|
72
|
+
} = useChat(sessionId, providerId, activeProject?.name ?? "");
|
|
73
|
+
|
|
74
|
+
const handleNewSession = useCallback(() => {
|
|
75
|
+
const projectName = activeProject?.name ?? null;
|
|
76
|
+
useTabStore.getState().openTab({
|
|
77
|
+
type: "chat",
|
|
78
|
+
title: "AI Chat",
|
|
79
|
+
metadata: { projectName },
|
|
80
|
+
projectId: projectName,
|
|
81
|
+
closable: true,
|
|
82
|
+
});
|
|
83
|
+
}, [activeProject?.name]);
|
|
84
|
+
|
|
85
|
+
const handleSelectSession = useCallback((session: SessionInfo) => {
|
|
86
|
+
setSessionId(session.id);
|
|
87
|
+
setProviderId(session.providerId);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
/** Build message content with file references prepended */
|
|
91
|
+
const buildMessageWithAttachments = useCallback(
|
|
92
|
+
(content: string, attachments: ChatAttachment[]): string => {
|
|
93
|
+
if (attachments.length === 0) return content;
|
|
94
|
+
|
|
95
|
+
const fileRefs = attachments
|
|
96
|
+
.filter((a) => a.serverPath)
|
|
97
|
+
.map((a) => a.serverPath!)
|
|
98
|
+
.join("\n");
|
|
99
|
+
|
|
100
|
+
if (!fileRefs) return content;
|
|
101
|
+
|
|
102
|
+
// Prepend file paths so Claude Code can read them
|
|
103
|
+
const prefix = attachments.length === 1
|
|
104
|
+
? `[Attached file: ${fileRefs}]\n\n`
|
|
105
|
+
: `[Attached files:\n${fileRefs}\n]\n\n`;
|
|
106
|
+
|
|
107
|
+
return prefix + content;
|
|
108
|
+
},
|
|
109
|
+
[],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const handleSend = useCallback(
|
|
113
|
+
async (content: string, attachments: ChatAttachment[] = []) => {
|
|
114
|
+
const fullContent = buildMessageWithAttachments(content, attachments);
|
|
115
|
+
if (!fullContent.trim()) return;
|
|
116
|
+
|
|
117
|
+
if (!sessionId) {
|
|
118
|
+
try {
|
|
119
|
+
const pName = activeProject?.name ?? (metadata?.project as string) ?? "";
|
|
120
|
+
const session = await api.post<Session>(`${projectUrl(pName)}/chat/sessions`, {
|
|
121
|
+
providerId,
|
|
122
|
+
title: content.slice(0, 50),
|
|
123
|
+
});
|
|
124
|
+
setSessionId(session.id);
|
|
125
|
+
setProviderId(session.providerId);
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
sendMessage(fullContent);
|
|
128
|
+
}, 500);
|
|
129
|
+
return;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
console.error("Failed to create session:", e);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
sendMessage(fullContent);
|
|
136
|
+
},
|
|
137
|
+
[sessionId, providerId, metadata?.project, sendMessage, buildMessageWithAttachments, activeProject?.name],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// --- Slash picker handlers ---
|
|
141
|
+
const handleSlashStateChange = useCallback((visible: boolean, filter: string) => {
|
|
142
|
+
setShowSlashPicker(visible);
|
|
143
|
+
setSlashFilter(filter);
|
|
144
|
+
}, []);
|
|
145
|
+
|
|
146
|
+
const handleSlashSelect = useCallback((item: SlashItem) => {
|
|
147
|
+
setSlashSelected(item);
|
|
148
|
+
setShowSlashPicker(false);
|
|
149
|
+
setSlashFilter("");
|
|
150
|
+
setTimeout(() => setSlashSelected(null), 50);
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const handleSlashClose = useCallback(() => {
|
|
154
|
+
setShowSlashPicker(false);
|
|
155
|
+
setSlashFilter("");
|
|
156
|
+
}, []);
|
|
157
|
+
|
|
158
|
+
// --- File picker handlers ---
|
|
159
|
+
const handleFileStateChange = useCallback((visible: boolean, filter: string) => {
|
|
160
|
+
setShowFilePicker(visible);
|
|
161
|
+
setFileFilter(filter);
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
const handleFileSelect = useCallback((item: FileNode) => {
|
|
165
|
+
setFileSelected(item);
|
|
166
|
+
setShowFilePicker(false);
|
|
167
|
+
setFileFilter("");
|
|
168
|
+
setTimeout(() => setFileSelected(null), 50);
|
|
169
|
+
}, []);
|
|
170
|
+
|
|
171
|
+
const handleFileClose = useCallback(() => {
|
|
172
|
+
setShowFilePicker(false);
|
|
173
|
+
setFileFilter("");
|
|
174
|
+
}, []);
|
|
175
|
+
|
|
176
|
+
// --- Drag-and-drop on entire chat area ---
|
|
177
|
+
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
178
|
+
e.preventDefault();
|
|
179
|
+
dragCounterRef.current++;
|
|
180
|
+
if (e.dataTransfer.types.includes("Files")) {
|
|
181
|
+
setIsDragging(true);
|
|
182
|
+
}
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
186
|
+
e.preventDefault();
|
|
187
|
+
dragCounterRef.current--;
|
|
188
|
+
if (dragCounterRef.current === 0) {
|
|
189
|
+
setIsDragging(false);
|
|
190
|
+
}
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const handleDragOver = useCallback((e: DragEvent) => {
|
|
194
|
+
e.preventDefault();
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
const handleDrop = useCallback((e: DragEvent) => {
|
|
198
|
+
e.preventDefault();
|
|
199
|
+
dragCounterRef.current = 0;
|
|
200
|
+
setIsDragging(false);
|
|
201
|
+
|
|
202
|
+
const files = Array.from(e.dataTransfer.files);
|
|
203
|
+
if (files.length > 0) {
|
|
204
|
+
setExternalFiles(files);
|
|
205
|
+
// Reset after a tick so the effect fires even with same files
|
|
206
|
+
setTimeout(() => setExternalFiles(null), 100);
|
|
207
|
+
}
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
return (
|
|
211
|
+
<div
|
|
212
|
+
className="flex flex-col h-full relative"
|
|
213
|
+
onDragEnter={handleDragEnter}
|
|
214
|
+
onDragLeave={handleDragLeave}
|
|
215
|
+
onDragOver={handleDragOver}
|
|
216
|
+
onDrop={handleDrop}
|
|
217
|
+
>
|
|
218
|
+
{/* Drag overlay */}
|
|
219
|
+
{isDragging && (
|
|
220
|
+
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm border-2 border-dashed border-primary rounded-lg pointer-events-none">
|
|
221
|
+
<div className="flex flex-col items-center gap-2 text-primary">
|
|
222
|
+
<Upload className="size-8" />
|
|
223
|
+
<span className="text-sm font-medium">Drop files to attach</span>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
|
|
228
|
+
{/* Messages */}
|
|
229
|
+
<MessageList
|
|
230
|
+
messages={messages}
|
|
231
|
+
messagesLoading={messagesLoading}
|
|
232
|
+
pendingApproval={pendingApproval}
|
|
233
|
+
onApprovalResponse={respondToApproval}
|
|
234
|
+
isStreaming={isStreaming}
|
|
235
|
+
projectName={activeProject?.name}
|
|
236
|
+
/>
|
|
237
|
+
|
|
238
|
+
{/* Bottom toolbar */}
|
|
239
|
+
<div className="border-t border-border bg-background shrink-0">
|
|
240
|
+
{/* Session bar */}
|
|
241
|
+
<div className="flex items-center justify-between px-3 py-1.5 border-b border-border/50">
|
|
242
|
+
<SessionPicker
|
|
243
|
+
currentSessionId={sessionId}
|
|
244
|
+
onSelectSession={handleSelectSession}
|
|
245
|
+
onNewSession={handleNewSession}
|
|
246
|
+
projectName={activeProject?.name}
|
|
247
|
+
/>
|
|
248
|
+
<div className="flex items-center gap-2">
|
|
249
|
+
<UsageBadge
|
|
250
|
+
usage={usageInfo}
|
|
251
|
+
onClick={() => setShowUsageDetail((v) => !v)}
|
|
252
|
+
/>
|
|
253
|
+
{isConnected && (
|
|
254
|
+
<span className="size-2 rounded-full bg-green-500" title="Connected" />
|
|
255
|
+
)}
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
|
|
259
|
+
{/* Usage detail panel (in-flow) */}
|
|
260
|
+
<UsageDetailPanel
|
|
261
|
+
usage={usageInfo}
|
|
262
|
+
visible={showUsageDetail}
|
|
263
|
+
onClose={() => setShowUsageDetail(false)}
|
|
264
|
+
onReload={refreshUsage}
|
|
265
|
+
loading={usageLoading}
|
|
266
|
+
/>
|
|
267
|
+
|
|
268
|
+
{/* Pickers (in-flow, above input — only one visible at a time) */}
|
|
269
|
+
<SlashCommandPicker
|
|
270
|
+
items={slashItems}
|
|
271
|
+
filter={slashFilter}
|
|
272
|
+
onSelect={handleSlashSelect}
|
|
273
|
+
onClose={handleSlashClose}
|
|
274
|
+
visible={showSlashPicker}
|
|
275
|
+
/>
|
|
276
|
+
<FilePicker
|
|
277
|
+
items={fileItems}
|
|
278
|
+
filter={fileFilter}
|
|
279
|
+
onSelect={handleFileSelect}
|
|
280
|
+
onClose={handleFileClose}
|
|
281
|
+
visible={showFilePicker}
|
|
282
|
+
/>
|
|
283
|
+
|
|
284
|
+
{/* Input */}
|
|
285
|
+
<MessageInput
|
|
286
|
+
onSend={handleSend}
|
|
287
|
+
isStreaming={isStreaming}
|
|
288
|
+
onCancel={cancelStreaming}
|
|
289
|
+
projectName={activeProject?.name}
|
|
290
|
+
onSlashStateChange={handleSlashStateChange}
|
|
291
|
+
onSlashItemsLoaded={setSlashItems}
|
|
292
|
+
slashSelected={slashSelected}
|
|
293
|
+
onFileStateChange={handleFileStateChange}
|
|
294
|
+
onFileItemsLoaded={setFileItems}
|
|
295
|
+
fileSelected={fileSelected}
|
|
296
|
+
externalFiles={externalFiles}
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
);
|
|
301
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useState, useEffect, useRef, useCallback, type KeyboardEvent } from "react";
|
|
2
|
+
import { File, Folder } from "lucide-react";
|
|
3
|
+
import type { FileNode } from "../../../types/project";
|
|
4
|
+
|
|
5
|
+
interface FilePickerProps {
|
|
6
|
+
items: FileNode[];
|
|
7
|
+
filter: string;
|
|
8
|
+
onSelect: (item: FileNode) => void;
|
|
9
|
+
onClose: () => void;
|
|
10
|
+
visible: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Flatten a FileNode tree into a flat list of files and directories. */
|
|
14
|
+
export function flattenFileTree(nodes: FileNode[]): FileNode[] {
|
|
15
|
+
const result: FileNode[] = [];
|
|
16
|
+
function walk(list: FileNode[]) {
|
|
17
|
+
for (const node of list) {
|
|
18
|
+
result.push(node);
|
|
19
|
+
if (node.children) walk(node.children);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
walk(nodes);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function FilePicker({
|
|
27
|
+
items,
|
|
28
|
+
filter,
|
|
29
|
+
onSelect,
|
|
30
|
+
onClose,
|
|
31
|
+
visible,
|
|
32
|
+
}: FilePickerProps) {
|
|
33
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
34
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
35
|
+
|
|
36
|
+
const filtered = (() => {
|
|
37
|
+
if (!filter) return items.slice(0, 50);
|
|
38
|
+
const q = filter.toLowerCase();
|
|
39
|
+
return items
|
|
40
|
+
.filter((node) => node.path.toLowerCase().includes(q) || node.name.toLowerCase().includes(q))
|
|
41
|
+
.slice(0, 50);
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
// Reset selection when filter changes
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setSelectedIndex(0);
|
|
47
|
+
}, [filter]);
|
|
48
|
+
|
|
49
|
+
// Scroll selected item into view
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const list = listRef.current;
|
|
52
|
+
if (!list) return;
|
|
53
|
+
const selected = list.children[selectedIndex] as HTMLElement | undefined;
|
|
54
|
+
selected?.scrollIntoView({ block: "nearest" });
|
|
55
|
+
}, [selectedIndex]);
|
|
56
|
+
|
|
57
|
+
const handleKeyDown = useCallback(
|
|
58
|
+
(e: KeyboardEvent | globalThis.KeyboardEvent) => {
|
|
59
|
+
if (!visible || filtered.length === 0) return false;
|
|
60
|
+
|
|
61
|
+
switch (e.key) {
|
|
62
|
+
case "ArrowUp":
|
|
63
|
+
e.preventDefault();
|
|
64
|
+
setSelectedIndex((i) => (i > 0 ? i - 1 : filtered.length - 1));
|
|
65
|
+
return true;
|
|
66
|
+
case "ArrowDown":
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
setSelectedIndex((i) => (i < filtered.length - 1 ? i + 1 : 0));
|
|
69
|
+
return true;
|
|
70
|
+
case "Enter":
|
|
71
|
+
case "Tab":
|
|
72
|
+
e.preventDefault();
|
|
73
|
+
if (filtered[selectedIndex]) {
|
|
74
|
+
onSelect(filtered[selectedIndex]);
|
|
75
|
+
}
|
|
76
|
+
return true;
|
|
77
|
+
case "Escape":
|
|
78
|
+
e.preventDefault();
|
|
79
|
+
onClose();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
},
|
|
84
|
+
[visible, filtered, selectedIndex, onSelect, onClose],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Global keyboard handler (captures before textarea)
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!visible) return;
|
|
90
|
+
const handler = (e: globalThis.KeyboardEvent) => {
|
|
91
|
+
handleKeyDown(e);
|
|
92
|
+
};
|
|
93
|
+
document.addEventListener("keydown", handler, true);
|
|
94
|
+
return () => document.removeEventListener("keydown", handler, true);
|
|
95
|
+
}, [visible, handleKeyDown]);
|
|
96
|
+
|
|
97
|
+
if (!visible || filtered.length === 0) return null;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<div className="max-h-52 overflow-y-auto border-b border-border bg-surface">
|
|
101
|
+
<div ref={listRef} className="py-1">
|
|
102
|
+
{filtered.map((item, i) => (
|
|
103
|
+
<button
|
|
104
|
+
key={item.path}
|
|
105
|
+
className={`flex items-center gap-2 w-full px-3 py-1.5 text-left transition-colors ${
|
|
106
|
+
i === selectedIndex
|
|
107
|
+
? "bg-primary/10 text-primary"
|
|
108
|
+
: "hover:bg-surface-hover text-text-primary"
|
|
109
|
+
}`}
|
|
110
|
+
onMouseEnter={() => setSelectedIndex(i)}
|
|
111
|
+
onClick={() => onSelect(item)}
|
|
112
|
+
>
|
|
113
|
+
<span className="shrink-0">
|
|
114
|
+
{item.type === "directory" ? (
|
|
115
|
+
<Folder className="size-4 text-amber-500" />
|
|
116
|
+
) : (
|
|
117
|
+
<File className="size-4 text-blue-400" />
|
|
118
|
+
)}
|
|
119
|
+
</span>
|
|
120
|
+
<span className="text-sm truncate">{item.path}</span>
|
|
121
|
+
</button>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|