@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,50 @@
1
+ import { create } from "zustand";
2
+ import { api } from "@/lib/api-client";
3
+
4
+ export interface Project {
5
+ name: string;
6
+ path: string;
7
+ }
8
+
9
+ export interface ProjectInfo extends Project {
10
+ branch?: string;
11
+ status?: "clean" | "dirty";
12
+ }
13
+
14
+ interface ProjectStore {
15
+ projects: ProjectInfo[];
16
+ activeProject: ProjectInfo | null;
17
+ loading: boolean;
18
+ error: string | null;
19
+ fetchProjects: () => Promise<void>;
20
+ setActiveProject: (project: ProjectInfo) => void;
21
+ }
22
+
23
+ export const useProjectStore = create<ProjectStore>((set) => ({
24
+ projects: [],
25
+ activeProject: null,
26
+ loading: false,
27
+ error: null,
28
+
29
+ fetchProjects: async () => {
30
+ set({ loading: true, error: null });
31
+ try {
32
+ const projects = await api.get<ProjectInfo[]>("/api/projects");
33
+ set({ projects, loading: false });
34
+ // Auto-select first project if none active
35
+ set((s) => {
36
+ if (!s.activeProject && projects.length > 0) {
37
+ return { activeProject: projects[0] };
38
+ }
39
+ return {};
40
+ });
41
+ } catch (err) {
42
+ set({
43
+ error: err instanceof Error ? err.message : "Failed to fetch projects",
44
+ loading: false,
45
+ });
46
+ }
47
+ },
48
+
49
+ setActiveProject: (project) => set({ activeProject: project }),
50
+ }));
@@ -0,0 +1,65 @@
1
+ import { create } from "zustand";
2
+
3
+ export type Theme = "light" | "dark" | "system";
4
+
5
+ const STORAGE_KEY = "ppm-settings";
6
+
7
+ interface SettingsState {
8
+ theme: Theme;
9
+ setTheme: (theme: Theme) => void;
10
+ }
11
+
12
+ function loadPersistedTheme(): Theme {
13
+ try {
14
+ const stored = localStorage.getItem(STORAGE_KEY);
15
+ if (stored) {
16
+ const parsed = JSON.parse(stored) as { theme?: Theme };
17
+ if (
18
+ parsed.theme === "light" ||
19
+ parsed.theme === "dark" ||
20
+ parsed.theme === "system"
21
+ ) {
22
+ return parsed.theme;
23
+ }
24
+ }
25
+ } catch {
26
+ // ignore
27
+ }
28
+ return "dark";
29
+ }
30
+
31
+ function persistTheme(theme: Theme) {
32
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ theme }));
33
+ }
34
+
35
+ /** Apply the resolved theme class to <html> */
36
+ export function applyThemeClass(theme: Theme) {
37
+ const resolved =
38
+ theme === "system"
39
+ ? window.matchMedia("(prefers-color-scheme: dark)").matches
40
+ ? "dark"
41
+ : "light"
42
+ : theme;
43
+
44
+ document.documentElement.classList.toggle("dark", resolved === "dark");
45
+ document.documentElement.classList.toggle("light", resolved === "light");
46
+
47
+ // Update theme-color meta tag
48
+ const metaThemeColor = document.querySelector('meta[name="theme-color"]');
49
+ if (metaThemeColor) {
50
+ metaThemeColor.setAttribute(
51
+ "content",
52
+ resolved === "dark" ? "#0f1419" : "#ffffff",
53
+ );
54
+ }
55
+ }
56
+
57
+ export const useSettingsStore = create<SettingsState>((set) => ({
58
+ theme: loadPersistedTheme(),
59
+
60
+ setTheme: (theme) => {
61
+ persistTheme(theme);
62
+ applyThemeClass(theme);
63
+ set({ theme });
64
+ },
65
+ }));
@@ -0,0 +1,187 @@
1
+ import { create } from "zustand";
2
+
3
+ export type TabType =
4
+ | "projects"
5
+ | "terminal"
6
+ | "chat"
7
+ | "editor"
8
+ | "git-graph"
9
+ | "git-status"
10
+ | "git-diff"
11
+ | "settings";
12
+
13
+ /** Tab types that can only have 1 instance per project */
14
+ const SINGLETON_TYPES = new Set<TabType>([
15
+ "git-status",
16
+ "git-graph",
17
+ "settings",
18
+ "projects",
19
+ ]);
20
+
21
+ export interface Tab {
22
+ id: string;
23
+ type: TabType;
24
+ title: string;
25
+ projectId: string | null;
26
+ metadata?: Record<string, unknown>;
27
+ closable: boolean;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // localStorage helpers
32
+ // ---------------------------------------------------------------------------
33
+ const STORAGE_PREFIX = "ppm-tabs-";
34
+
35
+ function storageKey(projectName: string): string {
36
+ return `${STORAGE_PREFIX}${projectName}`;
37
+ }
38
+
39
+ interface PersistedTabState {
40
+ tabs: Tab[];
41
+ activeTabId: string | null;
42
+ }
43
+
44
+ function loadTabs(projectName: string): PersistedTabState {
45
+ try {
46
+ const raw = localStorage.getItem(storageKey(projectName));
47
+ if (raw) return JSON.parse(raw) as PersistedTabState;
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ return { tabs: [], activeTabId: null };
52
+ }
53
+
54
+ function saveTabs(projectName: string, state: PersistedTabState) {
55
+ try {
56
+ localStorage.setItem(storageKey(projectName), JSON.stringify(state));
57
+ } catch {
58
+ /* ignore */
59
+ }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Unique ID generator — uses crypto.randomUUID to avoid cross-project collisions
64
+ // ---------------------------------------------------------------------------
65
+ function generateTabId(): string {
66
+ return `tab-${crypto.randomUUID().slice(0, 8)}`;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Store interface
71
+ // ---------------------------------------------------------------------------
72
+ interface TabStore {
73
+ tabs: Tab[];
74
+ activeTabId: string | null;
75
+ currentProject: string | null;
76
+ switchProject: (projectName: string) => void;
77
+ openTab: (tab: Omit<Tab, "id">) => string;
78
+ closeTab: (id: string) => void;
79
+ setActiveTab: (id: string) => void;
80
+ updateTab: (id: string, updates: Partial<Omit<Tab, "id">>) => void;
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Store
85
+ // ---------------------------------------------------------------------------
86
+ export const useTabStore = create<TabStore>()((set, get) => ({
87
+ tabs: [],
88
+ activeTabId: null,
89
+ currentProject: null,
90
+
91
+ switchProject: (projectName: string) => {
92
+ const { currentProject, tabs, activeTabId } = get();
93
+
94
+ // Save current project's tabs first
95
+ if (currentProject) {
96
+ saveTabs(currentProject, { tabs, activeTabId });
97
+ }
98
+
99
+ // Load new project's tabs
100
+ const loaded = loadTabs(projectName);
101
+
102
+ // If no tabs, open default "Projects" tab
103
+ if (loaded.tabs.length === 0) {
104
+ const defaultId = generateTabId();
105
+ const defaultTab: Tab = {
106
+ id: defaultId,
107
+ type: "projects",
108
+ title: "Projects",
109
+ projectId: null,
110
+ closable: false,
111
+ };
112
+ const newState = { tabs: [defaultTab], activeTabId: defaultId };
113
+ saveTabs(projectName, newState);
114
+ set({ currentProject: projectName, ...newState });
115
+ } else {
116
+ set({
117
+ currentProject: projectName,
118
+ tabs: loaded.tabs,
119
+ activeTabId: loaded.activeTabId,
120
+ });
121
+ }
122
+ },
123
+
124
+ openTab: (tabDef) => {
125
+ const { currentProject } = get();
126
+
127
+ // Singleton: only 1 instance per project (match type + projectId)
128
+ if (SINGLETON_TYPES.has(tabDef.type)) {
129
+ const existing = get().tabs.find(
130
+ (t) => t.type === tabDef.type && t.projectId === tabDef.projectId,
131
+ );
132
+ if (existing) {
133
+ set({ activeTabId: existing.id });
134
+ if (currentProject) {
135
+ saveTabs(currentProject, { tabs: get().tabs, activeTabId: existing.id });
136
+ }
137
+ return existing.id;
138
+ }
139
+ }
140
+
141
+ const id = generateTabId();
142
+ const tab: Tab = { ...tabDef, id };
143
+ set((s) => {
144
+ const newTabs = [...s.tabs, tab];
145
+ if (s.currentProject) {
146
+ saveTabs(s.currentProject, { tabs: newTabs, activeTabId: id });
147
+ }
148
+ return { tabs: newTabs, activeTabId: id };
149
+ });
150
+ return id;
151
+ },
152
+
153
+ closeTab: (id) => {
154
+ set((s) => {
155
+ const idx = s.tabs.findIndex((t) => t.id === id);
156
+ const newTabs = s.tabs.filter((t) => t.id !== id);
157
+ let newActive = s.activeTabId;
158
+ if (s.activeTabId === id) {
159
+ const nextIdx = Math.min(idx, newTabs.length - 1);
160
+ newActive = newTabs[nextIdx]?.id ?? null;
161
+ }
162
+ if (s.currentProject) {
163
+ saveTabs(s.currentProject, { tabs: newTabs, activeTabId: newActive });
164
+ }
165
+ return { tabs: newTabs, activeTabId: newActive };
166
+ });
167
+ },
168
+
169
+ setActiveTab: (id) => {
170
+ set((s) => {
171
+ if (s.currentProject) {
172
+ saveTabs(s.currentProject, { tabs: s.tabs, activeTabId: id });
173
+ }
174
+ return { activeTabId: id };
175
+ });
176
+ },
177
+
178
+ updateTab: (id, updates) => {
179
+ set((s) => {
180
+ const newTabs = s.tabs.map((t) => (t.id === id ? { ...t, ...updates } : t));
181
+ if (s.currentProject) {
182
+ saveTabs(s.currentProject, { tabs: newTabs, activeTabId: s.activeTabId });
183
+ }
184
+ return { tabs: newTabs };
185
+ });
186
+ },
187
+ }));
@@ -0,0 +1,227 @@
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ /* Dark theme colors (default) */
5
+ --color-background: #0f1419;
6
+ --color-foreground: #e5e7eb;
7
+ --color-surface: #1a1f2e;
8
+ --color-surface-elevated: #252d3d;
9
+
10
+ --color-card: #1a1f2e;
11
+ --color-card-foreground: #e5e7eb;
12
+
13
+ --color-popover: #1a1f2e;
14
+ --color-popover-foreground: #e5e7eb;
15
+
16
+ --color-primary: #3b82f6;
17
+ --color-primary-foreground: #ffffff;
18
+
19
+ --color-secondary: #252d3d;
20
+ --color-secondary-foreground: #e5e7eb;
21
+
22
+ --color-muted: #1a1f2e;
23
+ --color-muted-foreground: #9ca3af;
24
+
25
+ --color-accent: #252d3d;
26
+ --color-accent-foreground: #e5e7eb;
27
+
28
+ --color-destructive: #ef4444;
29
+ --color-destructive-foreground: #ffffff;
30
+
31
+ --color-border: #404854;
32
+ --color-input: #404854;
33
+ --color-ring: #3b82f6;
34
+
35
+ --color-success: #10b981;
36
+ --color-warning: #f59e0b;
37
+ --color-error: #ef4444;
38
+ --color-info: #06b6d4;
39
+
40
+ --color-text-primary: #e5e7eb;
41
+ --color-text-secondary: #9ca3af;
42
+ --color-text-subtle: #6b7280;
43
+
44
+ --font-sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI",
45
+ "Helvetica Neue", sans-serif;
46
+ --font-mono: "Geist Mono", "Monaco", "Courier New", monospace;
47
+
48
+ --radius-sm: 6px;
49
+ --radius-md: 8px;
50
+ --radius-lg: 12px;
51
+ }
52
+
53
+ /* Light theme overrides */
54
+ .light {
55
+ --color-background: #ffffff;
56
+ --color-foreground: #1a1f2e;
57
+ --color-surface: #f8fafc;
58
+ --color-surface-elevated: #f1f5f9;
59
+
60
+ --color-card: #f8fafc;
61
+ --color-card-foreground: #1a1f2e;
62
+
63
+ --color-popover: #ffffff;
64
+ --color-popover-foreground: #1a1f2e;
65
+
66
+ --color-primary: #2563eb;
67
+ --color-primary-foreground: #ffffff;
68
+
69
+ --color-secondary: #f1f5f9;
70
+ --color-secondary-foreground: #1a1f2e;
71
+
72
+ --color-muted: #f8fafc;
73
+ --color-muted-foreground: #64748b;
74
+
75
+ --color-accent: #f1f5f9;
76
+ --color-accent-foreground: #1a1f2e;
77
+
78
+ --color-destructive: #dc2626;
79
+ --color-destructive-foreground: #ffffff;
80
+
81
+ --color-border: #e2e8f0;
82
+ --color-input: #e2e8f0;
83
+ --color-ring: #2563eb;
84
+
85
+ --color-success: #059669;
86
+ --color-warning: #d97706;
87
+ --color-error: #dc2626;
88
+ --color-info: #0891b2;
89
+
90
+ --color-text-primary: #1a1f2e;
91
+ --color-text-secondary: #64748b;
92
+ --color-text-subtle: #94a3b8;
93
+ }
94
+
95
+ /* Scrollbar styling */
96
+ ::-webkit-scrollbar {
97
+ width: 8px;
98
+ height: 8px;
99
+ }
100
+
101
+ ::-webkit-scrollbar-track {
102
+ background: transparent;
103
+ }
104
+
105
+ ::-webkit-scrollbar-thumb {
106
+ background: var(--color-border);
107
+ border-radius: 4px;
108
+ }
109
+
110
+ ::-webkit-scrollbar-thumb:hover {
111
+ background: #525c6a;
112
+ }
113
+
114
+ /* Focus ring */
115
+ :focus-visible {
116
+ outline: 2px solid var(--color-ring);
117
+ outline-offset: 2px;
118
+ }
119
+
120
+ /* Prevent overscroll bounce on iOS */
121
+ html,
122
+ body {
123
+ overscroll-behavior: none;
124
+ }
125
+
126
+ /* Safe area padding for notched devices */
127
+ body {
128
+ padding-bottom: env(safe-area-inset-bottom);
129
+ }
130
+
131
+ /* Diff viewer: background highlight instead of underline for changed chars */
132
+ [class*="cm-changedText"] {
133
+ text-decoration: none !important;
134
+ text-decoration-line: none !important;
135
+ border-bottom: none !important;
136
+ outline: none !important;
137
+ background-image: none !important;
138
+ }
139
+
140
+ /* Markdown content styling */
141
+ .markdown-content p {
142
+ margin: 0.25rem 0;
143
+ white-space: pre-wrap;
144
+ word-break: break-word;
145
+ }
146
+ .markdown-content p:first-child { margin-top: 0; }
147
+ .markdown-content p:last-child { margin-bottom: 0; }
148
+
149
+ .markdown-content h1 { font-size: 1rem; font-weight: 700; margin: 0.75rem 0 0.25rem; }
150
+ .markdown-content h2 { font-size: 0.875rem; font-weight: 700; margin: 0.5rem 0 0.25rem; }
151
+ .markdown-content h3 { font-size: 0.875rem; font-weight: 600; margin: 0.5rem 0 0.125rem; }
152
+
153
+ .markdown-content pre {
154
+ overflow-x: auto;
155
+ border-radius: 6px;
156
+ background: var(--color-background);
157
+ padding: 0.5rem;
158
+ font-size: 0.75rem;
159
+ font-family: var(--font-mono);
160
+ border: 1px solid var(--color-border);
161
+ margin: 0.5rem 0;
162
+ }
163
+
164
+ .markdown-content code {
165
+ font-family: var(--font-mono);
166
+ font-size: 0.75rem;
167
+ }
168
+
169
+ .markdown-content :not(pre) > code {
170
+ background: var(--color-background);
171
+ padding: 0.125rem 0.25rem;
172
+ border-radius: 4px;
173
+ border: 1px solid var(--color-border);
174
+ }
175
+
176
+ .markdown-content a {
177
+ color: var(--color-primary);
178
+ text-decoration: none;
179
+ }
180
+ .markdown-content a:hover { text-decoration: underline; }
181
+
182
+ .markdown-content ul { list-style-type: disc; padding-left: 1rem; margin: 0.25rem 0; }
183
+ .markdown-content ol { list-style-type: decimal; padding-left: 1rem; margin: 0.25rem 0; }
184
+ .markdown-content li { margin: 0.125rem 0; }
185
+
186
+ .markdown-content blockquote {
187
+ border-left: 2px solid var(--color-border);
188
+ padding-left: 0.75rem;
189
+ margin: 0.25rem 0;
190
+ color: var(--color-text-secondary);
191
+ font-style: italic;
192
+ }
193
+
194
+ .markdown-content table {
195
+ width: 100%;
196
+ font-size: 0.75rem;
197
+ border-collapse: collapse;
198
+ margin: 0.5rem 0;
199
+ }
200
+ .markdown-content th, .markdown-content td {
201
+ border: 1px solid var(--color-border);
202
+ padding: 0.25rem 0.5rem;
203
+ text-align: left;
204
+ }
205
+ .markdown-content th {
206
+ background: var(--color-background);
207
+ font-weight: 500;
208
+ }
209
+
210
+ .markdown-content hr {
211
+ border: none;
212
+ border-top: 1px solid var(--color-border);
213
+ margin: 0.5rem 0;
214
+ }
215
+
216
+ .markdown-content strong { font-weight: 600; }
217
+ .markdown-content em { font-style: italic; }
218
+
219
+ /* Streaming cursor blink animation */
220
+ @keyframes blink {
221
+ 0%, 100% { opacity: 1; }
222
+ 50% { opacity: 0; }
223
+ }
224
+
225
+ .animate-blink {
226
+ animation: blink 0.8s ease-in-out infinite;
227
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import "../../test-setup.ts"; // disable auth
3
+ import { app } from "../../../src/server/index.ts";
4
+
5
+ async function req(path: string, init?: RequestInit) {
6
+ const url = `http://localhost${path}`;
7
+ const headers = new Headers(init?.headers);
8
+ headers.set("Content-Type", "application/json");
9
+ return app.request(new Request(url, { ...init, headers }));
10
+ }
11
+
12
+ describe("Chat REST API", () => {
13
+ it("GET /api/chat/providers lists available providers", async () => {
14
+ const res = await req("/api/chat/providers");
15
+ const json = await res.json() as any;
16
+
17
+ expect(json.ok).toBe(true);
18
+ expect(Array.isArray(json.data)).toBe(true);
19
+ const ids = json.data.map((p: any) => p.id);
20
+ expect(ids).toContain("claude-sdk");
21
+ expect(ids).toContain("mock");
22
+ });
23
+
24
+ it("POST /api/chat/sessions creates a session", async () => {
25
+ const res = await req("/api/chat/sessions", {
26
+ method: "POST",
27
+ body: JSON.stringify({ providerId: "mock", projectName: "test" }),
28
+ });
29
+ const json = await res.json() as any;
30
+
31
+ expect(res.status).toBe(201);
32
+ expect(json.ok).toBe(true);
33
+ expect(json.data.id).toBeTruthy();
34
+ expect(json.data.providerId).toBe("mock");
35
+ expect(json.data.projectName).toBe("test");
36
+ });
37
+
38
+ it("GET /api/chat/sessions lists sessions", async () => {
39
+ await req("/api/chat/sessions", {
40
+ method: "POST",
41
+ body: JSON.stringify({ providerId: "mock", title: "Listed" }),
42
+ });
43
+
44
+ const res = await req("/api/chat/sessions?providerId=mock");
45
+ const json = await res.json() as any;
46
+
47
+ expect(json.ok).toBe(true);
48
+ expect(Array.isArray(json.data)).toBe(true);
49
+ expect(json.data.length).toBeGreaterThanOrEqual(1);
50
+ });
51
+
52
+ it("GET /api/chat/sessions?providerId=mock filters by provider", async () => {
53
+ const res = await req("/api/chat/sessions?providerId=mock");
54
+ const json = await res.json() as any;
55
+
56
+ expect(json.ok).toBe(true);
57
+ expect(json.data.every((s: any) => s.providerId === "mock")).toBe(true);
58
+ });
59
+
60
+ it("GET /api/chat/sessions/:id/messages returns history", async () => {
61
+ const createRes = await req("/api/chat/sessions", {
62
+ method: "POST",
63
+ body: JSON.stringify({ providerId: "mock" }),
64
+ });
65
+ const { data: session } = await createRes.json() as any;
66
+
67
+ const msgRes = await req(`/api/chat/sessions/${session.id}/messages?providerId=mock`);
68
+ const json = await msgRes.json() as any;
69
+
70
+ expect(json.ok).toBe(true);
71
+ expect(Array.isArray(json.data)).toBe(true);
72
+ });
73
+
74
+ it("DELETE /api/chat/sessions/:id deletes a session", async () => {
75
+ const createRes = await req("/api/chat/sessions", {
76
+ method: "POST",
77
+ body: JSON.stringify({ providerId: "mock", title: "To delete" }),
78
+ });
79
+ const { data: session } = await createRes.json() as any;
80
+
81
+ const delRes = await req(`/api/chat/sessions/${session.id}?providerId=mock`, {
82
+ method: "DELETE",
83
+ });
84
+ const json = await delRes.json() as any;
85
+
86
+ expect(json.ok).toBe(true);
87
+ expect(json.data.deleted).toBe(session.id);
88
+ });
89
+
90
+ it("health endpoint works without auth", async () => {
91
+ const res = await app.request(new Request("http://localhost/api/health"));
92
+ const json = await res.json() as any;
93
+ expect(json.ok).toBe(true);
94
+ });
95
+ });