Skip to content

Commit 6e8bc78

Browse files
chore: add workspace hook, schedules library, and tests
1 parent 0f6f13c commit 6e8bc78

5 files changed

Lines changed: 599 additions & 0 deletions

File tree

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest'
2+
import { renderHook, waitFor } from '@testing-library/react'
3+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4+
import { useWorkspace } from '../useWorkspace'
5+
import type { AssistantModeStatus, Repo } from '@opencode-manager/shared/types'
6+
7+
const mocks = vi.hoisted(() => ({
8+
getRepo: vi.fn(),
9+
getAssistantModeStatus: vi.fn(),
10+
}))
11+
12+
vi.mock('@/api/repos', () => ({
13+
getRepo: mocks.getRepo,
14+
getAssistantModeStatus: mocks.getAssistantModeStatus,
15+
}))
16+
17+
const createWrapper = () => {
18+
const queryClient = new QueryClient({
19+
defaultOptions: {
20+
queries: { retry: false },
21+
mutations: { retry: false },
22+
},
23+
})
24+
return ({ children }: { children: React.ReactNode }) =>
25+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
26+
}
27+
28+
describe('useWorkspace', () => {
29+
beforeEach(() => {
30+
vi.clearAllMocks()
31+
})
32+
33+
describe('repoId === 0 (assistant)', () => {
34+
it('returns assistant workspace with correct properties', async () => {
35+
const mockStatus: AssistantModeStatus = {
36+
repoId: 0,
37+
directory: '/abs/assistant',
38+
relativePath: 'repos/assistant',
39+
files: {
40+
agentsMd: { path: '', exists: false, created: false },
41+
opencodeJson: { path: '', exists: false, created: false },
42+
},
43+
schedulesSkill: { path: '', exists: false, created: false },
44+
}
45+
46+
mocks.getAssistantModeStatus.mockResolvedValue(mockStatus)
47+
48+
const { result } = renderHook(() => useWorkspace(0), { wrapper: createWrapper() })
49+
50+
await waitFor(() => {
51+
expect(result.current.workspace).toBeDefined()
52+
})
53+
54+
expect(result.current.workspace?.kind).toBe('assistant')
55+
expect(result.current.workspace?.fullPath).toBe('/abs/assistant')
56+
expect(result.current.workspace?.repoId).toBe(0)
57+
expect(result.current.isLoading).toBe(false)
58+
expect(result.current.isError).toBe(false)
59+
})
60+
61+
it('does not call getRepo for assistant', async () => {
62+
mocks.getAssistantModeStatus.mockResolvedValue({
63+
directory: '/abs/assistant',
64+
relativePath: 'repos/assistant',
65+
files: { agentsMd: { path: '', exists: false, created: false }, opencodeJson: { path: '', exists: false, created: false } },
66+
schedulesSkill: { path: '', exists: false, created: false },
67+
repoId: 0,
68+
})
69+
70+
renderHook(() => useWorkspace(0), { wrapper: createWrapper() })
71+
72+
await vi.waitFor(() => {
73+
expect(mocks.getRepo).not.toHaveBeenCalled()
74+
})
75+
})
76+
})
77+
78+
describe('repoId === 5 (real repo)', () => {
79+
it('returns repo workspace with correct properties', async () => {
80+
const mockRepo: Repo = {
81+
id: 5,
82+
repoUrl: 'https://x/my-repo',
83+
localPath: 'repos/my-repo',
84+
fullPath: '/abs/repos/my-repo',
85+
sourcePath: undefined,
86+
defaultBranch: 'main',
87+
cloneStatus: 'ready',
88+
clonedAt: 0,
89+
}
90+
91+
mocks.getRepo.mockResolvedValue(mockRepo)
92+
93+
const { result } = renderHook(() => useWorkspace(5), { wrapper: createWrapper() })
94+
95+
await waitFor(() => {
96+
expect(result.current.workspace).toBeDefined()
97+
})
98+
99+
expect(result.current.workspace?.kind).toBe('repo')
100+
expect(result.current.workspace?.repoId).toBe(5)
101+
expect(result.current.workspace?.fullPath).toBe('/abs/repos/my-repo')
102+
expect(result.current.workspace?.backHref).toBe('/repos/5')
103+
})
104+
105+
it('does not call getAssistantModeStatus for repo', async () => {
106+
const mockRepo: Repo = {
107+
id: 5,
108+
repoUrl: 'https://x/my-repo',
109+
localPath: 'repos/my-repo',
110+
fullPath: '/abs/repos/my-repo',
111+
sourcePath: undefined,
112+
defaultBranch: 'main',
113+
cloneStatus: 'ready',
114+
clonedAt: 0,
115+
}
116+
117+
mocks.getRepo.mockResolvedValue(mockRepo)
118+
119+
renderHook(() => useWorkspace(5), { wrapper: createWrapper() })
120+
121+
await vi.waitFor(() => {
122+
expect(mocks.getAssistantModeStatus).not.toHaveBeenCalled()
123+
})
124+
})
125+
})
126+
127+
describe('repoId === undefined', () => {
128+
it('returns undefined workspace', () => {
129+
const { result } = renderHook(() => useWorkspace(undefined), { wrapper: createWrapper() })
130+
131+
expect(result.current.workspace).toBeUndefined()
132+
expect(result.current.isLoading).toBe(false)
133+
})
134+
})
135+
})

frontend/src/hooks/useWorkspace.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { useQuery } from '@tanstack/react-query'
2+
import { getRepo } from '@/api/repos'
3+
import { useAssistantMode } from '@/hooks/useAssistantMode'
4+
import { isAssistantRepoId, workspaceFromAssistant, workspaceFromRepo } from '@/lib/schedules/workspace'
5+
import type { Workspace } from '@/lib/schedules/workspace'
6+
7+
export function useWorkspace(repoId: number | undefined): {
8+
workspace: Workspace | undefined
9+
isLoading: boolean
10+
isError: boolean
11+
} {
12+
const assistantQuery = useAssistantMode(repoId)
13+
14+
const repoQuery = useQuery({
15+
queryKey: ['repo', repoId],
16+
queryFn: () => getRepo(repoId!),
17+
enabled: repoId !== undefined && repoId > 0,
18+
})
19+
20+
if (isAssistantRepoId(repoId)) {
21+
return {
22+
workspace: assistantQuery.status ? workspaceFromAssistant(assistantQuery.status) : undefined,
23+
isLoading: assistantQuery.isLoading,
24+
isError: assistantQuery.isError,
25+
}
26+
}
27+
28+
return {
29+
workspace: repoQuery.data ? workspaceFromRepo(repoQuery.data) : undefined,
30+
isLoading: repoQuery.isLoading,
31+
isError: repoQuery.isError,
32+
}
33+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, it, expect } from 'vitest'
2+
import {
3+
isAssistantRepoId,
4+
workspaceFromRepo,
5+
workspaceFromAssistant,
6+
} from './workspace'
7+
import type { AssistantModeStatus } from '@opencode-manager/shared/types'
8+
9+
describe('isAssistantRepoId', () => {
10+
it('returns true for repoId 0', () => {
11+
expect(isAssistantRepoId(0)).toBe(true)
12+
})
13+
14+
it('returns false for positive repoId', () => {
15+
expect(isAssistantRepoId(5)).toBe(false)
16+
})
17+
18+
it('returns false for undefined', () => {
19+
expect(isAssistantRepoId(undefined)).toBe(false)
20+
})
21+
})
22+
23+
describe('workspaceFromRepo', () => {
24+
it('returns correct workspace for a repo', () => {
25+
const repo = {
26+
id: 5,
27+
repoUrl: 'https://x/y',
28+
localPath: 'y',
29+
fullPath: '/abs/y',
30+
sourcePath: undefined,
31+
defaultBranch: 'main',
32+
cloneStatus: 'ready' as const,
33+
clonedAt: 0,
34+
}
35+
36+
const workspace = workspaceFromRepo(repo)
37+
38+
expect(workspace).toEqual({
39+
repoId: 5,
40+
kind: 'repo',
41+
name: 'y',
42+
subtitle: 'y',
43+
fullPath: '/abs/y',
44+
backHref: '/repos/5',
45+
})
46+
})
47+
})
48+
49+
describe('workspaceFromAssistant', () => {
50+
it('returns correct workspace for assistant', () => {
51+
const status: AssistantModeStatus = {
52+
repoId: 0,
53+
directory: '/abs/assistant',
54+
relativePath: 'repos/assistant',
55+
files: {
56+
agentsMd: { path: '', exists: false, created: false },
57+
opencodeJson: { path: '', exists: false, created: false },
58+
},
59+
schedulesSkill: { path: '', exists: false, created: false },
60+
}
61+
62+
const workspace = workspaceFromAssistant(status)
63+
64+
expect(workspace).toEqual({
65+
repoId: 0,
66+
kind: 'assistant',
67+
name: 'Assistant',
68+
subtitle: 'Assistant Workspace',
69+
fullPath: '/abs/assistant',
70+
backHref: '/repos/0/assistant',
71+
})
72+
})
73+
})
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type { Repo } from '@/api/types'
2+
import type { AssistantModeStatus } from '@opencode-manager/shared/types'
3+
import { getRepoDisplayName } from '@/lib/utils'
4+
5+
export interface Workspace {
6+
repoId: number
7+
kind: 'assistant' | 'repo'
8+
name: string
9+
subtitle: string
10+
fullPath: string
11+
backHref: string
12+
}
13+
14+
export const ASSISTANT_REPO_ID = 0
15+
16+
export function isAssistantRepoId(repoId: number | undefined): boolean {
17+
return repoId === ASSISTANT_REPO_ID
18+
}
19+
20+
export function workspaceFromRepo(repo: Repo): Workspace {
21+
return {
22+
repoId: repo.id,
23+
kind: 'repo',
24+
name: getRepoDisplayName(repo.repoUrl, repo.localPath, repo.sourcePath),
25+
subtitle: repo.localPath,
26+
fullPath: repo.fullPath,
27+
backHref: `/repos/${repo.id}`,
28+
}
29+
}
30+
31+
export function workspaceFromAssistant(status: AssistantModeStatus): Workspace {
32+
return {
33+
repoId: ASSISTANT_REPO_ID,
34+
kind: 'assistant',
35+
name: 'Assistant',
36+
subtitle: 'Assistant Workspace',
37+
fullPath: status.directory,
38+
backHref: `/repos/${ASSISTANT_REPO_ID}/assistant`,
39+
}
40+
}

0 commit comments

Comments
 (0)