@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,182 @@
1
+ import { useEffect, useRef, useCallback, useState } from "react";
2
+ import { Terminal } from "@xterm/xterm";
3
+ import { FitAddon } from "@xterm/addon-fit";
4
+ import { WebLinksAddon } from "@xterm/addon-web-links";
5
+
6
+ interface UseTerminalOptions {
7
+ sessionId: string;
8
+ projectName?: string;
9
+ containerRef: React.RefObject<HTMLDivElement | null>;
10
+ }
11
+
12
+ interface UseTerminalReturn {
13
+ connected: boolean;
14
+ reconnecting: boolean;
15
+ }
16
+
17
+ const RESIZE_PREFIX = "\x01RESIZE:";
18
+
19
+ export function useTerminal(
20
+ options: UseTerminalOptions,
21
+ ): UseTerminalReturn {
22
+ const { sessionId, containerRef } = options;
23
+ const termRef = useRef<Terminal | null>(null);
24
+ const fitRef = useRef<FitAddon | null>(null);
25
+ const wsRef = useRef<WebSocket | null>(null);
26
+ const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
27
+ const reconnectAttempts = useRef(0);
28
+ const [connected, setConnected] = useState(false);
29
+ const [reconnecting, setReconnecting] = useState(false);
30
+ const actualSessionId = useRef(sessionId); // Track server-assigned session ID
31
+
32
+ const sendResize = useCallback(() => {
33
+ const term = termRef.current;
34
+ const ws = wsRef.current;
35
+ if (term && ws?.readyState === WebSocket.OPEN) {
36
+ ws.send(`${RESIZE_PREFIX}${term.cols},${term.rows}`);
37
+ }
38
+ }, []);
39
+
40
+ const connectWs = useCallback(() => {
41
+ const term = termRef.current;
42
+ if (!term) return;
43
+
44
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
45
+ const projectName = options.projectName ?? "";
46
+ // Use actual session ID from server on reconnect (not "new")
47
+ const sid = actualSessionId.current;
48
+ const url = `${protocol}//${window.location.host}/ws/project/${encodeURIComponent(projectName)}/terminal/${sid}`;
49
+
50
+ const ws = new WebSocket(url);
51
+ wsRef.current = ws;
52
+
53
+ ws.onopen = () => {
54
+ setConnected(true);
55
+ setReconnecting(false);
56
+ reconnectAttempts.current = 0;
57
+ sendResize();
58
+ };
59
+
60
+ ws.onmessage = (event) => {
61
+ if (typeof event.data === "string") {
62
+ // Filter JSON control messages from terminal output
63
+ if (event.data.startsWith("{")) {
64
+ try {
65
+ const msg = JSON.parse(event.data);
66
+ if (msg.type === "session" || msg.type === "error") {
67
+ if (msg.type === "session" && msg.id) {
68
+ actualSessionId.current = msg.id; // Save for reconnect
69
+ }
70
+ if (msg.type === "error") {
71
+ term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`);
72
+ }
73
+ return; // Don't write raw JSON to terminal
74
+ }
75
+ } catch {
76
+ // Not JSON, write as terminal output
77
+ }
78
+ }
79
+ term.write(event.data);
80
+ }
81
+ };
82
+
83
+ ws.onclose = () => {
84
+ setConnected(false);
85
+ scheduleReconnect();
86
+ };
87
+
88
+ ws.onerror = () => {
89
+ ws.close();
90
+ };
91
+ }, [sessionId, sendResize]); // eslint-disable-line react-hooks/exhaustive-deps
92
+
93
+ function scheduleReconnect() {
94
+ const delay = Math.min(
95
+ 1000 * Math.pow(2, reconnectAttempts.current),
96
+ 30000,
97
+ );
98
+ reconnectAttempts.current++;
99
+ setReconnecting(true);
100
+ reconnectTimer.current = setTimeout(() => {
101
+ connectWs();
102
+ }, delay);
103
+ }
104
+
105
+ useEffect(() => {
106
+ const container = containerRef.current;
107
+ if (!container) return;
108
+
109
+ const term = new Terminal({
110
+ cursorBlink: true,
111
+ fontSize: 13,
112
+ fontFamily: "var(--font-mono)",
113
+ theme: {
114
+ background: "#0f1419",
115
+ foreground: "#e5e7eb",
116
+ cursor: "#e5e7eb",
117
+ selectionBackground: "#3b82f640",
118
+ black: "#1a1f2e",
119
+ red: "#ef4444",
120
+ green: "#10b981",
121
+ yellow: "#f59e0b",
122
+ blue: "#3b82f6",
123
+ magenta: "#a855f7",
124
+ cyan: "#06b6d4",
125
+ white: "#e5e7eb",
126
+ brightBlack: "#6b7280",
127
+ brightRed: "#f87171",
128
+ brightGreen: "#34d399",
129
+ brightYellow: "#fbbf24",
130
+ brightBlue: "#60a5fa",
131
+ brightMagenta: "#c084fc",
132
+ brightCyan: "#22d3ee",
133
+ brightWhite: "#f9fafb",
134
+ },
135
+ });
136
+
137
+ const fitAddon = new FitAddon();
138
+ const webLinksAddon = new WebLinksAddon();
139
+
140
+ term.loadAddon(fitAddon);
141
+ term.loadAddon(webLinksAddon);
142
+ term.open(container);
143
+ fitAddon.fit();
144
+
145
+ termRef.current = term;
146
+ fitRef.current = fitAddon;
147
+
148
+ // Wire input to WS
149
+ term.onData((data) => {
150
+ const ws = wsRef.current;
151
+ if (ws?.readyState === WebSocket.OPEN) {
152
+ ws.send(data);
153
+ }
154
+ });
155
+
156
+ // Connect WS
157
+ connectWs();
158
+
159
+ // ResizeObserver for auto-fit
160
+ const resizeObserver = new ResizeObserver(() => {
161
+ try {
162
+ fitAddon.fit();
163
+ sendResize();
164
+ } catch {
165
+ // Ignore fit errors during teardown
166
+ }
167
+ });
168
+ resizeObserver.observe(container);
169
+
170
+ return () => {
171
+ resizeObserver.disconnect();
172
+ if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
173
+ wsRef.current?.close();
174
+ wsRef.current = null;
175
+ term.dispose();
176
+ termRef.current = null;
177
+ fitRef.current = null;
178
+ };
179
+ }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
180
+
181
+ return { connected, reconnecting };
182
+ }
@@ -0,0 +1,66 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { useTabStore } from "@/stores/tab-store";
3
+
4
+ /**
5
+ * Parse the current URL to extract project name and tab ID.
6
+ * Expected format: /project/:projectName/tab/:tabId
7
+ */
8
+ export function parseUrlState(): { projectName: string | null; tabId: string | null } {
9
+ const path = window.location.pathname;
10
+ const match = path.match(/^\/project\/([^/]+)(?:\/tab\/([^/]+))?/);
11
+ if (!match) return { projectName: null, tabId: null };
12
+ return {
13
+ projectName: match[1] ? decodeURIComponent(match[1]) : null,
14
+ tabId: match[2] ? decodeURIComponent(match[2]) : null,
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Build URL path from project name and tab ID.
20
+ */
21
+ function buildUrl(projectName: string | null, tabId: string | null): string {
22
+ if (!projectName || projectName === "__global__") return "/";
23
+ let url = `/project/${encodeURIComponent(projectName)}`;
24
+ if (tabId) url += `/tab/${encodeURIComponent(tabId)}`;
25
+ return url;
26
+ }
27
+
28
+ /**
29
+ * Sync tab/project state with browser URL.
30
+ * - On tab/project change → pushState (enables back/forward navigation)
31
+ * - On popstate (back/forward) → restore tab from URL
32
+ */
33
+ export function useUrlSync() {
34
+ const activeTabId = useTabStore((s) => s.activeTabId);
35
+ const currentProject = useTabStore((s) => s.currentProject);
36
+ const isPopState = useRef(false);
37
+
38
+ // Push URL when active tab or project changes
39
+ useEffect(() => {
40
+ // Skip push if this change was triggered by popstate (back/forward)
41
+ if (isPopState.current) {
42
+ isPopState.current = false;
43
+ return;
44
+ }
45
+
46
+ const newUrl = buildUrl(currentProject, activeTabId);
47
+ if (window.location.pathname !== newUrl) {
48
+ window.history.pushState(null, "", newUrl);
49
+ }
50
+ }, [activeTabId, currentProject]);
51
+
52
+ // Listen for back/forward navigation
53
+ useEffect(() => {
54
+ function handlePopState() {
55
+ const { tabId } = parseUrlState();
56
+ const { tabs, setActiveTab } = useTabStore.getState();
57
+ if (tabId && tabs.some((t) => t.id === tabId)) {
58
+ isPopState.current = true;
59
+ setActiveTab(tabId);
60
+ }
61
+ }
62
+
63
+ window.addEventListener("popstate", handlePopState);
64
+ return () => window.removeEventListener("popstate", handlePopState);
65
+ }, []);
66
+ }
@@ -0,0 +1,48 @@
1
+ import { useEffect, useRef, useCallback } from "react";
2
+ import { WsClient } from "@/lib/ws-client";
3
+
4
+ interface UseWebSocketOptions {
5
+ url: string;
6
+ onMessage?: (event: MessageEvent) => void;
7
+ autoConnect?: boolean;
8
+ }
9
+
10
+ export function useWebSocket({
11
+ url,
12
+ onMessage,
13
+ autoConnect = true,
14
+ }: UseWebSocketOptions) {
15
+ const clientRef = useRef<WsClient | null>(null);
16
+
17
+ useEffect(() => {
18
+ const client = new WsClient(url);
19
+ clientRef.current = client;
20
+
21
+ if (onMessage) {
22
+ client.onMessage(onMessage);
23
+ }
24
+
25
+ if (autoConnect) {
26
+ client.connect();
27
+ }
28
+
29
+ return () => {
30
+ client.disconnect();
31
+ clientRef.current = null;
32
+ };
33
+ }, [url, autoConnect]); // eslint-disable-line react-hooks/exhaustive-deps
34
+
35
+ const send = useCallback((data: string | ArrayBuffer) => {
36
+ clientRef.current?.send(data);
37
+ }, []);
38
+
39
+ const connect = useCallback(() => {
40
+ clientRef.current?.connect();
41
+ }, []);
42
+
43
+ const disconnect = useCallback(() => {
44
+ clientRef.current?.disconnect();
45
+ }, []);
46
+
47
+ return { send, connect, disconnect };
48
+ }
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
+ <meta name="theme-color" content="#0f1419" />
7
+ <title>PPM — Personal Project Manager</title>
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@400;500;600;700&family=Geist:wght@400;500;600;700&display=swap" rel="stylesheet" />
11
+ </head>
12
+ <body class="bg-[#0f1419] text-[#e5e7eb] font-sans antialiased">
13
+ <div id="root"></div>
14
+ <script type="module" src="./main.tsx"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,90 @@
1
+ const TOKEN_KEY = "ppm-auth-token";
2
+
3
+ class ApiClient {
4
+ private baseUrl: string;
5
+
6
+ constructor(baseUrl = "") {
7
+ this.baseUrl = baseUrl;
8
+ }
9
+
10
+ private getToken(): string | null {
11
+ return localStorage.getItem(TOKEN_KEY);
12
+ }
13
+
14
+ private headers(): HeadersInit {
15
+ const h: HeadersInit = { "Content-Type": "application/json" };
16
+ const token = this.getToken();
17
+ if (token) h["Authorization"] = `Bearer ${token}`;
18
+ return h;
19
+ }
20
+
21
+ /** Auto-unwraps {ok, data} envelope. Returns T directly. */
22
+ async get<T>(path: string): Promise<T> {
23
+ const res = await fetch(`${this.baseUrl}${path}`, {
24
+ headers: this.headers(),
25
+ });
26
+ return this.handleResponse<T>(res);
27
+ }
28
+
29
+ async post<T>(path: string, body?: unknown): Promise<T> {
30
+ const res = await fetch(`${this.baseUrl}${path}`, {
31
+ method: "POST",
32
+ headers: this.headers(),
33
+ body: body != null ? JSON.stringify(body) : undefined,
34
+ });
35
+ return this.handleResponse<T>(res);
36
+ }
37
+
38
+ async put<T>(path: string, body?: unknown): Promise<T> {
39
+ const res = await fetch(`${this.baseUrl}${path}`, {
40
+ method: "PUT",
41
+ headers: this.headers(),
42
+ body: body != null ? JSON.stringify(body) : undefined,
43
+ });
44
+ return this.handleResponse<T>(res);
45
+ }
46
+
47
+ async del(path: string, body?: unknown): Promise<void> {
48
+ const res = await fetch(`${this.baseUrl}${path}`, {
49
+ method: "DELETE",
50
+ headers: this.headers(),
51
+ body: body != null ? JSON.stringify(body) : undefined,
52
+ });
53
+ await this.handleResponse<void>(res);
54
+ }
55
+
56
+ private async handleResponse<T>(res: Response): Promise<T> {
57
+ if (res.status === 401) {
58
+ localStorage.removeItem(TOKEN_KEY);
59
+ window.location.reload();
60
+ throw new Error("Unauthorized");
61
+ }
62
+
63
+ const json = await res.json();
64
+
65
+ if (json.ok === false) {
66
+ throw new Error(json.error ?? `HTTP ${res.status}`);
67
+ }
68
+
69
+ return json.data as T;
70
+ }
71
+ }
72
+
73
+ export const api = new ApiClient();
74
+
75
+ /** Build project-scoped API path prefix */
76
+ export function projectUrl(projectName: string): string {
77
+ return `/api/project/${encodeURIComponent(projectName)}`;
78
+ }
79
+
80
+ export function setAuthToken(token: string) {
81
+ localStorage.setItem(TOKEN_KEY, token);
82
+ }
83
+
84
+ export function clearAuthToken() {
85
+ localStorage.removeItem(TOKEN_KEY);
86
+ }
87
+
88
+ export function getAuthToken(): string | null {
89
+ return localStorage.getItem(TOKEN_KEY);
90
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Claude Code supported file types for chat attachments.
3
+ * Supported files are uploaded and referenced; unsupported files get path inserted as text.
4
+ */
5
+
6
+ /** Image MIME types Claude Code can read via the Read tool (multimodal) */
7
+ const SUPPORTED_IMAGE_TYPES = new Set([
8
+ "image/png",
9
+ "image/jpeg",
10
+ "image/gif",
11
+ "image/webp",
12
+ ]);
13
+
14
+ /** Document MIME types Claude Code can read */
15
+ const SUPPORTED_DOC_TYPES = new Set([
16
+ "application/pdf",
17
+ ]);
18
+
19
+ /** Text/code MIME prefixes Claude Code can read */
20
+ const TEXT_MIME_PREFIXES = [
21
+ "text/",
22
+ "application/json",
23
+ "application/xml",
24
+ "application/javascript",
25
+ "application/typescript",
26
+ "application/x-yaml",
27
+ "application/toml",
28
+ "application/x-sh",
29
+ ];
30
+
31
+ /** File extensions considered text/code even if MIME is application/octet-stream */
32
+ const TEXT_EXTENSIONS = new Set([
33
+ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
34
+ ".py", ".rb", ".go", ".rs", ".java", ".kt", ".swift",
35
+ ".c", ".cpp", ".h", ".hpp", ".cs",
36
+ ".json", ".yaml", ".yml", ".toml", ".xml",
37
+ ".md", ".mdx", ".txt", ".csv", ".tsv",
38
+ ".html", ".css", ".scss", ".less", ".sass",
39
+ ".sh", ".bash", ".zsh", ".fish",
40
+ ".sql", ".graphql", ".gql",
41
+ ".env", ".ini", ".cfg", ".conf",
42
+ ".dockerfile", ".makefile",
43
+ ".vue", ".svelte", ".astro",
44
+ ".ipynb",
45
+ ]);
46
+
47
+ export function isImageFile(file: File): boolean {
48
+ return SUPPORTED_IMAGE_TYPES.has(file.type);
49
+ }
50
+
51
+ export function isSupportedFile(file: File): boolean {
52
+ // Images
53
+ if (SUPPORTED_IMAGE_TYPES.has(file.type)) return true;
54
+ // Documents
55
+ if (SUPPORTED_DOC_TYPES.has(file.type)) return true;
56
+ // Text MIME types
57
+ if (TEXT_MIME_PREFIXES.some((p) => file.type.startsWith(p))) return true;
58
+ // Fallback: check extension
59
+ const ext = getExtension(file.name);
60
+ if (ext && TEXT_EXTENSIONS.has(ext)) return true;
61
+ return false;
62
+ }
63
+
64
+ function getExtension(name: string): string {
65
+ const dot = name.lastIndexOf(".");
66
+ if (dot === -1) return "";
67
+ return name.slice(dot).toLowerCase();
68
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
@@ -0,0 +1,100 @@
1
+ type MessageHandler = (data: MessageEvent) => void;
2
+
3
+ const MAX_RECONNECT_DELAY = 30_000;
4
+ const BASE_DELAY = 1_000;
5
+
6
+ export class WsClient {
7
+ private ws: WebSocket | null = null;
8
+ private url: string;
9
+ private handlers: MessageHandler[] = [];
10
+ private reconnectAttempts = 0;
11
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
12
+ private intentionalClose = false;
13
+
14
+ constructor(url: string) {
15
+ this.url = url;
16
+ }
17
+
18
+ connect(): void {
19
+ this.intentionalClose = false;
20
+ this.cleanup();
21
+
22
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
23
+ const fullUrl = this.url.startsWith("ws")
24
+ ? this.url
25
+ : `${protocol}//${window.location.host}${this.url}`;
26
+
27
+ this.ws = new WebSocket(fullUrl);
28
+
29
+ this.ws.onopen = () => {
30
+ this.reconnectAttempts = 0;
31
+ };
32
+
33
+ this.ws.onmessage = (event) => {
34
+ for (const handler of this.handlers) {
35
+ handler(event);
36
+ }
37
+ };
38
+
39
+ this.ws.onclose = () => {
40
+ if (!this.intentionalClose) {
41
+ this.scheduleReconnect();
42
+ }
43
+ };
44
+
45
+ this.ws.onerror = () => {
46
+ this.ws?.close();
47
+ };
48
+ }
49
+
50
+ disconnect(): void {
51
+ this.intentionalClose = true;
52
+ this.cleanup();
53
+ if (this.reconnectTimer) {
54
+ clearTimeout(this.reconnectTimer);
55
+ this.reconnectTimer = null;
56
+ }
57
+ }
58
+
59
+ send(data: string | ArrayBuffer): void {
60
+ if (this.ws?.readyState === WebSocket.OPEN) {
61
+ this.ws.send(data);
62
+ }
63
+ }
64
+
65
+ onMessage(handler: MessageHandler): () => void {
66
+ this.handlers.push(handler);
67
+ return () => {
68
+ this.handlers = this.handlers.filter((h) => h !== handler);
69
+ };
70
+ }
71
+
72
+ get isConnected(): boolean {
73
+ return this.ws?.readyState === WebSocket.OPEN;
74
+ }
75
+
76
+ private cleanup(): void {
77
+ if (this.ws) {
78
+ this.ws.onopen = null;
79
+ this.ws.onclose = null;
80
+ this.ws.onmessage = null;
81
+ this.ws.onerror = null;
82
+ if (
83
+ this.ws.readyState === WebSocket.OPEN ||
84
+ this.ws.readyState === WebSocket.CONNECTING
85
+ ) {
86
+ this.ws.close();
87
+ }
88
+ this.ws = null;
89
+ }
90
+ }
91
+
92
+ private scheduleReconnect(): void {
93
+ const delay = Math.min(
94
+ BASE_DELAY * Math.pow(2, this.reconnectAttempts),
95
+ MAX_RECONNECT_DELAY,
96
+ );
97
+ this.reconnectAttempts++;
98
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
99
+ }
100
+ }
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { App } from "./app.tsx";
4
+ import "./styles/globals.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
2
+ <rect width="192" height="192" rx="24" fill="#0f1419"/>
3
+ <rect x="8" y="8" width="176" height="176" rx="20" fill="#1a1f2e"/>
4
+ <text x="96" y="110" text-anchor="middle" font-family="sans-serif" font-weight="700" font-size="56" fill="#3b82f6">PPM</text>
5
+ </svg>
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
2
+ <rect width="192" height="192" rx="24" fill="#0f1419"/>
3
+ <rect x="8" y="8" width="176" height="176" rx="20" fill="#1a1f2e"/>
4
+ <text x="96" y="110" text-anchor="middle" font-family="sans-serif" font-weight="700" font-size="56" fill="#3b82f6">PPM</text>
5
+ </svg>
@@ -0,0 +1,81 @@
1
+ import { create } from "zustand";
2
+ import { api, projectUrl } from "@/lib/api-client";
3
+
4
+ export interface FileNode {
5
+ name: string;
6
+ path: string;
7
+ type: "file" | "directory";
8
+ children?: FileNode[];
9
+ size?: number;
10
+ modified?: string;
11
+ }
12
+
13
+ interface FileStore {
14
+ tree: FileNode[];
15
+ loading: boolean;
16
+ error: string | null;
17
+ expandedPaths: Set<string>;
18
+ selectedFiles: string[];
19
+ fetchTree: (projectName: string) => Promise<void>;
20
+ toggleExpand: (path: string) => void;
21
+ setExpanded: (path: string, expanded: boolean) => void;
22
+ toggleFileSelect: (path: string) => void;
23
+ clearSelection: () => void;
24
+ reset: () => void;
25
+ }
26
+
27
+ export const useFileStore = create<FileStore>((set, get) => ({
28
+ tree: [],
29
+ loading: false,
30
+ error: null,
31
+ expandedPaths: new Set<string>(),
32
+ selectedFiles: [],
33
+
34
+ fetchTree: async (projectName: string) => {
35
+ set({ loading: true, error: null });
36
+ try {
37
+ const tree = await api.get<FileNode[]>(
38
+ `${projectUrl(projectName)}/files/tree?depth=3`,
39
+ );
40
+ set({ tree, loading: false });
41
+ } catch (err) {
42
+ set({
43
+ error: err instanceof Error ? err.message : "Failed to load files",
44
+ loading: false,
45
+ });
46
+ }
47
+ },
48
+
49
+ toggleExpand: (path: string) => {
50
+ const expanded = new Set(get().expandedPaths);
51
+ if (expanded.has(path)) {
52
+ expanded.delete(path);
53
+ } else {
54
+ expanded.add(path);
55
+ }
56
+ set({ expandedPaths: expanded });
57
+ },
58
+
59
+ setExpanded: (path: string, expanded: boolean) => {
60
+ const paths = new Set(get().expandedPaths);
61
+ if (expanded) paths.add(path);
62
+ else paths.delete(path);
63
+ set({ expandedPaths: paths });
64
+ },
65
+
66
+ toggleFileSelect: (path: string) => {
67
+ const current = get().selectedFiles;
68
+ const idx = current.indexOf(path);
69
+ if (idx >= 0) {
70
+ set({ selectedFiles: current.filter((p) => p !== path) });
71
+ } else {
72
+ // Max 2 selected files
73
+ const next = current.length >= 2 ? [current[1]!, path] : [...current, path];
74
+ set({ selectedFiles: next });
75
+ }
76
+ },
77
+
78
+ clearSelection: () => set({ selectedFiles: [] }),
79
+
80
+ reset: () => set({ tree: [], expandedPaths: new Set(), error: null, selectedFiles: [] }),
81
+ }));