feat: add shared React hooks for workspace and daemon status

This commit is contained in:
Vaibhaav 2026-06-26 03:56:15 +05:30
parent 39f43763a2
commit 06a21ecbe1
12 changed files with 327 additions and 2 deletions

0
docs/telemetry.md Normal file
View File

View File

@ -0,0 +1,31 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: 'ts-jest/presets/default',
testEnvironment: 'jsdom',
testMatch: ['**/src/**/*.test.ts', '**/src/**/*.test.tsx'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
'^react$': '<rootDir>/node_modules/react',
'^react-dom$': '<rootDir>/node_modules/react-dom',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
tsconfig: {
types: ['jest'],
jsx: 'react',
module: 'ESNext',
moduleResolution: 'bundler',
allowSyntheticDefaultImports: true,
esModuleInterop: true,
},
},
],
},
transformIgnorePatterns: [
'node_modules/(?!(react|react-dom|@tanstack/react-query|@testing-library)/)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
moduleDirectories: ['node_modules', '<rootDir>/node_modules'],
};

View File

@ -0,0 +1 @@
module.exports = require('@testing-library/jest-dom');

View File

@ -11,14 +11,19 @@
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@types/event-source": "^3.0.0",
"event-source": "^2.0.0",
"eventsource": "^2.0.0",
"zustand": "^4.0.0"
},
"devDependencies": {
"@jest/globals": "^30.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^14.3.1",
"@types/jest": "^29.0.0",
"@types/react": "^18.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^30.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"typescript": "^5.0.0"
},
"peerDependencies": {

View File

@ -0,0 +1,97 @@
// packages/shared/src/api/client.ts
import { getPlatformBridge } from '../lib/bridge';
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: any;
headers?: Record<string, string>;
}
export class ApiError extends Error {
constructor(
public status: number,
public statusText: string,
public body?: any
) {
super(`API Error ${status}: ${statusText}`);
this.name = 'ApiError';
}
}
export class ApiClient {
private authToken?: string;
constructor(authToken?: string) {
this.authToken = authToken;
}
private getBaseUrl(): string {
return getPlatformBridge().getApiBaseUrl();
}
private getDefaultHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.authToken) {
headers['Authorization'] = `Bearer ${this.authToken}`;
}
return headers;
}
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const baseUrl = this.getBaseUrl();
const url = `${baseUrl}${path}`;
const response = await fetch(url, {
method: options.method || 'GET',
headers: {
...this.getDefaultHeaders(),
...options.headers,
},
body: options.body ? JSON.stringify(options.body) : undefined,
});
if (!response.ok) {
const body = await response.json().catch(() => undefined);
throw new ApiError(response.status, response.statusText, body);
}
// Handle 204 No Content
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
async get<T>(path: string, headers?: Record<string, string>): Promise<T> {
return this.request<T>(path, { method: 'GET', headers });
}
async post<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
return this.request<T>(path, { method: 'POST', body, headers });
}
async put<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
return this.request<T>(path, { method: 'PUT', body, headers });
}
async delete<T>(path: string, headers?: Record<string, string>): Promise<T> {
return this.request<T>(path, { method: 'DELETE', headers });
}
async patch<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
return this.request<T>(path, { method: 'PATCH', body, headers });
}
setAuthToken(token: string): void {
this.authToken = token;
}
clearAuthToken(): void {
this.authToken = undefined;
}
}

View File

@ -0,0 +1,6 @@
// packages/shared/src/api/paths.ts
// API path constants
export const API_PATHS = {
workspaces: '/api/v1/workspaces',
sessions: '/api/v1/sessions',
} as const;

View File

@ -0,0 +1,13 @@
// packages/shared/src/api/types.ts
// Types will be generated from OpenAPI schema
// This file is a placeholder for generated types
export interface ApiResponse<T> {
data: T;
}
export interface ApiErrorResponse {
error: string;
message: string;
details?: Record<string, unknown>;
}

View File

@ -0,0 +1,36 @@
// packages/shared/src/hooks/useDaemonStatus.ts
import { useEffect, useState } from 'react';
import { getPlatformBridge, DaemonStatus } from '../lib/bridge';
export function useDaemonStatus() {
const [status, setStatus] = useState<DaemonStatus>({ running: false, port: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
let mounted = true;
const checkStatus = async () => {
try {
const bridge = getPlatformBridge();
const currentStatus = await bridge.getDaemonStatus();
if (mounted) {
setStatus(currentStatus);
setLoading(false);
}
} catch (error) {
if (mounted) {
setStatus({ running: false, port: 0 });
setLoading(false);
}
}
};
checkStatus();
return () => {
mounted = false;
};
}, []);
return { status, loading };
}

View File

@ -0,0 +1,56 @@
// packages/shared/src/hooks/useWorkspaceQuery.test.tsx
import React from 'react';
import { describe, it, expect, beforeEach } from '@jest/globals';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useWorkspaceQuery } from './useWorkspaceQuery';
import { ApiClient } from '../api/client';
import { setPlatformBridge } from '../lib/bridge';
describe('useWorkspaceQuery', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
setPlatformBridge({
getDaemonStatus: async () => ({ running: true, port: 3001 }),
startDaemon: async () => {},
stopDaemon: async () => {},
getApiBaseUrl: () => 'http://test:3001',
subscribeApiBaseUrl: () => () => {},
});
});
it('should fetch workspace data', async () => {
const mockWorkspaces = [
{ id: '1', name: 'Project A', sessions: [] },
{ id: '2', name: 'Project B', sessions: [] },
];
globalThis.fetch = globalThis.fetch || jest.fn();
(globalThis.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ workspaces: mockWorkspaces }),
});
const { result } = renderHook(() => useWorkspaceQuery(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
),
});
// Trigger the query and wait for it to complete
const refetchPromise = result.current.refetch();
await waitFor(() => expect(result.current.isSuccess).toBe(true));
await refetchPromise;
expect(result.current.data).toEqual({ workspaces: mockWorkspaces });
});
});

View File

@ -0,0 +1,45 @@
// packages/shared/src/hooks/useWorkspaceQuery.ts
import { useQuery } from '@tanstack/react-query';
import { ApiClient } from '../api/client';
export interface Session {
id: string;
projectId: string;
agent: string;
status: string;
createdAt: string;
}
export interface Project {
id: string;
name: string;
sessions: Session[];
}
export interface WorkspaceResponse {
workspaces: Project[];
}
const WORKSPACE_QUERY_KEY = ['workspaces'] as const;
export function useWorkspaceQuery(client?: ApiClient) {
const apiClient = client || new ApiClient();
const query = useQuery({
queryKey: WORKSPACE_QUERY_KEY,
queryFn: async () => {
const response = await apiClient.get<WorkspaceResponse>('/api/v1/workspaces');
return response;
},
enabled: false, // Manually refetch on SSE events
refetchOnMount: false,
refetchOnWindowFocus: false,
});
return {
...query,
refetch: () => query.refetch(),
};
}
export { WORKSPACE_QUERY_KEY };

View File

@ -0,0 +1,7 @@
// packages/shared/src/index.ts
export * from './api/client';
export * from './api/paths';
export * from './api/types';
export * from './lib/bridge';
export * from './hooks/useWorkspaceQuery';
export * from './hooks/useDaemonStatus';

View File

@ -0,0 +1,28 @@
export interface DaemonStatus {
running: boolean;
port: number;
}
export interface PlatformBridge {
// Daemon lifecycle
getDaemonStatus: () => Promise<DaemonStatus>;
startDaemon: () => Promise<void>;
stopDaemon: () => Promise<void>;
// API base URL resolution
getApiBaseUrl: () => string;
subscribeApiBaseUrl: (callback: (url: string) => void) => () => void;
}
let platformBridgeInstance: PlatformBridge | null = null;
export function setPlatformBridge(bridge: PlatformBridge): void {
platformBridgeInstance = bridge;
}
export function getPlatformBridge(): PlatformBridge {
if (!platformBridgeInstance) {
throw new Error('PlatformBridge not initialized. Call setPlatformBridge() first.');
}
return platformBridgeInstance;
}