prjct-cli 0.11.0 → 0.11.1

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 (80) hide show
  1. package/package.json +11 -1
  2. package/packages/shared/dist/index.d.ts +615 -0
  3. package/packages/shared/dist/index.js +204 -0
  4. package/packages/shared/package.json +29 -0
  5. package/packages/shared/src/index.ts +9 -0
  6. package/packages/shared/src/schemas.ts +124 -0
  7. package/packages/shared/src/types.ts +187 -0
  8. package/packages/shared/src/utils.ts +148 -0
  9. package/packages/shared/tsconfig.json +18 -0
  10. package/packages/web/README.md +36 -0
  11. package/packages/web/app/api/claude/sessions/route.ts +44 -0
  12. package/packages/web/app/api/claude/status/route.ts +34 -0
  13. package/packages/web/app/api/projects/[id]/delete/route.ts +21 -0
  14. package/packages/web/app/api/projects/[id]/icon/route.ts +33 -0
  15. package/packages/web/app/api/projects/[id]/route.ts +29 -0
  16. package/packages/web/app/api/projects/[id]/stats/route.ts +36 -0
  17. package/packages/web/app/api/projects/[id]/status/route.ts +21 -0
  18. package/packages/web/app/api/projects/route.ts +16 -0
  19. package/packages/web/app/api/sessions/history/route.ts +122 -0
  20. package/packages/web/app/api/stats/route.ts +38 -0
  21. package/packages/web/app/error.tsx +34 -0
  22. package/packages/web/app/favicon.ico +0 -0
  23. package/packages/web/app/globals.css +155 -0
  24. package/packages/web/app/layout.tsx +43 -0
  25. package/packages/web/app/loading.tsx +7 -0
  26. package/packages/web/app/not-found.tsx +25 -0
  27. package/packages/web/app/page.tsx +227 -0
  28. package/packages/web/app/project/[id]/error.tsx +41 -0
  29. package/packages/web/app/project/[id]/loading.tsx +9 -0
  30. package/packages/web/app/project/[id]/not-found.tsx +27 -0
  31. package/packages/web/app/project/[id]/page.tsx +253 -0
  32. package/packages/web/app/project/[id]/stats/page.tsx +447 -0
  33. package/packages/web/app/sessions/page.tsx +165 -0
  34. package/packages/web/app/settings/page.tsx +150 -0
  35. package/packages/web/components/AppSidebar.tsx +113 -0
  36. package/packages/web/components/CommandButton.tsx +39 -0
  37. package/packages/web/components/ConnectionStatus.tsx +29 -0
  38. package/packages/web/components/Logo.tsx +65 -0
  39. package/packages/web/components/MarkdownContent.tsx +123 -0
  40. package/packages/web/components/ProjectAvatar.tsx +54 -0
  41. package/packages/web/components/TechStackBadges.tsx +20 -0
  42. package/packages/web/components/TerminalTab.tsx +84 -0
  43. package/packages/web/components/TerminalTabs.tsx +210 -0
  44. package/packages/web/components/charts/SessionsChart.tsx +172 -0
  45. package/packages/web/components/providers.tsx +45 -0
  46. package/packages/web/components/ui/alert-dialog.tsx +157 -0
  47. package/packages/web/components/ui/badge.tsx +46 -0
  48. package/packages/web/components/ui/button.tsx +60 -0
  49. package/packages/web/components/ui/card.tsx +92 -0
  50. package/packages/web/components/ui/chart.tsx +385 -0
  51. package/packages/web/components/ui/dropdown-menu.tsx +257 -0
  52. package/packages/web/components/ui/scroll-area.tsx +58 -0
  53. package/packages/web/components/ui/sheet.tsx +139 -0
  54. package/packages/web/components/ui/tabs.tsx +66 -0
  55. package/packages/web/components/ui/tooltip.tsx +61 -0
  56. package/packages/web/components.json +22 -0
  57. package/packages/web/context/TerminalContext.tsx +45 -0
  58. package/packages/web/context/TerminalTabsContext.tsx +136 -0
  59. package/packages/web/eslint.config.mjs +18 -0
  60. package/packages/web/hooks/useClaudeTerminal.ts +375 -0
  61. package/packages/web/hooks/useProjectStats.ts +38 -0
  62. package/packages/web/hooks/useProjects.ts +73 -0
  63. package/packages/web/hooks/useStats.ts +28 -0
  64. package/packages/web/lib/format.ts +23 -0
  65. package/packages/web/lib/parse-prjct-files.ts +1122 -0
  66. package/packages/web/lib/projects.ts +452 -0
  67. package/packages/web/lib/pty.ts +101 -0
  68. package/packages/web/lib/query-config.ts +44 -0
  69. package/packages/web/lib/utils.ts +6 -0
  70. package/packages/web/next-env.d.ts +6 -0
  71. package/packages/web/next.config.ts +7 -0
  72. package/packages/web/package.json +53 -0
  73. package/packages/web/postcss.config.mjs +7 -0
  74. package/packages/web/public/file.svg +1 -0
  75. package/packages/web/public/globe.svg +1 -0
  76. package/packages/web/public/next.svg +1 -0
  77. package/packages/web/public/vercel.svg +1 -0
  78. package/packages/web/public/window.svg +1 -0
  79. package/packages/web/server.ts +262 -0
  80. package/packages/web/tsconfig.json +34 -0
@@ -0,0 +1,136 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Terminal Tabs Context - Manage multiple terminal sessions
5
+ */
6
+
7
+ import { createContext, useContext, useCallback, useState, useRef, ReactNode } from 'react'
8
+
9
+ export interface TerminalSession {
10
+ id: string
11
+ createdAt: Date
12
+ isConnected: boolean
13
+ isLoading: boolean
14
+ label: string
15
+ }
16
+
17
+ interface TerminalTabsContextType {
18
+ sessions: TerminalSession[]
19
+ activeSessionId: string | null
20
+ createSession: () => string
21
+ closeSession: (sessionId: string) => void
22
+ setActiveSession: (sessionId: string) => void
23
+ updateSession: (sessionId: string, updates: Partial<TerminalSession>) => void
24
+ getActiveSession: () => TerminalSession | null
25
+ sendCommandToActive: (command: string) => void
26
+ registerSendInput: (sessionId: string, fn: (data: string) => void) => void
27
+ registerFocusTerminal: (sessionId: string, fn: () => void) => void
28
+ focusActiveTerminal: () => void
29
+ }
30
+
31
+ const TerminalTabsContext = createContext<TerminalTabsContextType | null>(null)
32
+
33
+ let sessionCounter = 0
34
+
35
+ export function TerminalTabsProvider({ children, projectId }: { children: ReactNode; projectId: string }) {
36
+ const [sessions, setSessions] = useState<TerminalSession[]>([])
37
+ const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
38
+ const sendInputRefs = useRef<Map<string, (data: string) => void>>(new Map())
39
+ const focusTerminalRefs = useRef<Map<string, () => void>>(new Map())
40
+
41
+ const createSession = useCallback(() => {
42
+ sessionCounter++
43
+ const newSession: TerminalSession = {
44
+ id: `pty_${projectId}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
45
+ createdAt: new Date(),
46
+ isConnected: false,
47
+ isLoading: true,
48
+ label: `Terminal ${sessionCounter}`
49
+ }
50
+
51
+ setSessions(prev => [...prev, newSession])
52
+ setActiveSessionId(newSession.id)
53
+ return newSession.id
54
+ }, [projectId])
55
+
56
+ const closeSession = useCallback((sessionId: string) => {
57
+ setSessions(prev => {
58
+ const filtered = prev.filter(s => s.id !== sessionId)
59
+ // If closing active session, switch to last remaining
60
+ if (sessionId === activeSessionId && filtered.length > 0) {
61
+ setActiveSessionId(filtered[filtered.length - 1].id)
62
+ } else if (filtered.length === 0) {
63
+ setActiveSessionId(null)
64
+ }
65
+ return filtered
66
+ })
67
+ sendInputRefs.current.delete(sessionId)
68
+ focusTerminalRefs.current.delete(sessionId)
69
+ }, [activeSessionId])
70
+
71
+ const setActiveSession = useCallback((sessionId: string) => {
72
+ setActiveSessionId(sessionId)
73
+ }, [])
74
+
75
+ const updateSession = useCallback((sessionId: string, updates: Partial<TerminalSession>) => {
76
+ setSessions(prev => prev.map(s =>
77
+ s.id === sessionId ? { ...s, ...updates } : s
78
+ ))
79
+ }, [])
80
+
81
+ const getActiveSession = useCallback(() => {
82
+ return sessions.find(s => s.id === activeSessionId) || null
83
+ }, [sessions, activeSessionId])
84
+
85
+ const registerSendInput = useCallback((sessionId: string, fn: (data: string) => void) => {
86
+ sendInputRefs.current.set(sessionId, fn)
87
+ }, [])
88
+
89
+ const registerFocusTerminal = useCallback((sessionId: string, fn: () => void) => {
90
+ focusTerminalRefs.current.set(sessionId, fn)
91
+ }, [])
92
+
93
+ const focusActiveTerminal = useCallback(() => {
94
+ if (!activeSessionId) return
95
+ const focusFn = focusTerminalRefs.current.get(activeSessionId)
96
+ if (focusFn) focusFn()
97
+ }, [activeSessionId])
98
+
99
+ const sendCommandToActive = useCallback((command: string) => {
100
+ if (!activeSessionId) return
101
+ const sendFn = sendInputRefs.current.get(activeSessionId)
102
+ const session = sessions.find(s => s.id === activeSessionId)
103
+ if (sendFn && session?.isConnected) {
104
+ sendFn(command + '\n')
105
+ // Auto-focus terminal after sending command
106
+ const focusFn = focusTerminalRefs.current.get(activeSessionId)
107
+ if (focusFn) focusFn()
108
+ }
109
+ }, [activeSessionId, sessions])
110
+
111
+ return (
112
+ <TerminalTabsContext.Provider value={{
113
+ sessions,
114
+ activeSessionId,
115
+ createSession,
116
+ closeSession,
117
+ setActiveSession,
118
+ updateSession,
119
+ getActiveSession,
120
+ sendCommandToActive,
121
+ registerSendInput,
122
+ registerFocusTerminal,
123
+ focusActiveTerminal
124
+ }}>
125
+ {children}
126
+ </TerminalTabsContext.Provider>
127
+ )
128
+ }
129
+
130
+ export function useTerminalTabs() {
131
+ const context = useContext(TerminalTabsContext)
132
+ if (!context) {
133
+ throw new Error('useTerminalTabs must be used within TerminalTabsProvider')
134
+ }
135
+ return context
136
+ }
@@ -0,0 +1,18 @@
1
+ import { defineConfig, globalIgnores } from "eslint/config";
2
+ import nextVitals from "eslint-config-next/core-web-vitals";
3
+ import nextTs from "eslint-config-next/typescript";
4
+
5
+ const eslintConfig = defineConfig([
6
+ ...nextVitals,
7
+ ...nextTs,
8
+ // Override default ignores of eslint-config-next.
9
+ globalIgnores([
10
+ // Default ignores of eslint-config-next:
11
+ ".next/**",
12
+ "out/**",
13
+ "build/**",
14
+ "next-env.d.ts",
15
+ ]),
16
+ ]);
17
+
18
+ export default eslintConfig;
@@ -0,0 +1,375 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * useClaudeTerminal - WebSocket connection to Claude Code CLI via PTY
5
+ *
6
+ * Features:
7
+ * - Auto-reconnect with exponential backoff
8
+ * - Session persistence across server restarts
9
+ * - NO API costs - uses your existing Claude Max subscription!
10
+ * - Light/Dark theme support
11
+ */
12
+
13
+ import { useEffect, useRef, useCallback, useState } from 'react'
14
+ import { useTheme } from 'next-themes'
15
+ import type { Terminal, ITheme } from '@xterm/xterm'
16
+ import type { FitAddon } from '@xterm/addon-fit'
17
+
18
+ // Terminal themes
19
+ const darkTheme: ITheme = {
20
+ background: '#0a0a0f',
21
+ foreground: '#e4e4e7',
22
+ cursor: '#e4e4e7',
23
+ cursorAccent: '#0a0a0f',
24
+ selectionBackground: '#3f3f46',
25
+ black: '#18181b',
26
+ red: '#ef4444',
27
+ green: '#22c55e',
28
+ yellow: '#eab308',
29
+ blue: '#3b82f6',
30
+ magenta: '#a855f7',
31
+ cyan: '#06b6d4',
32
+ white: '#e4e4e7',
33
+ brightBlack: '#52525b',
34
+ brightRed: '#f87171',
35
+ brightGreen: '#4ade80',
36
+ brightYellow: '#facc15',
37
+ brightBlue: '#60a5fa',
38
+ brightMagenta: '#c084fc',
39
+ brightCyan: '#22d3ee',
40
+ brightWhite: '#fafafa'
41
+ }
42
+
43
+ const lightTheme: ITheme = {
44
+ background: '#ffffff',
45
+ foreground: '#18181b',
46
+ cursor: '#18181b',
47
+ cursorAccent: '#ffffff',
48
+ selectionBackground: '#d4d4d8',
49
+ black: '#18181b',
50
+ red: '#dc2626',
51
+ green: '#16a34a',
52
+ yellow: '#ca8a04',
53
+ blue: '#2563eb',
54
+ magenta: '#9333ea',
55
+ cyan: '#0891b2',
56
+ white: '#f4f4f5',
57
+ brightBlack: '#71717a',
58
+ brightRed: '#ef4444',
59
+ brightGreen: '#22c55e',
60
+ brightYellow: '#eab308',
61
+ brightBlue: '#3b82f6',
62
+ brightMagenta: '#a855f7',
63
+ brightCyan: '#06b6d4',
64
+ brightWhite: '#fafafa'
65
+ }
66
+
67
+ interface UseClaudeTerminalOptions {
68
+ sessionId: string
69
+ projectDir: string
70
+ onConnect?: () => void
71
+ onDisconnect?: () => void
72
+ onError?: (error: string) => void
73
+ onReconnecting?: (attempt: number, maxAttempts: number) => void
74
+ }
75
+
76
+ const MAX_RECONNECT_ATTEMPTS = 10
77
+ const BASE_RECONNECT_DELAY = 1000 // 1 second
78
+ const MAX_RECONNECT_DELAY = 30000 // 30 seconds
79
+
80
+ export function useClaudeTerminal(options: UseClaudeTerminalOptions) {
81
+ const { sessionId, projectDir, onConnect, onDisconnect, onError, onReconnecting } = options
82
+ const { resolvedTheme } = useTheme()
83
+
84
+ const terminalRef = useRef<Terminal | null>(null)
85
+ const fitAddonRef = useRef<FitAddon | null>(null)
86
+ const wsRef = useRef<WebSocket | null>(null)
87
+ const containerRef = useRef<HTMLDivElement | null>(null)
88
+ const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null)
89
+ const reconnectAttemptsRef = useRef(0)
90
+ const intentionalDisconnectRef = useRef(false)
91
+ const currentSessionIdRef = useRef<string | null>(null)
92
+
93
+ const [isConnected, setIsConnected] = useState(false)
94
+ const [isLoading, setIsLoading] = useState(false)
95
+ const [isReconnecting, setIsReconnecting] = useState(false)
96
+
97
+ // Update terminal theme when resolvedTheme changes
98
+ useEffect(() => {
99
+ if (terminalRef.current) {
100
+ const newTheme = resolvedTheme === 'light' ? lightTheme : darkTheme
101
+ terminalRef.current.options.theme = newTheme
102
+ }
103
+ }, [resolvedTheme])
104
+
105
+ // Initialize terminal - returns Promise<void>
106
+ const initTerminal = useCallback(async (container: HTMLDivElement): Promise<void> => {
107
+ if (terminalRef.current) return
108
+
109
+ // Dynamic imports for client-side only
110
+ const { Terminal } = await import('@xterm/xterm')
111
+ const { FitAddon } = await import('@xterm/addon-fit')
112
+ const { WebLinksAddon } = await import('@xterm/addon-web-links')
113
+ // CSS is loaded globally via globals.css
114
+
115
+ // Determine initial theme based on document class
116
+ const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
117
+ const initialTheme = isDark ? darkTheme : lightTheme
118
+
119
+ const term = new Terminal({
120
+ cursorBlink: true,
121
+ cursorStyle: 'block',
122
+ fontFamily: '"JetBrains Mono", "Fira Code", monospace',
123
+ fontSize: 14,
124
+ lineHeight: 1.2,
125
+ theme: initialTheme
126
+ })
127
+
128
+ const fitAddon = new FitAddon()
129
+ const webLinksAddon = new WebLinksAddon()
130
+
131
+ term.loadAddon(fitAddon)
132
+ term.loadAddon(webLinksAddon)
133
+
134
+ term.open(container)
135
+ fitAddon.fit()
136
+
137
+ terminalRef.current = term
138
+ fitAddonRef.current = fitAddon
139
+ containerRef.current = container
140
+
141
+ // Handle window resize
142
+ const handleResize = () => {
143
+ if (fitAddonRef.current) {
144
+ fitAddonRef.current.fit()
145
+
146
+ // Send resize to server
147
+ if (wsRef.current?.readyState === WebSocket.OPEN && terminalRef.current) {
148
+ wsRef.current.send(JSON.stringify({
149
+ type: 'resize',
150
+ cols: terminalRef.current.cols,
151
+ rows: terminalRef.current.rows
152
+ }))
153
+ }
154
+ }
155
+ }
156
+
157
+ window.addEventListener('resize', handleResize)
158
+
159
+ // Note: cleanup is handled by the component unmount, not here
160
+ // The terminal persists for the lifetime of the TerminalTab component
161
+ }, [])
162
+
163
+ // Clear reconnect timeout
164
+ const clearReconnectTimeout = useCallback(() => {
165
+ if (reconnectTimeoutRef.current) {
166
+ clearTimeout(reconnectTimeoutRef.current)
167
+ reconnectTimeoutRef.current = null
168
+ }
169
+ }, [])
170
+
171
+ // Schedule reconnect with exponential backoff
172
+ const scheduleReconnect = useCallback(() => {
173
+ if (intentionalDisconnectRef.current) return
174
+ if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
175
+ setIsReconnecting(false)
176
+ onError?.(`Failed to reconnect after ${MAX_RECONNECT_ATTEMPTS} attempts`)
177
+ return
178
+ }
179
+
180
+ const delay = Math.min(
181
+ BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttemptsRef.current),
182
+ MAX_RECONNECT_DELAY
183
+ )
184
+
185
+ reconnectAttemptsRef.current++
186
+ setIsReconnecting(true)
187
+ onReconnecting?.(reconnectAttemptsRef.current, MAX_RECONNECT_ATTEMPTS)
188
+
189
+ console.log(`[Terminal] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current}/${MAX_RECONNECT_ATTEMPTS})`)
190
+
191
+ reconnectTimeoutRef.current = setTimeout(() => {
192
+ connectWebSocket()
193
+ }, delay)
194
+ }, [onError, onReconnecting])
195
+
196
+ // Connect WebSocket (internal)
197
+ const connectWebSocket = useCallback(async () => {
198
+ // Close existing connection if any
199
+ if (wsRef.current) {
200
+ wsRef.current.close()
201
+ wsRef.current = null
202
+ }
203
+
204
+ const activeSessionId = currentSessionIdRef.current || sessionId
205
+
206
+ try {
207
+ // Create PTY session on server
208
+ const response = await fetch('/api/claude/sessions', {
209
+ method: 'POST',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ sessionId: activeSessionId, projectDir })
212
+ })
213
+
214
+ if (!response.ok) {
215
+ throw new Error('Failed to create PTY session')
216
+ }
217
+
218
+ // Connect WebSocket
219
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
220
+ const wsUrl = `${protocol}//${window.location.host}/ws/claude/${activeSessionId}`
221
+ const ws = new WebSocket(wsUrl)
222
+
223
+ ws.onopen = () => {
224
+ console.log('[Terminal] WebSocket connected')
225
+ reconnectAttemptsRef.current = 0
226
+ setIsConnected(true)
227
+ setIsLoading(false)
228
+ setIsReconnecting(false)
229
+ onConnect?.()
230
+
231
+ // Send initial resize
232
+ if (terminalRef.current) {
233
+ ws.send(JSON.stringify({
234
+ type: 'resize',
235
+ cols: terminalRef.current.cols,
236
+ rows: terminalRef.current.rows
237
+ }))
238
+ }
239
+ }
240
+
241
+ ws.onmessage = (event) => {
242
+ try {
243
+ const message = JSON.parse(event.data)
244
+
245
+ switch (message.type) {
246
+ case 'output':
247
+ terminalRef.current?.write(message.data)
248
+ break
249
+
250
+ case 'connected':
251
+ console.log('[Terminal] Connected to Claude Code CLI')
252
+ break
253
+
254
+ case 'exit':
255
+ console.log('[Terminal] Claude Code exited with code:', message.code)
256
+ terminalRef.current?.write('\r\n[Session ended]\r\n')
257
+ break
258
+
259
+ case 'error':
260
+ onError?.(message.message)
261
+ break
262
+ }
263
+ } catch {
264
+ // Raw data, write directly
265
+ terminalRef.current?.write(event.data)
266
+ }
267
+ }
268
+
269
+ ws.onclose = (event) => {
270
+ console.log(`[Terminal] WebSocket closed (code: ${event.code})`)
271
+ setIsConnected(false)
272
+ wsRef.current = null
273
+
274
+ // Only trigger reconnect if not intentional
275
+ if (!intentionalDisconnectRef.current) {
276
+ terminalRef.current?.write('\r\n\x1b[33m[Disconnected - Reconnecting...]\x1b[0m\r\n')
277
+ scheduleReconnect()
278
+ } else {
279
+ onDisconnect?.()
280
+ }
281
+ }
282
+
283
+ ws.onerror = (error) => {
284
+ console.error('[Terminal] WebSocket error:', error)
285
+ // Don't call onError here, onclose will handle reconnect
286
+ }
287
+
288
+ wsRef.current = ws
289
+
290
+ // Forward terminal input to WebSocket
291
+ terminalRef.current?.onData((data) => {
292
+ if (ws.readyState === WebSocket.OPEN) {
293
+ ws.send(JSON.stringify({ type: 'input', data }))
294
+ }
295
+ })
296
+
297
+ } catch (error) {
298
+ console.error('[Terminal] Connection error:', error)
299
+ setIsLoading(false)
300
+
301
+ // Schedule reconnect on connection failure
302
+ if (!intentionalDisconnectRef.current) {
303
+ scheduleReconnect()
304
+ } else {
305
+ onError?.(error instanceof Error ? error.message : 'Connection failed')
306
+ }
307
+ }
308
+ }, [sessionId, projectDir, onConnect, onDisconnect, onError, scheduleReconnect])
309
+
310
+ // Public connect function
311
+ const connect = useCallback(async () => {
312
+ if (wsRef.current?.readyState === WebSocket.OPEN) return
313
+
314
+ intentionalDisconnectRef.current = false
315
+ reconnectAttemptsRef.current = 0
316
+ currentSessionIdRef.current = sessionId
317
+ setIsLoading(true)
318
+
319
+ await connectWebSocket()
320
+ }, [sessionId, connectWebSocket])
321
+
322
+ // Disconnect (intentional)
323
+ const disconnect = useCallback(() => {
324
+ intentionalDisconnectRef.current = true
325
+ clearReconnectTimeout()
326
+ setIsReconnecting(false)
327
+
328
+ if (wsRef.current) {
329
+ wsRef.current.close()
330
+ wsRef.current = null
331
+ }
332
+ setIsConnected(false)
333
+ onDisconnect?.()
334
+ }, [clearReconnectTimeout, onDisconnect])
335
+
336
+ // Send input
337
+ const sendInput = useCallback((data: string) => {
338
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
339
+ wsRef.current.send(JSON.stringify({ type: 'input', data }))
340
+ }
341
+ }, [])
342
+
343
+ // Focus terminal
344
+ const focusTerminal = useCallback(() => {
345
+ if (terminalRef.current) {
346
+ terminalRef.current.focus()
347
+ }
348
+ }, [])
349
+
350
+ // Cleanup on unmount - empty deps to run only on unmount
351
+ useEffect(() => {
352
+ return () => {
353
+ intentionalDisconnectRef.current = true
354
+ if (reconnectTimeoutRef.current) {
355
+ clearTimeout(reconnectTimeoutRef.current)
356
+ }
357
+ if (wsRef.current) {
358
+ wsRef.current.close()
359
+ }
360
+ }
361
+ }, []) // Empty deps - run cleanup only on unmount
362
+
363
+ return {
364
+ initTerminal,
365
+ connect,
366
+ disconnect,
367
+ sendInput,
368
+ focusTerminal,
369
+ isConnected,
370
+ isLoading,
371
+ isReconnecting,
372
+ terminalRef,
373
+ containerRef
374
+ }
375
+ }
@@ -0,0 +1,38 @@
1
+ 'use client'
2
+
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import type { ProjectStats, RawProjectFiles } from '@/lib/parse-prjct-files'
5
+
6
+ interface ProjectStatsResponse {
7
+ success: boolean
8
+ data?: ProjectStats
9
+ raw?: RawProjectFiles
10
+ error?: string
11
+ }
12
+
13
+ interface ProjectStatsData {
14
+ stats: ProjectStats
15
+ raw: RawProjectFiles
16
+ }
17
+
18
+ async function fetchProjectStats(projectId: string): Promise<ProjectStatsData> {
19
+ const res = await fetch(`/api/projects/${projectId}/stats`, {
20
+ cache: 'no-store',
21
+ })
22
+ if (!res.ok) throw new Error('Failed to fetch project stats')
23
+ const json: ProjectStatsResponse = await res.json()
24
+ if (!json.success || !json.data || !json.raw) {
25
+ throw new Error(json.error || 'Failed to fetch project stats')
26
+ }
27
+ return { stats: json.data, raw: json.raw }
28
+ }
29
+
30
+ export function useProjectStats(projectId: string) {
31
+ return useQuery({
32
+ queryKey: ['project-stats', projectId],
33
+ queryFn: () => fetchProjectStats(projectId),
34
+ staleTime: 30_000, // Cache 30s
35
+ refetchOnWindowFocus: true,
36
+ enabled: !!projectId,
37
+ })
38
+ }
@@ -0,0 +1,73 @@
1
+ 'use client'
2
+
3
+ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
4
+ import { queryPresets } from '@/lib/query-config'
5
+
6
+ export interface Project {
7
+ id: string
8
+ name: string
9
+ path: string
10
+ repoPath?: string | null
11
+ currentTask?: string | null
12
+ hasActiveSession?: boolean
13
+ lastActivity?: string | null
14
+ ideasCount?: number
15
+ nextTasksCount?: number
16
+ techStack?: string[]
17
+ iconPath?: string | null
18
+ version?: string
19
+ stack?: string
20
+ filesCount?: number
21
+ commitsCount?: number
22
+ }
23
+
24
+ // Fetch with no-cache headers for fresh data
25
+ async function fetchJson<T>(url: string): Promise<T> {
26
+ const res = await fetch(url, {
27
+ cache: 'no-store',
28
+ headers: {
29
+ 'Cache-Control': 'no-cache',
30
+ },
31
+ })
32
+ if (!res.ok) throw new Error(`Failed to fetch ${url}`)
33
+ const json = await res.json()
34
+ return json.data ?? json
35
+ }
36
+
37
+ // Hook for fetching all projects
38
+ export function useProjects() {
39
+ return useQuery({
40
+ queryKey: ['projects'],
41
+ queryFn: () => fetchJson<Project[]>('/api/projects'),
42
+ ...queryPresets.normal,
43
+ })
44
+ }
45
+
46
+ // Hook for fetching a single project
47
+ export function useProject(projectId: string | null) {
48
+ return useQuery({
49
+ queryKey: ['project', projectId],
50
+ queryFn: () => fetchJson<Project>(`/api/projects/${projectId}`),
51
+ enabled: !!projectId,
52
+ ...queryPresets.fast,
53
+ })
54
+ }
55
+
56
+ // Hook for deleting a project
57
+ export function useDeleteProject() {
58
+ const queryClient = useQueryClient()
59
+
60
+ return useMutation({
61
+ mutationFn: async (projectId: string) => {
62
+ const res = await fetch(`/api/projects/${projectId}/delete`, { method: 'POST' })
63
+ if (!res.ok) {
64
+ const error = await res.json()
65
+ throw new Error(error.error || 'Failed to delete project')
66
+ }
67
+ return res.json()
68
+ },
69
+ onSuccess: () => {
70
+ queryClient.invalidateQueries({ queryKey: ['projects'] })
71
+ },
72
+ })
73
+ }
@@ -0,0 +1,28 @@
1
+ 'use client'
2
+
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { queryPresets } from '@/lib/query-config'
5
+
6
+ export interface Stats {
7
+ userName?: string
8
+ totalProjects?: number
9
+ activeProjects?: number
10
+ totalSessions?: number
11
+ sessionsThisWeek?: number
12
+ }
13
+
14
+ export function useStats() {
15
+ return useQuery({
16
+ queryKey: ['stats'],
17
+ queryFn: async () => {
18
+ const res = await fetch('/api/stats', {
19
+ cache: 'no-store',
20
+ headers: { 'Cache-Control': 'no-cache' },
21
+ })
22
+ if (!res.ok) throw new Error('Failed to fetch stats')
23
+ const json = await res.json()
24
+ return json.data as Stats
25
+ },
26
+ ...queryPresets.normal,
27
+ })
28
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Shared formatting utilities
3
+ */
4
+
5
+ export function formatRelativeTime(dateStr: string | null | undefined): string {
6
+ if (!dateStr) return ''
7
+ const date = new Date(dateStr)
8
+ const now = new Date()
9
+ const diffMs = now.getTime() - date.getTime()
10
+ const diffMins = Math.floor(diffMs / 60000)
11
+ const diffHours = Math.floor(diffMs / 3600000)
12
+ const diffDays = Math.floor(diffMs / 86400000)
13
+
14
+ if (diffMins < 1) return 'just now'
15
+ if (diffMins < 60) return `${diffMins}m ago`
16
+ if (diffHours < 24) return `${diffHours}h ago`
17
+ if (diffDays < 7) return `${diffDays}d ago`
18
+ return date.toLocaleDateString()
19
+ }
20
+
21
+ export function formatPath(path: string): string {
22
+ return path.replace(/^\/Users\/[^/]+\//, '~/')
23
+ }