@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,339 @@
1
+ import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test";
2
+ import type { ChatEvent } from "../../../src/types/chat.ts";
3
+
4
+ /**
5
+ * Helper: create an async iterable from an array of items with optional delay.
6
+ * Supports being "closed" mid-iteration (simulates SDK query.close()).
7
+ */
8
+ function createMockQueryIterator(
9
+ items: Array<{ type: string; message?: unknown }>,
10
+ delayMs = 10,
11
+ ) {
12
+ let closed = false;
13
+ let closeResolve: (() => void) | undefined;
14
+ const closePromise = new Promise<void>((r) => (closeResolve = r));
15
+
16
+ const iterator: AsyncIterableIterator<any> & { close: () => void } = {
17
+ close() {
18
+ closed = true;
19
+ closeResolve?.();
20
+ },
21
+ [Symbol.asyncIterator]() {
22
+ return this;
23
+ },
24
+ async next() {
25
+ if (closed) return { done: true, value: undefined };
26
+
27
+ if (items.length === 0) return { done: true, value: undefined };
28
+
29
+ // Small delay to simulate streaming
30
+ await new Promise((r) => setTimeout(r, delayMs));
31
+
32
+ if (closed) return { done: true, value: undefined };
33
+
34
+ const item = items.shift()!;
35
+ return { done: false, value: item };
36
+ },
37
+ };
38
+
39
+ return iterator;
40
+ }
41
+
42
+ // Mock the SDK module
43
+ let mockQueryFn: ReturnType<typeof mock>;
44
+
45
+ mock.module("@anthropic-ai/claude-agent-sdk", () => {
46
+ mockQueryFn = mock((...args: any[]) => {
47
+ // Default: return empty iterator. Tests override via mockQueryFn.mockImplementation()
48
+ return createMockQueryIterator([]);
49
+ });
50
+ return {
51
+ query: (...args: any[]) => mockQueryFn(...args),
52
+ listSessions: mock(() => Promise.resolve([])),
53
+ getSessionMessages: mock(() => Promise.resolve([])),
54
+ };
55
+ });
56
+
57
+ // Import AFTER mocking
58
+ const { ClaudeAgentSdkProvider } = await import(
59
+ "../../../src/providers/claude-agent-sdk.ts"
60
+ );
61
+
62
+ describe("ClaudeAgentSdkProvider", () => {
63
+ let provider: InstanceType<typeof ClaudeAgentSdkProvider>;
64
+
65
+ beforeEach(() => {
66
+ provider = new ClaudeAgentSdkProvider();
67
+ mockQueryFn.mockReset();
68
+ });
69
+
70
+ describe("sendMessage", () => {
71
+ it("yields text events from partial messages", async () => {
72
+ const iter = createMockQueryIterator([
73
+ {
74
+ type: "partial",
75
+ message: { content: [{ type: "text", text: "Hello" }] },
76
+ },
77
+ {
78
+ type: "partial",
79
+ message: { content: [{ type: "text", text: "Hello world" }] },
80
+ },
81
+ {
82
+ type: "assistant",
83
+ message: { content: [{ type: "text", text: "Hello world" }] },
84
+ },
85
+ { type: "result" },
86
+ ]);
87
+ mockQueryFn.mockReturnValue(iter);
88
+
89
+ const session = await provider.createSession({});
90
+ const events: ChatEvent[] = [];
91
+ for await (const event of provider.sendMessage(session.id, "hi")) {
92
+ events.push(event);
93
+ }
94
+
95
+ const textEvents = events.filter((e) => e.type === "text");
96
+ expect(textEvents.length).toBeGreaterThan(0);
97
+
98
+ const fullText = textEvents.map((e) => (e as any).content).join("");
99
+ expect(fullText).toContain("Hello");
100
+
101
+ const done = events.find((e) => e.type === "done");
102
+ expect(done).toBeTruthy();
103
+ });
104
+
105
+ it("yields tool_use events from assistant messages", async () => {
106
+ const iter = createMockQueryIterator([
107
+ {
108
+ type: "assistant",
109
+ message: {
110
+ content: [
111
+ { type: "tool_use", name: "Read", input: { path: "test.ts" } },
112
+ ],
113
+ },
114
+ },
115
+ { type: "result" },
116
+ ]);
117
+ mockQueryFn.mockReturnValue(iter);
118
+
119
+ const session = await provider.createSession({});
120
+ const events: ChatEvent[] = [];
121
+ for await (const event of provider.sendMessage(session.id, "read file")) {
122
+ events.push(event);
123
+ }
124
+
125
+ const toolUse = events.find((e) => e.type === "tool_use");
126
+ expect(toolUse).toBeTruthy();
127
+ expect((toolUse as any).tool).toBe("Read");
128
+ });
129
+
130
+ it("always yields done event even on empty response", async () => {
131
+ const iter = createMockQueryIterator([{ type: "result" }]);
132
+ mockQueryFn.mockReturnValue(iter);
133
+
134
+ const session = await provider.createSession({});
135
+ const events: ChatEvent[] = [];
136
+ for await (const event of provider.sendMessage(session.id, "hi")) {
137
+ events.push(event);
138
+ }
139
+
140
+ expect(events).toHaveLength(1);
141
+ expect(events[0]!.type).toBe("done");
142
+ });
143
+
144
+ it("yields done event after SDK error (non-abort)", async () => {
145
+ mockQueryFn.mockImplementation(() => {
146
+ const iter = createMockQueryIterator([], 0);
147
+ // Override next to throw
148
+ iter.next = async () => {
149
+ throw new Error("SDK connection failed");
150
+ };
151
+ return iter;
152
+ });
153
+
154
+ const session = await provider.createSession({});
155
+ const events: ChatEvent[] = [];
156
+ for await (const event of provider.sendMessage(session.id, "hi")) {
157
+ events.push(event);
158
+ }
159
+
160
+ const error = events.find((e) => e.type === "error");
161
+ expect(error).toBeTruthy();
162
+ expect((error as any).message).toContain("SDK connection failed");
163
+
164
+ const done = events.find((e) => e.type === "done");
165
+ expect(done).toBeTruthy();
166
+ });
167
+
168
+ it("uses sessionId for first message and resume for subsequent", async () => {
169
+ // First call
170
+ mockQueryFn.mockReturnValue(
171
+ createMockQueryIterator([{ type: "result" }]),
172
+ );
173
+
174
+ const session = await provider.createSession({});
175
+ const events1: ChatEvent[] = [];
176
+ for await (const event of provider.sendMessage(session.id, "first")) {
177
+ events1.push(event);
178
+ }
179
+
180
+ expect(mockQueryFn).toHaveBeenCalledTimes(1);
181
+ const firstCall = mockQueryFn.mock.calls[0]![0];
182
+ expect(firstCall.options.sessionId).toBe(session.id);
183
+ expect(firstCall.options.resume).toBeUndefined();
184
+
185
+ // Second call
186
+ mockQueryFn.mockReturnValue(
187
+ createMockQueryIterator([{ type: "result" }]),
188
+ );
189
+
190
+ const events2: ChatEvent[] = [];
191
+ for await (const event of provider.sendMessage(session.id, "second")) {
192
+ events2.push(event);
193
+ }
194
+
195
+ const secondCall = mockQueryFn.mock.calls[1]![0];
196
+ expect(secondCall.options.sessionId).toBeUndefined();
197
+ expect(secondCall.options.resume).toBe(session.id);
198
+ });
199
+ });
200
+
201
+ describe("abortQuery (cancel)", () => {
202
+ it("calls close() on active SDK query", async () => {
203
+ const iter = createMockQueryIterator(
204
+ [
205
+ {
206
+ type: "partial",
207
+ message: { content: [{ type: "text", text: "Working..." }] },
208
+ },
209
+ // Many more items that won't be reached after close
210
+ {
211
+ type: "partial",
212
+ message: {
213
+ content: [{ type: "text", text: "Working... still going" }],
214
+ },
215
+ },
216
+ {
217
+ type: "partial",
218
+ message: {
219
+ content: [
220
+ { type: "text", text: "Working... still going... more" },
221
+ ],
222
+ },
223
+ },
224
+ { type: "result" },
225
+ ],
226
+ 100, // slow enough to cancel mid-stream
227
+ );
228
+
229
+ const closeSpy = spyOn(iter, "close");
230
+ mockQueryFn.mockReturnValue(iter);
231
+
232
+ const session = await provider.createSession({});
233
+
234
+ // Start streaming in background
235
+ const events: ChatEvent[] = [];
236
+ const streamPromise = (async () => {
237
+ for await (const event of provider.sendMessage(session.id, "hello")) {
238
+ events.push(event);
239
+ }
240
+ })();
241
+
242
+ // Wait for first event
243
+ await new Promise((r) => setTimeout(r, 50));
244
+
245
+ // Cancel
246
+ provider.abortQuery(session.id);
247
+
248
+ expect(closeSpy).toHaveBeenCalledTimes(1);
249
+
250
+ // Wait for stream to finish
251
+ await streamPromise;
252
+
253
+ // Should have done event (always emitted)
254
+ const done = events.find((e) => e.type === "done");
255
+ expect(done).toBeTruthy();
256
+ });
257
+
258
+ it("does not yield error event on abort", async () => {
259
+ // Simulate SDK throwing abort error when query is closed
260
+ let rejectNext: ((err: Error) => void) | undefined;
261
+
262
+ mockQueryFn.mockImplementation(() => {
263
+ let callCount = 0;
264
+ const q = {
265
+ close() {
266
+ rejectNext?.(new Error("aborted"));
267
+ },
268
+ [Symbol.asyncIterator]() {
269
+ return this;
270
+ },
271
+ async next(): Promise<{ done: boolean; value: any }> {
272
+ callCount++;
273
+ if (callCount === 1) {
274
+ return {
275
+ done: false,
276
+ value: {
277
+ type: "partial",
278
+ message: { content: [{ type: "text", text: "Hi" }] },
279
+ },
280
+ };
281
+ }
282
+ // Second call: wait to be aborted
283
+ return new Promise((resolve, reject) => {
284
+ rejectNext = reject;
285
+ // Also resolve after timeout as fallback
286
+ setTimeout(
287
+ () => resolve({ done: true, value: undefined }),
288
+ 5000,
289
+ );
290
+ });
291
+ },
292
+ };
293
+ return q;
294
+ });
295
+
296
+ const session = await provider.createSession({});
297
+ const events: ChatEvent[] = [];
298
+ const streamPromise = (async () => {
299
+ for await (const event of provider.sendMessage(session.id, "test")) {
300
+ events.push(event);
301
+ }
302
+ })();
303
+
304
+ // Wait for first event
305
+ await new Promise((r) => setTimeout(r, 50));
306
+
307
+ // Cancel — should trigger abort error
308
+ provider.abortQuery(session.id);
309
+
310
+ await streamPromise;
311
+
312
+ // Should NOT have an error event (abort is intentional)
313
+ const errors = events.filter((e) => e.type === "error");
314
+ expect(errors).toHaveLength(0);
315
+
316
+ // Should still have done event
317
+ const done = events.find((e) => e.type === "done");
318
+ expect(done).toBeTruthy();
319
+ });
320
+
321
+ it("abortQuery is no-op when no active query", () => {
322
+ // Should not throw
323
+ expect(() => provider.abortQuery("nonexistent-session")).not.toThrow();
324
+ });
325
+
326
+ it("cleans up activeQueries after stream ends", async () => {
327
+ const iter = createMockQueryIterator([{ type: "result" }]);
328
+ mockQueryFn.mockReturnValue(iter);
329
+
330
+ const session = await provider.createSession({});
331
+ for await (const _ of provider.sendMessage(session.id, "hi")) {
332
+ // consume
333
+ }
334
+
335
+ // abortQuery should be no-op now (query already cleaned up)
336
+ expect(() => provider.abortQuery(session.id)).not.toThrow();
337
+ });
338
+ });
339
+ });
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { MockProvider } from "../../../src/providers/mock-provider.ts";
3
+
4
+ describe("MockProvider", () => {
5
+ it("creates a session with UUID and metadata", async () => {
6
+ const provider = new MockProvider();
7
+ const session = await provider.createSession({
8
+ projectName: "test-project",
9
+ title: "Test Chat",
10
+ });
11
+
12
+ expect(session.id).toBeTruthy();
13
+ expect(session.providerId).toBe("mock");
14
+ expect(session.title).toBe("Test Chat");
15
+ expect(session.projectName).toBe("test-project");
16
+ expect(session.createdAt).toBeTruthy();
17
+ });
18
+
19
+ it("resumes an existing session", async () => {
20
+ const provider = new MockProvider();
21
+ const session = await provider.createSession({ title: "Original" });
22
+ const resumed = await provider.resumeSession(session.id);
23
+
24
+ expect(resumed.id).toBe(session.id);
25
+ expect(resumed.title).toBe("Original");
26
+ });
27
+
28
+ it("throws on resume of non-existent session", async () => {
29
+ const provider = new MockProvider();
30
+ expect(provider.resumeSession("non-existent")).rejects.toThrow();
31
+ });
32
+
33
+ it("lists sessions", async () => {
34
+ const provider = new MockProvider();
35
+ await provider.createSession({ title: "Chat 1" });
36
+ await provider.createSession({ title: "Chat 2" });
37
+ const sessions = await provider.listSessions();
38
+
39
+ expect(sessions).toHaveLength(2);
40
+ expect(sessions.map((s) => s.title)).toContain("Chat 1");
41
+ expect(sessions.map((s) => s.title)).toContain("Chat 2");
42
+ });
43
+
44
+ it("deletes a session", async () => {
45
+ const provider = new MockProvider();
46
+ const session = await provider.createSession({ title: "Doomed" });
47
+ await provider.deleteSession(session.id);
48
+ const sessions = await provider.listSessions();
49
+
50
+ expect(sessions).toHaveLength(0);
51
+ });
52
+
53
+ it("streams text events for a simple message", async () => {
54
+ const provider = new MockProvider();
55
+ const session = await provider.createSession({});
56
+ const events: any[] = [];
57
+
58
+ for await (const event of provider.sendMessage(session.id, "hello")) {
59
+ events.push(event);
60
+ }
61
+
62
+ const textEvents = events.filter((e) => e.type === "text");
63
+ const doneEvents = events.filter((e) => e.type === "done");
64
+
65
+ expect(textEvents.length).toBeGreaterThan(0);
66
+ expect(doneEvents).toHaveLength(1);
67
+ expect(doneEvents[0].sessionId).toBe(session.id);
68
+ });
69
+
70
+ it("yields tool_use events for messages containing 'file'", async () => {
71
+ const provider = new MockProvider();
72
+ const session = await provider.createSession({});
73
+ const events: any[] = [];
74
+
75
+ for await (const event of provider.sendMessage(session.id, "read the file")) {
76
+ events.push(event);
77
+ }
78
+
79
+ const toolUse = events.find((e) => e.type === "tool_use");
80
+ const toolResult = events.find((e) => e.type === "tool_result");
81
+
82
+ expect(toolUse).toBeTruthy();
83
+ expect(toolUse.tool).toBe("Read");
84
+ expect(toolResult).toBeTruthy();
85
+ expect(toolResult.output).toContain("Main entry point");
86
+ });
87
+
88
+ it("yields approval_request for messages containing 'delete'", async () => {
89
+ const provider = new MockProvider();
90
+ const session = await provider.createSession({});
91
+ const events: any[] = [];
92
+
93
+ for await (const event of provider.sendMessage(session.id, "delete the temp files")) {
94
+ events.push(event);
95
+ }
96
+
97
+ const approval = events.find((e) => e.type === "approval_request");
98
+ expect(approval).toBeTruthy();
99
+ expect(approval.tool).toBe("Bash");
100
+ expect(approval.requestId).toBeTruthy();
101
+ });
102
+
103
+ it("updates session title from first message", async () => {
104
+ const provider = new MockProvider();
105
+ const session = await provider.createSession({});
106
+
107
+ for await (const _ of provider.sendMessage(session.id, "My first question about testing")) {
108
+ // consume
109
+ }
110
+
111
+ const sessions = await provider.listSessions();
112
+ const updated = sessions.find((s) => s.id === session.id);
113
+ expect(updated?.title).toContain("My first question");
114
+ });
115
+
116
+ it("stores message history", async () => {
117
+ const provider = new MockProvider();
118
+ const session = await provider.createSession({});
119
+
120
+ for await (const _ of provider.sendMessage(session.id, "hello")) {
121
+ // consume
122
+ }
123
+
124
+ const messages = provider.getMessages(session.id);
125
+ expect(messages).toHaveLength(2); // user + assistant
126
+ expect(messages[0].role).toBe("user");
127
+ expect(messages[0].content).toBe("hello");
128
+ expect(messages[1].role).toBe("assistant");
129
+ expect(messages[1].content).toBeTruthy();
130
+ });
131
+
132
+ it("yields error for non-existent session", async () => {
133
+ const provider = new MockProvider();
134
+ const events: any[] = [];
135
+
136
+ for await (const event of provider.sendMessage("bad-id", "hello")) {
137
+ events.push(event);
138
+ }
139
+
140
+ expect(events[0].type).toBe("error");
141
+ expect(events[0].message).toBe("Session not found");
142
+ });
143
+ });
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { chatService } from "../../../src/services/chat.service.ts";
3
+
4
+ describe("ChatService", () => {
5
+ it("creates session with default provider", async () => {
6
+ const session = await chatService.createSession(undefined, {
7
+ title: "Test",
8
+ });
9
+
10
+ expect(session.id).toBeTruthy();
11
+ expect(session.providerId).toBe("claude-sdk"); // default provider
12
+ expect(session.title).toBe("Test");
13
+ });
14
+
15
+ it("creates session with specific provider", async () => {
16
+ const session = await chatService.createSession("mock", {
17
+ title: "Mock Chat",
18
+ });
19
+
20
+ expect(session.providerId).toBe("mock");
21
+ expect(session.title).toBe("Mock Chat");
22
+ });
23
+
24
+ it("throws on unknown provider", async () => {
25
+ expect(
26
+ chatService.createSession("nonexistent", {}),
27
+ ).rejects.toThrow('Provider "nonexistent" not found');
28
+ });
29
+
30
+ it("lists sessions from all providers", async () => {
31
+ // Create sessions in mock provider
32
+ await chatService.createSession("mock", { title: "A" });
33
+ await chatService.createSession("mock", { title: "B" });
34
+
35
+ const all = await chatService.listSessions();
36
+ // Should include at least the mock sessions
37
+ const mockSessions = all.filter((s) => s.providerId === "mock");
38
+ expect(mockSessions.length).toBeGreaterThanOrEqual(2);
39
+ });
40
+
41
+ it("lists sessions filtered by provider", async () => {
42
+ await chatService.createSession("mock", { title: "Filtered" });
43
+ const sessions = await chatService.listSessions("mock");
44
+
45
+ expect(sessions.length).toBeGreaterThanOrEqual(1);
46
+ expect(sessions.every((s) => s.providerId === "mock")).toBe(true);
47
+ });
48
+
49
+ it("deletes session", async () => {
50
+ const session = await chatService.createSession("mock", { title: "Delete me" });
51
+ await chatService.deleteSession("mock", session.id);
52
+
53
+ const sessions = await chatService.listSessions("mock");
54
+ expect(sessions.find((s) => s.id === session.id)).toBeUndefined();
55
+ });
56
+
57
+ it("streams events from sendMessage", async () => {
58
+ const session = await chatService.createSession("mock", {});
59
+ const events: any[] = [];
60
+
61
+ for await (const event of chatService.sendMessage("mock", session.id, "hello world")) {
62
+ events.push(event);
63
+ }
64
+
65
+ expect(events.some((e) => e.type === "text")).toBe(true);
66
+ expect(events.some((e) => e.type === "done")).toBe(true);
67
+ });
68
+
69
+ it("returns error for unknown provider in sendMessage", async () => {
70
+ const events: any[] = [];
71
+
72
+ for await (const event of chatService.sendMessage("bad-provider", "id", "hello")) {
73
+ events.push(event);
74
+ }
75
+
76
+ expect(events[0].type).toBe("error");
77
+ expect(events[0].message).toContain("not found");
78
+ });
79
+
80
+ it("getSession finds session across providers", async () => {
81
+ const session = await chatService.createSession("mock", { title: "Findable" });
82
+ const found = chatService.getSession(session.id);
83
+
84
+ expect(found).not.toBeNull();
85
+ expect(found?.id).toBe(session.id);
86
+ });
87
+
88
+ it("getMessages returns history for mock provider", async () => {
89
+ const session = await chatService.createSession("mock", {});
90
+
91
+ for await (const _ of chatService.sendMessage("mock", session.id, "test msg")) {
92
+ // consume
93
+ }
94
+
95
+ const messages = await chatService.getMessages("mock", session.id);
96
+ expect(messages.length).toBeGreaterThanOrEqual(2); // user + assistant
97
+ expect(messages[0].role).toBe("user");
98
+ expect(messages[0].content).toBe("test msg");
99
+ });
100
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "jsx": "react-jsx",
8
+ "allowJs": true,
9
+
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "noEmit": true,
14
+
15
+ "strict": true,
16
+ "skipLibCheck": true,
17
+ "noFallthroughCasesInSwitch": true,
18
+ "noUncheckedIndexedAccess": true,
19
+ "noImplicitOverride": true,
20
+
21
+ "noUnusedLocals": false,
22
+ "noUnusedParameters": false,
23
+ "noPropertyAccessFromIndexSignature": false,
24
+
25
+ "baseUrl": ".",
26
+ "paths": {
27
+ "@/*": ["./src/web/*"]
28
+ }
29
+ },
30
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
31
+ "exclude": ["node_modules", "dist"]
32
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { defineConfig } from "vite";
2
+ import react from "@vitejs/plugin-react";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+ import { VitePWA } from "vite-plugin-pwa";
5
+ import { resolve } from "path";
6
+
7
+ export default defineConfig({
8
+ plugins: [
9
+ react(),
10
+ tailwindcss(),
11
+ VitePWA({
12
+ registerType: "autoUpdate",
13
+ manifest: {
14
+ name: "PPM — Personal Project Manager",
15
+ short_name: "PPM",
16
+ description: "Mobile-first web IDE for managing code projects",
17
+ theme_color: "#0f1419",
18
+ background_color: "#0f1419",
19
+ display: "standalone",
20
+ orientation: "any",
21
+ icons: [
22
+ { src: "/icon-192.png", sizes: "192x192", type: "image/png" },
23
+ { src: "/icon-512.png", sizes: "512x512", type: "image/png" },
24
+ ],
25
+ },
26
+ workbox: {
27
+ globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
28
+ runtimeCaching: [
29
+ {
30
+ urlPattern: /^https?:\/\/.*\/api\//,
31
+ handler: "NetworkOnly",
32
+ },
33
+ {
34
+ urlPattern: /^https?:\/\/.*\/ws\//,
35
+ handler: "NetworkOnly",
36
+ },
37
+ ],
38
+ },
39
+ }),
40
+ ],
41
+ root: "src/web",
42
+ resolve: {
43
+ alias: {
44
+ "@": resolve(__dirname, "src/web"),
45
+ },
46
+ },
47
+ build: {
48
+ outDir: resolve(__dirname, "dist/web"),
49
+ emptyOutDir: true,
50
+ },
51
+ server: {
52
+ host: true,
53
+ port: 5173,
54
+ proxy: {
55
+ "/api": "http://localhost:8080",
56
+ "/ws": {
57
+ target: "ws://localhost:8080",
58
+ ws: true,
59
+ },
60
+ },
61
+ },
62
+ });