@mdxui/issues 6.0.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.
- package/.turbo/turbo-typecheck.log +5 -0
- package/CHANGELOG.md +80 -0
- package/package.json +35 -0
- package/src/components/epic-list.tsx +210 -0
- package/src/components/index.ts +9 -0
- package/src/components/issue-badges.tsx +135 -0
- package/src/components/issue-board.tsx +211 -0
- package/src/components/issue-card.tsx +57 -0
- package/src/components/issue-detail.tsx +390 -0
- package/src/components/issue-dialog.tsx +184 -0
- package/src/components/issue-filters.tsx +180 -0
- package/src/components/issue-list.tsx +142 -0
- package/src/components/issue-row.tsx +56 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/use-beads.ts +224 -0
- package/src/hooks/use-issue-mutations.ts +260 -0
- package/src/hooks/use-issues-store.ts +210 -0
- package/src/index.ts +20 -0
- package/src/types/index.ts +194 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Button } from '@mdxui/primitives/button'
|
|
5
|
+
import { Input } from '@mdxui/primitives/input'
|
|
6
|
+
import {
|
|
7
|
+
Select,
|
|
8
|
+
SelectContent,
|
|
9
|
+
SelectItem,
|
|
10
|
+
SelectTrigger,
|
|
11
|
+
SelectValue,
|
|
12
|
+
} from '@mdxui/primitives/select'
|
|
13
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
14
|
+
import { Search, X } from 'lucide-react'
|
|
15
|
+
import { useIssuesStore } from '../hooks/use-issues-store'
|
|
16
|
+
import type { IssueStatus, IssueType, IssueFilters as IssueFiltersType } from '../types'
|
|
17
|
+
|
|
18
|
+
export interface IssueFiltersProps {
|
|
19
|
+
className?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function IssueFilters({ className }: IssueFiltersProps) {
|
|
23
|
+
const filters = useIssuesStore((s) => s.filters)
|
|
24
|
+
const setFilters = useIssuesStore((s) => s.setFilters)
|
|
25
|
+
const [searchText, setSearchText] = React.useState(filters.search ?? '')
|
|
26
|
+
|
|
27
|
+
// Debounce search
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
setFilters({ ...filters, search: searchText || undefined })
|
|
31
|
+
}, 300)
|
|
32
|
+
return () => clearTimeout(timer)
|
|
33
|
+
}, [searchText])
|
|
34
|
+
|
|
35
|
+
const updateFilter = <K extends keyof IssueFiltersType>(
|
|
36
|
+
key: K,
|
|
37
|
+
value: IssueFiltersType[K]
|
|
38
|
+
) => {
|
|
39
|
+
setFilters({ ...filters, [key]: value })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const clearFilters = () => {
|
|
43
|
+
setSearchText('')
|
|
44
|
+
setFilters({})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const hasActiveFilters =
|
|
48
|
+
searchText ||
|
|
49
|
+
filters.status?.length ||
|
|
50
|
+
filters.type?.length ||
|
|
51
|
+
filters.priority?.length
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className={cn('flex flex-wrap items-center gap-2', className)}>
|
|
55
|
+
{/* Search */}
|
|
56
|
+
<div className="relative flex-1 min-w-48">
|
|
57
|
+
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
58
|
+
<Input
|
|
59
|
+
type="search"
|
|
60
|
+
placeholder="Search issues..."
|
|
61
|
+
value={searchText}
|
|
62
|
+
onChange={(e) => setSearchText(e.target.value)}
|
|
63
|
+
className="pl-8"
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
{/* Status Filter */}
|
|
68
|
+
<Select
|
|
69
|
+
value={filters.status?.[0] ?? 'all'}
|
|
70
|
+
onValueChange={(value) =>
|
|
71
|
+
updateFilter('status', value === 'all' ? undefined : [value as IssueStatus])
|
|
72
|
+
}
|
|
73
|
+
>
|
|
74
|
+
<SelectTrigger className="w-32">
|
|
75
|
+
<SelectValue placeholder="Status" />
|
|
76
|
+
</SelectTrigger>
|
|
77
|
+
<SelectContent>
|
|
78
|
+
<SelectItem value="all">All Status</SelectItem>
|
|
79
|
+
<SelectItem value="open">Open</SelectItem>
|
|
80
|
+
<SelectItem value="in_progress">In Progress</SelectItem>
|
|
81
|
+
<SelectItem value="blocked">Blocked</SelectItem>
|
|
82
|
+
<SelectItem value="closed">Closed</SelectItem>
|
|
83
|
+
</SelectContent>
|
|
84
|
+
</Select>
|
|
85
|
+
|
|
86
|
+
{/* Type Filter */}
|
|
87
|
+
<Select
|
|
88
|
+
value={filters.type?.[0] ?? 'all'}
|
|
89
|
+
onValueChange={(value) =>
|
|
90
|
+
updateFilter('type', value === 'all' ? undefined : [value as IssueType])
|
|
91
|
+
}
|
|
92
|
+
>
|
|
93
|
+
<SelectTrigger className="w-28">
|
|
94
|
+
<SelectValue placeholder="Type" />
|
|
95
|
+
</SelectTrigger>
|
|
96
|
+
<SelectContent>
|
|
97
|
+
<SelectItem value="all">All Types</SelectItem>
|
|
98
|
+
<SelectItem value="task">Task</SelectItem>
|
|
99
|
+
<SelectItem value="bug">Bug</SelectItem>
|
|
100
|
+
<SelectItem value="feature">Feature</SelectItem>
|
|
101
|
+
<SelectItem value="epic">Epic</SelectItem>
|
|
102
|
+
<SelectItem value="story">Story</SelectItem>
|
|
103
|
+
<SelectItem value="chore">Chore</SelectItem>
|
|
104
|
+
</SelectContent>
|
|
105
|
+
</Select>
|
|
106
|
+
|
|
107
|
+
{/* Clear Filters */}
|
|
108
|
+
{hasActiveFilters && (
|
|
109
|
+
<Button variant="ghost" size="sm" onClick={clearFilters}>
|
|
110
|
+
<X className="h-4 w-4 mr-1" />
|
|
111
|
+
Clear
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Quick filter buttons for common filters
|
|
119
|
+
export interface QuickFiltersProps {
|
|
120
|
+
className?: string
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function QuickFilters({ className }: QuickFiltersProps) {
|
|
124
|
+
const filters = useIssuesStore((s) => s.filters)
|
|
125
|
+
const setFilters = useIssuesStore((s) => s.setFilters)
|
|
126
|
+
|
|
127
|
+
const isActive = (check: Partial<IssueFiltersType>) => {
|
|
128
|
+
if (check.ready && filters.ready) return true
|
|
129
|
+
if (check.blocked && filters.blocked) return true
|
|
130
|
+
if (check.status && JSON.stringify(check.status) === JSON.stringify(filters.status))
|
|
131
|
+
return true
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const toggleFilter = (filter: Partial<IssueFiltersType>) => {
|
|
136
|
+
if (isActive(filter)) {
|
|
137
|
+
// Clear the filter
|
|
138
|
+
const newFilters = { ...filters }
|
|
139
|
+
if (filter.ready) delete newFilters.ready
|
|
140
|
+
if (filter.blocked) delete newFilters.blocked
|
|
141
|
+
if (filter.status) delete newFilters.status
|
|
142
|
+
setFilters(newFilters)
|
|
143
|
+
} else {
|
|
144
|
+
setFilters({ ...filters, ...filter, ready: undefined, blocked: undefined })
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div className={cn('flex items-center gap-1', className)}>
|
|
150
|
+
<Button
|
|
151
|
+
variant={filters.ready ? 'default' : 'outline'}
|
|
152
|
+
size="sm"
|
|
153
|
+
onClick={() => toggleFilter({ ready: true })}
|
|
154
|
+
>
|
|
155
|
+
Ready
|
|
156
|
+
</Button>
|
|
157
|
+
<Button
|
|
158
|
+
variant={filters.blocked ? 'default' : 'outline'}
|
|
159
|
+
size="sm"
|
|
160
|
+
onClick={() => toggleFilter({ blocked: true })}
|
|
161
|
+
>
|
|
162
|
+
Blocked
|
|
163
|
+
</Button>
|
|
164
|
+
<Button
|
|
165
|
+
variant={isActive({ status: ['in_progress'] }) ? 'default' : 'outline'}
|
|
166
|
+
size="sm"
|
|
167
|
+
onClick={() => toggleFilter({ status: ['in_progress'] })}
|
|
168
|
+
>
|
|
169
|
+
In Progress
|
|
170
|
+
</Button>
|
|
171
|
+
<Button
|
|
172
|
+
variant={isActive({ status: ['closed'] }) ? 'default' : 'outline'}
|
|
173
|
+
size="sm"
|
|
174
|
+
onClick={() => toggleFilter({ status: ['closed'] })}
|
|
175
|
+
>
|
|
176
|
+
Closed
|
|
177
|
+
</Button>
|
|
178
|
+
</div>
|
|
179
|
+
)
|
|
180
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Table,
|
|
6
|
+
TableBody,
|
|
7
|
+
TableHead,
|
|
8
|
+
TableHeader,
|
|
9
|
+
TableRow,
|
|
10
|
+
} from '@mdxui/primitives/table'
|
|
11
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
12
|
+
import { IssueRow } from './issue-row'
|
|
13
|
+
import { IssueFilters } from './issue-filters'
|
|
14
|
+
import { useIssuesStore, useFilteredIssues } from '../hooks/use-issues-store'
|
|
15
|
+
import type { Issue, IssueSort } from '../types'
|
|
16
|
+
|
|
17
|
+
export interface IssueListProps {
|
|
18
|
+
/** Issues to display (overrides store) */
|
|
19
|
+
issues?: Issue[]
|
|
20
|
+
/** Currently selected issue ID */
|
|
21
|
+
selectedId?: string
|
|
22
|
+
/** Callback when an issue is selected */
|
|
23
|
+
onSelect?: (issue: Issue) => void
|
|
24
|
+
/** Show filters */
|
|
25
|
+
showFilters?: boolean
|
|
26
|
+
/** Custom class name */
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function IssueList({
|
|
31
|
+
issues: propIssues,
|
|
32
|
+
selectedId: propSelectedId,
|
|
33
|
+
onSelect,
|
|
34
|
+
showFilters = true,
|
|
35
|
+
className,
|
|
36
|
+
}: IssueListProps) {
|
|
37
|
+
const storeIssues = useFilteredIssues()
|
|
38
|
+
const storeSelectedId = useIssuesStore((s) => s.selectedId)
|
|
39
|
+
const selectIssue = useIssuesStore((s) => s.selectIssue)
|
|
40
|
+
const sort = useIssuesStore((s) => s.sort)
|
|
41
|
+
const setSort = useIssuesStore((s) => s.setSort)
|
|
42
|
+
|
|
43
|
+
const issues = propIssues ?? storeIssues
|
|
44
|
+
const selectedId = propSelectedId ?? storeSelectedId
|
|
45
|
+
|
|
46
|
+
const handleSelect = (issue: Issue) => {
|
|
47
|
+
selectIssue(issue.id)
|
|
48
|
+
onSelect?.(issue)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const handleSort = (field: IssueSort['field']) => {
|
|
52
|
+
if (sort.field === field) {
|
|
53
|
+
setSort({ field, direction: sort.direction === 'asc' ? 'desc' : 'asc' })
|
|
54
|
+
} else {
|
|
55
|
+
setSort({ field, direction: 'asc' })
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
|
|
60
|
+
if (e.key === 'ArrowDown' && index < issues.length - 1) {
|
|
61
|
+
e.preventDefault()
|
|
62
|
+
handleSelect(issues[index + 1])
|
|
63
|
+
} else if (e.key === 'ArrowUp' && index > 0) {
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
handleSelect(issues[index - 1])
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SortHeader = ({
|
|
70
|
+
field,
|
|
71
|
+
children,
|
|
72
|
+
className: headerClassName,
|
|
73
|
+
}: {
|
|
74
|
+
field: IssueSort['field']
|
|
75
|
+
children: React.ReactNode
|
|
76
|
+
className?: string
|
|
77
|
+
}) => (
|
|
78
|
+
<TableHead
|
|
79
|
+
className={cn('cursor-pointer select-none hover:bg-muted/50', headerClassName)}
|
|
80
|
+
onClick={() => handleSort(field)}
|
|
81
|
+
>
|
|
82
|
+
<div className="flex items-center gap-1">
|
|
83
|
+
{children}
|
|
84
|
+
{sort.field === field && (
|
|
85
|
+
<span className="text-xs">{sort.direction === 'asc' ? '↑' : '↓'}</span>
|
|
86
|
+
)}
|
|
87
|
+
</div>
|
|
88
|
+
</TableHead>
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className={cn('flex flex-col gap-4', className)}>
|
|
93
|
+
{showFilters && <IssueFilters />}
|
|
94
|
+
|
|
95
|
+
<div className="rounded-md border">
|
|
96
|
+
<Table>
|
|
97
|
+
<TableHeader>
|
|
98
|
+
<TableRow>
|
|
99
|
+
<TableHead className="w-28">ID</TableHead>
|
|
100
|
+
<TableHead className="w-20">Type</TableHead>
|
|
101
|
+
<SortHeader field="title" className="max-w-md">
|
|
102
|
+
Title
|
|
103
|
+
</SortHeader>
|
|
104
|
+
<SortHeader field="status" className="w-28">
|
|
105
|
+
Status
|
|
106
|
+
</SortHeader>
|
|
107
|
+
<TableHead className="w-24">Assignee</TableHead>
|
|
108
|
+
<SortHeader field="priority" className="w-16">
|
|
109
|
+
Priority
|
|
110
|
+
</SortHeader>
|
|
111
|
+
</TableRow>
|
|
112
|
+
</TableHeader>
|
|
113
|
+
<TableBody>
|
|
114
|
+
{issues.length === 0 ? (
|
|
115
|
+
<TableRow>
|
|
116
|
+
<TableHead colSpan={6} className="text-center text-muted-foreground py-8">
|
|
117
|
+
No issues found
|
|
118
|
+
</TableHead>
|
|
119
|
+
</TableRow>
|
|
120
|
+
) : (
|
|
121
|
+
issues.map((issue, index) => (
|
|
122
|
+
<IssueRow
|
|
123
|
+
key={issue.id}
|
|
124
|
+
issue={issue}
|
|
125
|
+
isSelected={issue.id === selectedId}
|
|
126
|
+
onClick={() => handleSelect(issue)}
|
|
127
|
+
onKeyDown={(e) => handleKeyDown(e, index)}
|
|
128
|
+
/>
|
|
129
|
+
))
|
|
130
|
+
)}
|
|
131
|
+
</TableBody>
|
|
132
|
+
</Table>
|
|
133
|
+
</div>
|
|
134
|
+
|
|
135
|
+
{issues.length > 0 && (
|
|
136
|
+
<div className="text-sm text-muted-foreground">
|
|
137
|
+
{issues.length} issue{issues.length !== 1 ? 's' : ''}
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { TableRow, TableCell } from '@mdxui/primitives/table'
|
|
5
|
+
import { cn } from '@mdxui/primitives/lib/utils'
|
|
6
|
+
import { TypeBadge, StatusBadge, PriorityBadge } from './issue-badges'
|
|
7
|
+
import type { Issue } from '../types'
|
|
8
|
+
|
|
9
|
+
export interface IssueRowProps {
|
|
10
|
+
issue: Issue
|
|
11
|
+
isSelected?: boolean
|
|
12
|
+
onClick?: () => void
|
|
13
|
+
onKeyDown?: (e: React.KeyboardEvent) => void
|
|
14
|
+
className?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function IssueRow({ issue, isSelected, onClick, onKeyDown, className }: IssueRowProps) {
|
|
18
|
+
return (
|
|
19
|
+
<TableRow
|
|
20
|
+
role="button"
|
|
21
|
+
tabIndex={0}
|
|
22
|
+
onClick={onClick}
|
|
23
|
+
onKeyDown={(e) => {
|
|
24
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
25
|
+
e.preventDefault()
|
|
26
|
+
onClick?.()
|
|
27
|
+
}
|
|
28
|
+
onKeyDown?.(e)
|
|
29
|
+
}}
|
|
30
|
+
className={cn(
|
|
31
|
+
'cursor-pointer transition-colors',
|
|
32
|
+
isSelected && 'bg-muted',
|
|
33
|
+
className
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<TableCell className="font-mono text-xs text-muted-foreground w-28">
|
|
37
|
+
{issue.id}
|
|
38
|
+
</TableCell>
|
|
39
|
+
<TableCell className="w-20">
|
|
40
|
+
<TypeBadge type={issue.type} />
|
|
41
|
+
</TableCell>
|
|
42
|
+
<TableCell className="max-w-md">
|
|
43
|
+
<span className="line-clamp-1">{issue.title}</span>
|
|
44
|
+
</TableCell>
|
|
45
|
+
<TableCell className="w-28">
|
|
46
|
+
<StatusBadge status={issue.status} />
|
|
47
|
+
</TableCell>
|
|
48
|
+
<TableCell className="w-24 text-sm text-muted-foreground">
|
|
49
|
+
{issue.assignee ?? '-'}
|
|
50
|
+
</TableCell>
|
|
51
|
+
<TableCell className="w-16">
|
|
52
|
+
<PriorityBadge priority={issue.priority} />
|
|
53
|
+
</TableCell>
|
|
54
|
+
</TableRow>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { useIssuesStore } from './use-issues-store'
|
|
3
|
+
import type { Issue } from '../types'
|
|
4
|
+
|
|
5
|
+
export interface BeadsConfig {
|
|
6
|
+
/** Base URL for the beads API server */
|
|
7
|
+
apiUrl?: string
|
|
8
|
+
/** WebSocket URL for live updates */
|
|
9
|
+
wsUrl?: string
|
|
10
|
+
/** Enable live updates via WebSocket */
|
|
11
|
+
liveUpdates?: boolean
|
|
12
|
+
/** Polling interval in ms (fallback if WS not available) */
|
|
13
|
+
pollInterval?: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface BeadsTransport {
|
|
17
|
+
/** Send an RPC request to the beads server */
|
|
18
|
+
send: (method: string, params: Record<string, unknown>) => Promise<unknown>
|
|
19
|
+
/** Connect to WebSocket for live updates */
|
|
20
|
+
connect: () => void
|
|
21
|
+
/** Disconnect WebSocket */
|
|
22
|
+
disconnect: () => void
|
|
23
|
+
/** Connection status */
|
|
24
|
+
isConnected: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Hook for connecting to the beads CLI/server.
|
|
29
|
+
* Provides real-time updates and RPC transport.
|
|
30
|
+
*/
|
|
31
|
+
export function useBeads(config: BeadsConfig = {}): BeadsTransport {
|
|
32
|
+
const {
|
|
33
|
+
apiUrl = 'http://localhost:5000/api',
|
|
34
|
+
wsUrl = 'ws://localhost:5000/ws',
|
|
35
|
+
liveUpdates = true,
|
|
36
|
+
pollInterval = 30000,
|
|
37
|
+
} = config
|
|
38
|
+
|
|
39
|
+
const wsRef = useRef<WebSocket | null>(null)
|
|
40
|
+
const [isConnected, setIsConnected] = useState(false)
|
|
41
|
+
const setIssues = useIssuesStore((s) => s.setIssues)
|
|
42
|
+
const updateIssue = useIssuesStore((s) => s.updateIssue)
|
|
43
|
+
const addIssue = useIssuesStore((s) => s.addIssue)
|
|
44
|
+
const removeIssue = useIssuesStore((s) => s.removeIssue)
|
|
45
|
+
|
|
46
|
+
// Fetch all issues from the server
|
|
47
|
+
const fetchIssues = useCallback(async () => {
|
|
48
|
+
try {
|
|
49
|
+
const response = await fetch(`${apiUrl}/issues`)
|
|
50
|
+
if (!response.ok) throw new Error('Failed to fetch issues')
|
|
51
|
+
const data = await response.json()
|
|
52
|
+
setIssues(mapBeadsIssuesToIssues(data.issues ?? []))
|
|
53
|
+
} catch (error) {
|
|
54
|
+
console.error('Failed to fetch issues:', error)
|
|
55
|
+
}
|
|
56
|
+
}, [apiUrl, setIssues])
|
|
57
|
+
|
|
58
|
+
// Send RPC request
|
|
59
|
+
const send = useCallback(
|
|
60
|
+
async (method: string, params: Record<string, unknown>): Promise<unknown> => {
|
|
61
|
+
const response = await fetch(`${apiUrl}/rpc`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ method, params }),
|
|
65
|
+
})
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const error = await response.json().catch(() => ({ message: 'Unknown error' }))
|
|
68
|
+
throw new Error(error.message ?? 'RPC request failed')
|
|
69
|
+
}
|
|
70
|
+
return response.json()
|
|
71
|
+
},
|
|
72
|
+
[apiUrl]
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// WebSocket connection
|
|
76
|
+
const connect = useCallback(() => {
|
|
77
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) return
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const ws = new WebSocket(wsUrl)
|
|
81
|
+
|
|
82
|
+
ws.onopen = () => {
|
|
83
|
+
console.log('WebSocket connected')
|
|
84
|
+
setIsConnected(true)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
ws.onclose = () => {
|
|
88
|
+
console.log('WebSocket disconnected')
|
|
89
|
+
setIsConnected(false)
|
|
90
|
+
// Reconnect after delay
|
|
91
|
+
setTimeout(connect, 5000)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
ws.onerror = (error) => {
|
|
95
|
+
console.error('WebSocket error:', error)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
ws.onmessage = (event) => {
|
|
99
|
+
try {
|
|
100
|
+
const message = JSON.parse(event.data)
|
|
101
|
+
handleLiveUpdate(message)
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error('Failed to parse WebSocket message:', error)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
wsRef.current = ws
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error('Failed to connect WebSocket:', error)
|
|
110
|
+
}
|
|
111
|
+
}, [wsUrl])
|
|
112
|
+
|
|
113
|
+
const disconnect = useCallback(() => {
|
|
114
|
+
if (wsRef.current) {
|
|
115
|
+
wsRef.current.close()
|
|
116
|
+
wsRef.current = null
|
|
117
|
+
}
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
120
|
+
// Handle live updates from WebSocket
|
|
121
|
+
const handleLiveUpdate = (message: { type: string; payload: unknown }) => {
|
|
122
|
+
switch (message.type) {
|
|
123
|
+
case 'issue:created':
|
|
124
|
+
addIssue(mapBeadsIssueToIssue(message.payload as BeadsIssue))
|
|
125
|
+
break
|
|
126
|
+
case 'issue:updated':
|
|
127
|
+
const updated = message.payload as BeadsIssue
|
|
128
|
+
updateIssue(updated.id, mapBeadsIssueToIssue(updated))
|
|
129
|
+
break
|
|
130
|
+
case 'issue:deleted':
|
|
131
|
+
removeIssue((message.payload as { id: string }).id)
|
|
132
|
+
break
|
|
133
|
+
case 'issues:refresh':
|
|
134
|
+
fetchIssues()
|
|
135
|
+
break
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Initial fetch and setup
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
fetchIssues()
|
|
142
|
+
|
|
143
|
+
if (liveUpdates) {
|
|
144
|
+
connect()
|
|
145
|
+
} else if (pollInterval > 0) {
|
|
146
|
+
const interval = setInterval(fetchIssues, pollInterval)
|
|
147
|
+
return () => clearInterval(interval)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return disconnect
|
|
151
|
+
}, [fetchIssues, connect, disconnect, liveUpdates, pollInterval])
|
|
152
|
+
|
|
153
|
+
return { send, connect, disconnect, isConnected }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Beads-specific issue format (from beads CLI)
|
|
157
|
+
interface BeadsIssue {
|
|
158
|
+
id: string
|
|
159
|
+
title: string
|
|
160
|
+
description?: string
|
|
161
|
+
type: string
|
|
162
|
+
status: string
|
|
163
|
+
priority: number
|
|
164
|
+
assignee?: string
|
|
165
|
+
labels?: string[]
|
|
166
|
+
epic?: string
|
|
167
|
+
deps?: string[]
|
|
168
|
+
dependents?: string[]
|
|
169
|
+
design?: string
|
|
170
|
+
acceptance?: string
|
|
171
|
+
notes?: string
|
|
172
|
+
created: string
|
|
173
|
+
updated: string
|
|
174
|
+
closed?: string
|
|
175
|
+
close_reason?: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Map beads format to our Issue type
|
|
179
|
+
function mapBeadsIssueToIssue(beads: BeadsIssue): Issue {
|
|
180
|
+
return {
|
|
181
|
+
id: beads.id,
|
|
182
|
+
title: beads.title,
|
|
183
|
+
description: beads.description,
|
|
184
|
+
type: beads.type as Issue['type'],
|
|
185
|
+
status: beads.status as Issue['status'],
|
|
186
|
+
priority: beads.priority as Issue['priority'],
|
|
187
|
+
assignee: beads.assignee,
|
|
188
|
+
labels: beads.labels ?? [],
|
|
189
|
+
epic: beads.epic,
|
|
190
|
+
dependencies: beads.deps ?? [],
|
|
191
|
+
dependents: beads.dependents ?? [],
|
|
192
|
+
design: beads.design,
|
|
193
|
+
acceptance: beads.acceptance,
|
|
194
|
+
notes: beads.notes,
|
|
195
|
+
createdAt: beads.created,
|
|
196
|
+
updatedAt: beads.updated,
|
|
197
|
+
closedAt: beads.closed,
|
|
198
|
+
closeReason: beads.close_reason,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function mapBeadsIssuesToIssues(beads: BeadsIssue[]): Issue[] {
|
|
203
|
+
return beads.map(mapBeadsIssueToIssue)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Map our Issue type to beads format for updates
|
|
207
|
+
export function mapIssueToBeads(issue: Partial<Issue>): Partial<BeadsIssue> {
|
|
208
|
+
const beads: Partial<BeadsIssue> = {}
|
|
209
|
+
|
|
210
|
+
if (issue.title !== undefined) beads.title = issue.title
|
|
211
|
+
if (issue.description !== undefined) beads.description = issue.description
|
|
212
|
+
if (issue.type !== undefined) beads.type = issue.type
|
|
213
|
+
if (issue.status !== undefined) beads.status = issue.status
|
|
214
|
+
if (issue.priority !== undefined) beads.priority = issue.priority
|
|
215
|
+
if (issue.assignee !== undefined) beads.assignee = issue.assignee
|
|
216
|
+
if (issue.labels !== undefined) beads.labels = issue.labels
|
|
217
|
+
if (issue.epic !== undefined) beads.epic = issue.epic
|
|
218
|
+
if (issue.dependencies !== undefined) beads.deps = issue.dependencies
|
|
219
|
+
if (issue.design !== undefined) beads.design = issue.design
|
|
220
|
+
if (issue.acceptance !== undefined) beads.acceptance = issue.acceptance
|
|
221
|
+
if (issue.notes !== undefined) beads.notes = issue.notes
|
|
222
|
+
|
|
223
|
+
return beads
|
|
224
|
+
}
|