beads-kanban-ui 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 (154) hide show
  1. package/.designs/beads-kanban-ui-bj0.md +73 -0
  2. package/.designs/beads-kanban-ui-qxq.md +144 -0
  3. package/.designs/epic-support.md +282 -0
  4. package/.env.local.example +2 -0
  5. package/.eslintrc.json +3 -0
  6. package/.gitattributes +3 -0
  7. package/.github/workflows/release.yml +123 -0
  8. package/.history/README_20260121193710.md +227 -0
  9. package/.history/README_20260121193918.md +227 -0
  10. package/.history/README_20260121193921.md +227 -0
  11. package/.history/README_20260121193933.md +227 -0
  12. package/.history/README_20260121193934.md +227 -0
  13. package/.history/README_20260121193944.md +227 -0
  14. package/.history/README_20260121193953.md +227 -0
  15. package/.history/src/app/page_20260121133429.tsx +134 -0
  16. package/.history/src/app/page_20260121133928.tsx +134 -0
  17. package/.history/src/app/page_20260121144850.tsx +138 -0
  18. package/.history/src/app/page_20260121144854.tsx +138 -0
  19. package/.history/src/app/page_20260121144858.tsx +138 -0
  20. package/.history/src/app/page_20260121144902.tsx +138 -0
  21. package/.history/src/app/page_20260121144906.tsx +138 -0
  22. package/.history/src/app/page_20260121144911.tsx +138 -0
  23. package/.history/src/app/page_20260121144928.tsx +138 -0
  24. package/.playwright-mcp/.playwright-mcp/morphing-dialog-wheel-scroll-fix.png +0 -0
  25. package/.playwright-mcp/beams-test.png +0 -0
  26. package/.playwright-mcp/card-verification.png +0 -0
  27. package/.playwright-mcp/design-doc-dialog-fix-verification.png +0 -0
  28. package/.playwright-mcp/dialog-width-test.png +0 -0
  29. package/.playwright-mcp/homepage.png +0 -0
  30. package/.playwright-mcp/morphing-dialog-expanded.png +0 -0
  31. package/.playwright-mcp/morphing-dialog-fixes-final.png +0 -0
  32. package/.playwright-mcp/morphing-dialog-open.png +0 -0
  33. package/.playwright-mcp/page-2026-01-21T14-08-31-529Z.png +0 -0
  34. package/.playwright-mcp/page-2026-01-21T14-09-23-431Z.png +0 -0
  35. package/.playwright-mcp/page-2026-01-21T14-10-28-773Z.png +0 -0
  36. package/.playwright-mcp/page-2026-01-21T14-10-47-432Z.png +0 -0
  37. package/.playwright-mcp/page-2026-01-21T14-11-12-350Z.png +0 -0
  38. package/.playwright-mcp/screenshot-after-click.png +0 -0
  39. package/.playwright-mcp/screenshot-after-dialog-click.png +0 -0
  40. package/.playwright-mcp/sheet-restored-after-dialog-close.png +0 -0
  41. package/.playwright-mcp/test-1-sheet-open-with-overlay.png +0 -0
  42. package/.playwright-mcp/test-2-morphing-dialog-with-overlay.png +0 -0
  43. package/.playwright-mcp/test-3-sheet-open-dark-overlay.png +0 -0
  44. package/.playwright-mcp/test-4-morphing-dialog-with-dark-overlay.png +0 -0
  45. package/.playwright-mcp/test-5-morphing-dialog-scrolled.png +0 -0
  46. package/.playwright-mcp/test-6-sheet-restored-after-dialog-close.png +0 -0
  47. package/.playwright-mcp/wheel-scroll-fixed.png +0 -0
  48. package/README.md +243 -0
  49. package/Screenshots/bead-detail.png +0 -0
  50. package/Screenshots/dashboard.png +0 -0
  51. package/Screenshots/kanban-board.png +0 -0
  52. package/components.json +27 -0
  53. package/logo/logo.svg +1 -0
  54. package/next.config.js +9 -0
  55. package/npm/README.md +37 -0
  56. package/npm/bin/cli.js +107 -0
  57. package/npm/package.json +20 -0
  58. package/npm/scripts/postinstall.js +132 -0
  59. package/package.json +62 -0
  60. package/postcss.config.js +6 -0
  61. package/public/logo.svg +1 -0
  62. package/restart.sh +5 -0
  63. package/server/Cargo.lock +1685 -0
  64. package/server/Cargo.toml +24 -0
  65. package/server/src/db.rs +570 -0
  66. package/server/src/main.rs +141 -0
  67. package/server/src/routes/beads.rs +413 -0
  68. package/server/src/routes/cli.rs +150 -0
  69. package/server/src/routes/fs.rs +360 -0
  70. package/server/src/routes/git.rs +169 -0
  71. package/server/src/routes/mod.rs +107 -0
  72. package/server/src/routes/projects.rs +177 -0
  73. package/server/src/routes/watch.rs +211 -0
  74. package/src/app/globals.css +101 -0
  75. package/src/app/layout.tsx +36 -0
  76. package/src/app/page.tsx +348 -0
  77. package/src/app/project/kanban-board.tsx +356 -0
  78. package/src/app/project/page.tsx +18 -0
  79. package/src/app/settings/page.tsx +224 -0
  80. package/src/components/Beams.css +5 -0
  81. package/src/components/Beams.jsx +307 -0
  82. package/src/components/Galaxy.css +5 -0
  83. package/src/components/Galaxy.jsx +333 -0
  84. package/src/components/activity-timeline.tsx +172 -0
  85. package/src/components/add-project-dialog.tsx +219 -0
  86. package/src/components/bead-card.tsx +196 -0
  87. package/src/components/bead-detail.tsx +306 -0
  88. package/src/components/color-picker.tsx +101 -0
  89. package/src/components/comment-input.tsx +155 -0
  90. package/src/components/comment-list.tsx +147 -0
  91. package/src/components/dependency-badge.tsx +106 -0
  92. package/src/components/design-doc-dialog.tsx +58 -0
  93. package/src/components/design-doc-preview.tsx +97 -0
  94. package/src/components/design-doc-viewer.tsx +199 -0
  95. package/src/components/editable-project-name.tsx +178 -0
  96. package/src/components/epic-card.tsx +263 -0
  97. package/src/components/folder-browser.tsx +273 -0
  98. package/src/components/footer.tsx +27 -0
  99. package/src/components/kanban/default.tsx +184 -0
  100. package/src/components/kanban-column.tsx +167 -0
  101. package/src/components/project-card.tsx +191 -0
  102. package/src/components/quick-filter-bar.tsx +279 -0
  103. package/src/components/scan-directory-dialog.tsx +368 -0
  104. package/src/components/status-donut.tsx +197 -0
  105. package/src/components/subtask-list.tsx +128 -0
  106. package/src/components/tag-picker.tsx +252 -0
  107. package/src/components/ui/.gitkeep +0 -0
  108. package/src/components/ui/alert-dialog.tsx +141 -0
  109. package/src/components/ui/avatar.tsx +67 -0
  110. package/src/components/ui/badge.tsx +230 -0
  111. package/src/components/ui/button.tsx +433 -0
  112. package/src/components/ui/card/index.tsx +24 -0
  113. package/src/components/ui/card/roiui-card.module.css +197 -0
  114. package/src/components/ui/card/roiui-card.tsx +154 -0
  115. package/src/components/ui/card/shadcn-card.tsx +76 -0
  116. package/src/components/ui/chart.tsx +369 -0
  117. package/src/components/ui/dialog.tsx +122 -0
  118. package/src/components/ui/dropdown-menu.tsx +201 -0
  119. package/src/components/ui/input.tsx +22 -0
  120. package/src/components/ui/kanban.tsx +522 -0
  121. package/src/components/ui/morphing-dialog.tsx +457 -0
  122. package/src/components/ui/popover.tsx +33 -0
  123. package/src/components/ui/progress.tsx +28 -0
  124. package/src/components/ui/scroll-area.tsx +48 -0
  125. package/src/components/ui/select.tsx +159 -0
  126. package/src/components/ui/separator.tsx +31 -0
  127. package/src/components/ui/sheet.tsx +142 -0
  128. package/src/components/ui/skeleton.tsx +15 -0
  129. package/src/components/ui/toast.tsx +129 -0
  130. package/src/components/ui/toaster.tsx +35 -0
  131. package/src/components/ui/tooltip.tsx +30 -0
  132. package/src/hooks/.gitkeep +0 -0
  133. package/src/hooks/use-bead-filters.ts +261 -0
  134. package/src/hooks/use-beads.ts +162 -0
  135. package/src/hooks/use-branch-statuses.ts +161 -0
  136. package/src/hooks/use-epics.ts +173 -0
  137. package/src/hooks/use-file-watcher.ts +111 -0
  138. package/src/hooks/use-keyboard-navigation.ts +282 -0
  139. package/src/hooks/use-project.ts +61 -0
  140. package/src/hooks/use-projects.ts +93 -0
  141. package/src/hooks/use-toast.ts +194 -0
  142. package/src/hooks/useClickOutside.tsx +26 -0
  143. package/src/lib/.gitkeep +0 -0
  144. package/src/lib/api.ts +186 -0
  145. package/src/lib/beads-parser.ts +252 -0
  146. package/src/lib/cli.ts +193 -0
  147. package/src/lib/db.ts +145 -0
  148. package/src/lib/design-doc.ts +74 -0
  149. package/src/lib/epic-parser.ts +242 -0
  150. package/src/lib/git.ts +102 -0
  151. package/src/lib/utils.ts +12 -0
  152. package/src/types/index.ts +107 -0
  153. package/tailwind.config.ts +85 -0
  154. package/tsconfig.json +26 -0
@@ -0,0 +1,93 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import {
5
+ getProjectsWithTags,
6
+ createProject,
7
+ type CreateProjectInput,
8
+ } from "@/lib/db";
9
+ import { loadProjectBeads, groupBeadsByStatus } from "@/lib/beads-parser";
10
+ import type { Project, Tag, BeadCounts } from "@/types";
11
+
12
+ interface UseProjectsResult {
13
+ projects: Project[];
14
+ isLoading: boolean;
15
+ error: Error | null;
16
+ refetch: () => Promise<void>;
17
+ addProject: (input: CreateProjectInput) => Promise<Project>;
18
+ updateProjectTags: (projectId: string, tags: Tag[]) => void;
19
+ }
20
+
21
+ export function useProjects(): UseProjectsResult {
22
+ const [projects, setProjects] = useState<Project[]>([]);
23
+ const [isLoading, setIsLoading] = useState(true);
24
+ const [error, setError] = useState<Error | null>(null);
25
+
26
+ const fetchProjects = useCallback(async () => {
27
+ try {
28
+ setIsLoading(true);
29
+ setError(null);
30
+ const data = await getProjectsWithTags();
31
+
32
+ // Fetch bead counts for all projects in parallel
33
+ const projectsWithCounts = await Promise.all(
34
+ data.map(async (project) => {
35
+ try {
36
+ const beads = await loadProjectBeads(project.path);
37
+ const grouped = groupBeadsByStatus(beads);
38
+ const beadCounts: BeadCounts = {
39
+ open: grouped.open.length,
40
+ in_progress: grouped.in_progress.length,
41
+ inreview: grouped.inreview.length,
42
+ closed: grouped.closed.length,
43
+ };
44
+ return { ...project, beadCounts };
45
+ } catch {
46
+ // If loading beads fails, return project with zero counts
47
+ return {
48
+ ...project,
49
+ beadCounts: { open: 0, in_progress: 0, inreview: 0, closed: 0 },
50
+ };
51
+ }
52
+ })
53
+ );
54
+
55
+ setProjects(projectsWithCounts);
56
+ } catch (err) {
57
+ setError(err instanceof Error ? err : new Error("Failed to fetch projects"));
58
+ } finally {
59
+ setIsLoading(false);
60
+ }
61
+ }, []);
62
+
63
+ const addProject = useCallback(
64
+ async (input: CreateProjectInput): Promise<Project> => {
65
+ const newProject = await createProject(input);
66
+ await fetchProjects();
67
+ return newProject;
68
+ },
69
+ [fetchProjects]
70
+ );
71
+
72
+ const updateProjectTags = useCallback((projectId: string, tags: Tag[]) => {
73
+ setProjects((prev) =>
74
+ prev.map((project) =>
75
+ project.id === projectId ? { ...project, tags } : project
76
+ )
77
+ );
78
+ }, []);
79
+
80
+ // Fetch projects on mount
81
+ useEffect(() => {
82
+ fetchProjects();
83
+ }, [fetchProjects]);
84
+
85
+ return {
86
+ projects,
87
+ isLoading,
88
+ error,
89
+ refetch: fetchProjects,
90
+ addProject,
91
+ updateProjectTags,
92
+ };
93
+ }
@@ -0,0 +1,194 @@
1
+ "use client"
2
+
3
+ // Inspired by react-hot-toast library
4
+ import * as React from "react"
5
+
6
+ import type {
7
+ ToastActionElement,
8
+ ToastProps,
9
+ } from "@/components/ui/toast"
10
+
11
+ const TOAST_LIMIT = 1
12
+ const TOAST_REMOVE_DELAY = 1000000
13
+
14
+ type ToasterToast = ToastProps & {
15
+ id: string
16
+ title?: React.ReactNode
17
+ description?: React.ReactNode
18
+ action?: ToastActionElement
19
+ }
20
+
21
+ const actionTypes = {
22
+ ADD_TOAST: "ADD_TOAST",
23
+ UPDATE_TOAST: "UPDATE_TOAST",
24
+ DISMISS_TOAST: "DISMISS_TOAST",
25
+ REMOVE_TOAST: "REMOVE_TOAST",
26
+ } as const
27
+
28
+ let count = 0
29
+
30
+ function genId() {
31
+ count = (count + 1) % Number.MAX_SAFE_INTEGER
32
+ return count.toString()
33
+ }
34
+
35
+ type ActionType = typeof actionTypes
36
+
37
+ type Action =
38
+ | {
39
+ type: ActionType["ADD_TOAST"]
40
+ toast: ToasterToast
41
+ }
42
+ | {
43
+ type: ActionType["UPDATE_TOAST"]
44
+ toast: Partial<ToasterToast>
45
+ }
46
+ | {
47
+ type: ActionType["DISMISS_TOAST"]
48
+ toastId?: ToasterToast["id"]
49
+ }
50
+ | {
51
+ type: ActionType["REMOVE_TOAST"]
52
+ toastId?: ToasterToast["id"]
53
+ }
54
+
55
+ interface State {
56
+ toasts: ToasterToast[]
57
+ }
58
+
59
+ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
60
+
61
+ const addToRemoveQueue = (toastId: string) => {
62
+ if (toastTimeouts.has(toastId)) {
63
+ return
64
+ }
65
+
66
+ const timeout = setTimeout(() => {
67
+ toastTimeouts.delete(toastId)
68
+ dispatch({
69
+ type: "REMOVE_TOAST",
70
+ toastId: toastId,
71
+ })
72
+ }, TOAST_REMOVE_DELAY)
73
+
74
+ toastTimeouts.set(toastId, timeout)
75
+ }
76
+
77
+ export const reducer = (state: State, action: Action): State => {
78
+ switch (action.type) {
79
+ case "ADD_TOAST":
80
+ return {
81
+ ...state,
82
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
83
+ }
84
+
85
+ case "UPDATE_TOAST":
86
+ return {
87
+ ...state,
88
+ toasts: state.toasts.map((t) =>
89
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
90
+ ),
91
+ }
92
+
93
+ case "DISMISS_TOAST": {
94
+ const { toastId } = action
95
+
96
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
97
+ // but I'll keep it here for simplicity
98
+ if (toastId) {
99
+ addToRemoveQueue(toastId)
100
+ } else {
101
+ state.toasts.forEach((toast) => {
102
+ addToRemoveQueue(toast.id)
103
+ })
104
+ }
105
+
106
+ return {
107
+ ...state,
108
+ toasts: state.toasts.map((t) =>
109
+ t.id === toastId || toastId === undefined
110
+ ? {
111
+ ...t,
112
+ open: false,
113
+ }
114
+ : t
115
+ ),
116
+ }
117
+ }
118
+ case "REMOVE_TOAST":
119
+ if (action.toastId === undefined) {
120
+ return {
121
+ ...state,
122
+ toasts: [],
123
+ }
124
+ }
125
+ return {
126
+ ...state,
127
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
128
+ }
129
+ }
130
+ }
131
+
132
+ const listeners: Array<(state: State) => void> = []
133
+
134
+ let memoryState: State = { toasts: [] }
135
+
136
+ function dispatch(action: Action) {
137
+ memoryState = reducer(memoryState, action)
138
+ listeners.forEach((listener) => {
139
+ listener(memoryState)
140
+ })
141
+ }
142
+
143
+ type Toast = Omit<ToasterToast, "id">
144
+
145
+ function toast({ ...props }: Toast) {
146
+ const id = genId()
147
+
148
+ const update = (props: ToasterToast) =>
149
+ dispatch({
150
+ type: "UPDATE_TOAST",
151
+ toast: { ...props, id },
152
+ })
153
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
154
+
155
+ dispatch({
156
+ type: "ADD_TOAST",
157
+ toast: {
158
+ ...props,
159
+ id,
160
+ open: true,
161
+ onOpenChange: (open) => {
162
+ if (!open) dismiss()
163
+ },
164
+ },
165
+ })
166
+
167
+ return {
168
+ id: id,
169
+ dismiss,
170
+ update,
171
+ }
172
+ }
173
+
174
+ function useToast() {
175
+ const [state, setState] = React.useState<State>(memoryState)
176
+
177
+ React.useEffect(() => {
178
+ listeners.push(setState)
179
+ return () => {
180
+ const index = listeners.indexOf(setState)
181
+ if (index > -1) {
182
+ listeners.splice(index, 1)
183
+ }
184
+ }
185
+ }, [state])
186
+
187
+ return {
188
+ ...state,
189
+ toast,
190
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
191
+ }
192
+ }
193
+
194
+ export { useToast, toast }
@@ -0,0 +1,26 @@
1
+ import { RefObject, useEffect } from 'react';
2
+
3
+ function useClickOutside<T extends HTMLElement>(
4
+ ref: RefObject<T | null>,
5
+ handler: (event: MouseEvent | TouchEvent) => void
6
+ ): void {
7
+ useEffect(() => {
8
+ const handleClickOutside = (event: MouseEvent | TouchEvent) => {
9
+ if (!ref || !ref.current || ref.current.contains(event.target as Node)) {
10
+ return;
11
+ }
12
+
13
+ handler(event);
14
+ };
15
+
16
+ document.addEventListener('mousedown', handleClickOutside);
17
+ document.addEventListener('touchstart', handleClickOutside);
18
+
19
+ return () => {
20
+ document.removeEventListener('mousedown', handleClickOutside);
21
+ document.removeEventListener('touchstart', handleClickOutside);
22
+ };
23
+ }, [ref, handler]);
24
+ }
25
+
26
+ export default useClickOutside;
File without changes
package/src/lib/api.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Frontend API layer for beads-kanban-ui webapp
3
+ * Replaces Tauri invoke() calls with HTTP fetch to backend
4
+ */
5
+
6
+ import type { Project, Tag, Bead } from '@/types';
7
+
8
+ const API_BASE = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3008';
9
+
10
+ /**
11
+ * Input for creating a new project
12
+ */
13
+ export interface CreateProjectInput {
14
+ name: string;
15
+ path: string;
16
+ }
17
+
18
+ /**
19
+ * Input for creating a new tag
20
+ */
21
+ export interface CreateTagInput {
22
+ name: string;
23
+ color: string;
24
+ }
25
+
26
+ /**
27
+ * File system entry from directory listing
28
+ */
29
+ export interface FsEntry {
30
+ name: string;
31
+ path: string;
32
+ isDirectory: boolean;
33
+ }
34
+
35
+ /**
36
+ * Git branch status information
37
+ */
38
+ export interface BranchStatus {
39
+ exists: boolean;
40
+ ahead: number;
41
+ behind: number;
42
+ }
43
+
44
+ /**
45
+ * BD CLI command result
46
+ */
47
+ export interface BdCommandResult {
48
+ stdout: string;
49
+ stderr: string;
50
+ code: number;
51
+ }
52
+
53
+ /**
54
+ * File watcher event
55
+ */
56
+ export interface WatchEvent {
57
+ path: string;
58
+ type: string;
59
+ }
60
+
61
+ /**
62
+ * Helper for fetch with error handling
63
+ */
64
+ async function fetchApi<T>(path: string, options?: RequestInit): Promise<T> {
65
+ const res = await fetch(`${API_BASE}${path}`, {
66
+ ...options,
67
+ headers: {
68
+ 'Content-Type': 'application/json',
69
+ ...options?.headers,
70
+ },
71
+ });
72
+ if (!res.ok) {
73
+ throw new Error(`API error: ${res.status} ${res.statusText}`);
74
+ }
75
+ return res.json();
76
+ }
77
+
78
+ /**
79
+ * Projects API
80
+ */
81
+ export const projects = {
82
+ list: () => fetchApi<Project[]>('/api/projects'),
83
+
84
+ create: (data: CreateProjectInput) => fetchApi<Project>('/api/projects', {
85
+ method: 'POST',
86
+ body: JSON.stringify(data),
87
+ }),
88
+
89
+ update: (id: string, data: Partial<Project>) => fetchApi<Project>(`/api/projects/${id}`, {
90
+ method: 'PATCH',
91
+ body: JSON.stringify(data),
92
+ }),
93
+
94
+ delete: (id: string) => fetchApi<void>(`/api/projects/${id}`, { method: 'DELETE' }),
95
+ };
96
+
97
+ /**
98
+ * Tags API
99
+ */
100
+ export const tags = {
101
+ list: () => fetchApi<Tag[]>('/api/tags'),
102
+
103
+ create: (data: CreateTagInput) => fetchApi<Tag>('/api/tags', {
104
+ method: 'POST',
105
+ body: JSON.stringify(data),
106
+ }),
107
+
108
+ delete: (id: string) => fetchApi<void>(`/api/tags/${id}`, { method: 'DELETE' }),
109
+
110
+ addToProject: (projectId: string, tagId: string) => fetchApi<void>('/api/project-tags', {
111
+ method: 'POST',
112
+ body: JSON.stringify({ projectId, tagId }),
113
+ }),
114
+
115
+ removeFromProject: (projectId: string, tagId: string) => fetchApi<void>(
116
+ `/api/project-tags/${projectId}/${tagId}`,
117
+ { method: 'DELETE' }
118
+ ),
119
+ };
120
+
121
+ /**
122
+ * Beads API
123
+ */
124
+ export const beads = {
125
+ read: (path: string) => fetchApi<{ beads: Bead[] }>(
126
+ `/api/beads?path=${encodeURIComponent(path)}`
127
+ ),
128
+
129
+ addComment: (path: string, beadId: string, text: string, author: string) =>
130
+ fetchApi<Bead>('/api/beads/comment', {
131
+ method: 'POST',
132
+ body: JSON.stringify({ path, bead_id: beadId, text, author }),
133
+ }),
134
+ };
135
+
136
+ /**
137
+ * BD CLI API
138
+ */
139
+ export const bd = {
140
+ command: (args: string[], cwd?: string) => fetchApi<BdCommandResult>('/api/bd/command', {
141
+ method: 'POST',
142
+ body: JSON.stringify({ args, cwd }),
143
+ }),
144
+ };
145
+
146
+ /**
147
+ * Git API
148
+ */
149
+ export const git = {
150
+ branchStatus: (path: string, branch: string) => fetchApi<BranchStatus>(
151
+ `/api/git/branch-status?path=${encodeURIComponent(path)}&branch=${encodeURIComponent(branch)}`
152
+ ),
153
+ };
154
+
155
+ /**
156
+ * File System API
157
+ */
158
+ export const fs = {
159
+ list: (path: string) => fetchApi<{ entries: FsEntry[] }>(
160
+ `/api/fs/list?path=${encodeURIComponent(path)}`
161
+ ),
162
+
163
+ exists: (path: string) => fetchApi<{ exists: boolean }>(
164
+ `/api/fs/exists?path=${encodeURIComponent(path)}`
165
+ ),
166
+
167
+ openExternal: (path: string, target: 'vscode' | 'cursor' | 'finder') =>
168
+ fetchApi<{ success: boolean }>('/api/fs/open-external', {
169
+ method: 'POST',
170
+ body: JSON.stringify({ path, target }),
171
+ }),
172
+ };
173
+
174
+ /**
175
+ * File Watcher (Server-Sent Events)
176
+ */
177
+ export const watch = {
178
+ beads: (path: string, onEvent: (event: WatchEvent) => void) => {
179
+ const eventSource = new EventSource(
180
+ `${API_BASE}/api/watch/beads?path=${encodeURIComponent(path)}`
181
+ );
182
+ eventSource.onmessage = (e) => onEvent(JSON.parse(e.data));
183
+ eventSource.onerror = () => eventSource.close();
184
+ return () => eventSource.close();
185
+ },
186
+ };