prjct-cli 0.12.1 → 0.13.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 (38) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/CLAUDE.md +18 -6
  3. package/bin/serve.js +12 -30
  4. package/core/data/index.ts +19 -5
  5. package/core/data/md-base-manager.ts +203 -0
  6. package/core/data/md-queue-manager.ts +179 -0
  7. package/core/data/md-state-manager.ts +133 -0
  8. package/core/serializers/index.ts +20 -0
  9. package/core/serializers/queue-serializer.ts +210 -0
  10. package/core/serializers/state-serializer.ts +136 -0
  11. package/core/utils/file-helper.ts +12 -0
  12. package/package.json +1 -1
  13. package/packages/web/app/api/projects/[id]/stats/route.ts +6 -29
  14. package/packages/web/app/project/[id]/page.tsx +34 -1
  15. package/packages/web/app/project/[id]/stats/page.tsx +11 -5
  16. package/packages/web/app/settings/page.tsx +2 -221
  17. package/packages/web/components/AppSidebar/AppSidebar.tsx +5 -3
  18. package/packages/web/components/BlockersCard/BlockersCard.tsx +67 -0
  19. package/packages/web/components/BlockersCard/BlockersCard.types.ts +11 -0
  20. package/packages/web/components/BlockersCard/index.ts +2 -0
  21. package/packages/web/components/CommandButton/CommandButton.tsx +10 -3
  22. package/packages/web/lib/projects.ts +28 -27
  23. package/packages/web/lib/services/projects.server.ts +25 -21
  24. package/packages/web/lib/services/stats.server.ts +355 -57
  25. package/packages/web/package.json +0 -2
  26. package/templates/commands/decision.md +226 -0
  27. package/templates/commands/done.md +100 -68
  28. package/templates/commands/feature.md +102 -103
  29. package/templates/commands/idea.md +41 -38
  30. package/templates/commands/now.md +94 -33
  31. package/templates/commands/pause.md +90 -30
  32. package/templates/commands/ship.md +179 -74
  33. package/templates/commands/sync.md +324 -200
  34. package/packages/web/app/api/migrate/route.ts +0 -46
  35. package/packages/web/app/api/settings/route.ts +0 -97
  36. package/packages/web/app/api/v2/projects/[id]/unified/route.ts +0 -57
  37. package/packages/web/lib/json-loader.ts +0 -630
  38. package/packages/web/lib/services/migration.server.ts +0 -600
@@ -0,0 +1,133 @@
1
+ /**
2
+ * MD State Manager
3
+ *
4
+ * MD-First Architecture: Manages state via now.md.
5
+ * Source of truth is the markdown file, not JSON.
6
+ */
7
+
8
+ import { MdBaseManager } from './md-base-manager'
9
+ import { parseState, serializeState } from '../serializers'
10
+ import type { StateJson, CurrentTask, PreviousTask } from '../schemas/state'
11
+
12
+ class MdStateManager extends MdBaseManager<StateJson> {
13
+ constructor() {
14
+ super('core/now.md')
15
+ }
16
+
17
+ protected getDefault(): StateJson {
18
+ return {
19
+ currentTask: null,
20
+ lastUpdated: ''
21
+ }
22
+ }
23
+
24
+ protected parse(content: string): StateJson {
25
+ return parseState(content)
26
+ }
27
+
28
+ protected serialize(data: StateJson): string {
29
+ return serializeState(data)
30
+ }
31
+
32
+ // =========== Current Task ===========
33
+
34
+ async getCurrentTask(projectId: string): Promise<CurrentTask | null> {
35
+ const state = await this.read(projectId)
36
+ return state.currentTask
37
+ }
38
+
39
+ async setCurrentTask(projectId: string, task: CurrentTask | null): Promise<StateJson> {
40
+ return this.update(projectId, (state) => ({
41
+ ...state,
42
+ currentTask: task,
43
+ lastUpdated: new Date().toISOString()
44
+ }))
45
+ }
46
+
47
+ async startTask(
48
+ projectId: string,
49
+ task: Omit<CurrentTask, 'startedAt'>
50
+ ): Promise<StateJson> {
51
+ const currentTask: CurrentTask = {
52
+ ...task,
53
+ startedAt: new Date().toISOString()
54
+ }
55
+
56
+ return this.update(projectId, () => ({
57
+ currentTask,
58
+ lastUpdated: new Date().toISOString()
59
+ }))
60
+ }
61
+
62
+ async completeTask(projectId: string): Promise<StateJson> {
63
+ const state = await this.read(projectId)
64
+ if (!state.currentTask) {
65
+ throw new Error('No active task to complete')
66
+ }
67
+
68
+ return this.update(projectId, () => ({
69
+ currentTask: null,
70
+ lastUpdated: new Date().toISOString()
71
+ }))
72
+ }
73
+
74
+ async pauseTask(projectId: string): Promise<StateJson> {
75
+ const state = await this.read(projectId)
76
+ if (!state.currentTask) {
77
+ throw new Error('No active task to pause')
78
+ }
79
+
80
+ const previousTask: PreviousTask = {
81
+ id: state.currentTask.id,
82
+ description: state.currentTask.description,
83
+ status: 'paused',
84
+ startedAt: state.currentTask.startedAt,
85
+ pausedAt: new Date().toISOString()
86
+ }
87
+
88
+ return this.update(projectId, () => ({
89
+ currentTask: null,
90
+ previousTask,
91
+ lastUpdated: new Date().toISOString()
92
+ }))
93
+ }
94
+
95
+ async resumeTask(projectId: string): Promise<StateJson> {
96
+ const state = await this.read(projectId)
97
+ if (!state.previousTask) {
98
+ throw new Error('No paused task to resume')
99
+ }
100
+
101
+ const currentTask: CurrentTask = {
102
+ id: state.previousTask.id,
103
+ description: state.previousTask.description,
104
+ startedAt: new Date().toISOString(), // Reset start time
105
+ sessionId: `sess_${Date.now()}`
106
+ }
107
+
108
+ return this.update(projectId, () => ({
109
+ currentTask,
110
+ previousTask: null,
111
+ lastUpdated: new Date().toISOString()
112
+ }))
113
+ }
114
+
115
+ async clearTask(projectId: string): Promise<StateJson> {
116
+ return this.update(projectId, () => ({
117
+ currentTask: null,
118
+ previousTask: null,
119
+ lastUpdated: new Date().toISOString()
120
+ }))
121
+ }
122
+
123
+ /**
124
+ * Check if there's an active or paused task
125
+ */
126
+ async hasTask(projectId: string): Promise<boolean> {
127
+ const state = await this.read(projectId)
128
+ return state.currentTask !== null || state.previousTask !== null
129
+ }
130
+ }
131
+
132
+ export const mdStateManager = new MdStateManager()
133
+ export default mdStateManager
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Serializers Index
3
+ *
4
+ * MD-First Architecture: These serializers convert between TypeScript schemas and MD format.
5
+ */
6
+
7
+ // State (now.md)
8
+ export {
9
+ parseState,
10
+ serializeState,
11
+ createCurrentTaskMd,
12
+ createEmptyStateMd
13
+ } from './state-serializer'
14
+
15
+ // Queue (next.md)
16
+ export {
17
+ parseQueue,
18
+ serializeQueue,
19
+ createEmptyQueueMd
20
+ } from './queue-serializer'
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Queue Serializer
3
+ *
4
+ * Parses and serializes next.md for task queue.
5
+ *
6
+ * MD Format (next.md):
7
+ * ```
8
+ * # Priority Queue
9
+ *
10
+ * > Tasks ready to start
11
+ *
12
+ * ## Active Tasks
13
+ * 1. [ ] Task description @agent (from: Feature Name)
14
+ * 2. [x] Completed task ✅
15
+ *
16
+ * ## Previously Active
17
+ * - [ ] Paused task
18
+ *
19
+ * ## Backlog
20
+ * - [ ] 🐛 [HIGH] Bug description
21
+ * - [ ] Feature task
22
+ * ```
23
+ */
24
+
25
+ import type { QueueJson, QueueTask, Priority, TaskType, TaskSection } from '../schemas/state'
26
+
27
+ /**
28
+ * Parse next.md content to QueueJson
29
+ */
30
+ export function parseQueue(content: string): QueueJson {
31
+ if (!content || !content.trim()) {
32
+ return { tasks: [], lastUpdated: '' }
33
+ }
34
+
35
+ const lines = content.split('\n')
36
+ const tasks: QueueTask[] = []
37
+ let currentSection: TaskSection = 'active'
38
+ let taskIndex = 0
39
+
40
+ for (const line of lines) {
41
+ // Detect section headers
42
+ if (line.match(/^##\s*Active/i)) {
43
+ currentSection = 'active'
44
+ continue
45
+ }
46
+ if (line.match(/^##\s*Previously/i)) {
47
+ currentSection = 'previously_active'
48
+ continue
49
+ }
50
+ if (line.match(/^##\s*Backlog/i)) {
51
+ currentSection = 'backlog'
52
+ continue
53
+ }
54
+
55
+ // Parse task lines: "1. [ ] Task" or "- [ ] Task" or "- [x] Task"
56
+ const taskMatch = line.match(/^(?:\d+\.|[-*])\s*\[([\sx])\]\s*(.+)$/i)
57
+ if (!taskMatch) continue
58
+
59
+ const isCompleted = taskMatch[1].toLowerCase() === 'x'
60
+ let taskText = taskMatch[2].trim()
61
+
62
+ // Extract agent: @fe, @be, @fe+be
63
+ const agentMatch = taskText.match(/@(\w+(?:\+\w+)?)/)
64
+ const agent = agentMatch ? agentMatch[1] : undefined
65
+ taskText = taskText.replace(/@\w+(?:\+\w+)?/, '').trim()
66
+
67
+ // Extract origin feature: (from: Feature Name)
68
+ const fromMatch = taskText.match(/\(from:\s*([^)]+)\)/)
69
+ const originFeature = fromMatch ? fromMatch[1].trim() : undefined
70
+ taskText = taskText.replace(/\(from:\s*[^)]+\)/, '').trim()
71
+
72
+ // Detect task type from emoji/prefix
73
+ let type: TaskType = 'feature'
74
+ let priority: Priority = 'medium'
75
+
76
+ if (taskText.includes('🐛') || taskText.toLowerCase().includes('bug')) {
77
+ type = 'bug'
78
+ taskText = taskText.replace('🐛', '').trim()
79
+ }
80
+ if (taskText.includes('🔧') || taskText.toLowerCase().includes('fix')) {
81
+ type = 'improvement'
82
+ }
83
+ if (taskText.includes('♻️') || taskText.toLowerCase().includes('refactor')) {
84
+ type = 'chore'
85
+ }
86
+
87
+ // Extract priority: [HIGH], [MEDIUM], [LOW], [CRITICAL]
88
+ const priorityMatch = taskText.match(/\[(HIGH|MEDIUM|LOW|CRITICAL)\]/i)
89
+ if (priorityMatch) {
90
+ priority = priorityMatch[1].toLowerCase() as Priority
91
+ taskText = taskText.replace(/\[(HIGH|MEDIUM|LOW|CRITICAL)\]/i, '').trim()
92
+ }
93
+
94
+ // Remove checkmarks and clean up
95
+ taskText = taskText.replace(/✅/g, '').trim()
96
+
97
+ // Skip empty descriptions
98
+ if (!taskText) continue
99
+
100
+ const task: QueueTask = {
101
+ id: `task_${Date.now()}_${taskIndex++}`,
102
+ description: taskText,
103
+ priority,
104
+ type,
105
+ completed: isCompleted,
106
+ createdAt: new Date().toISOString(),
107
+ section: currentSection
108
+ }
109
+
110
+ if (agent) task.agent = agent
111
+ if (originFeature) task.originFeature = originFeature
112
+ if (isCompleted) task.completedAt = new Date().toISOString()
113
+
114
+ tasks.push(task)
115
+ }
116
+
117
+ // Extract updated date
118
+ const updatedMatch = content.match(/_Updated:\s*(\d{4}-\d{2}-\d{2})/)
119
+ const lastUpdated = updatedMatch ? updatedMatch[1] : new Date().toISOString().split('T')[0]
120
+
121
+ return { tasks, lastUpdated }
122
+ }
123
+
124
+ /**
125
+ * Serialize QueueJson to next.md format
126
+ */
127
+ export function serializeQueue(data: QueueJson): string {
128
+ const lines: string[] = [
129
+ '# Priority Queue',
130
+ '',
131
+ '> Tasks ready to start (max 100)',
132
+ '> Auto-updated by prjct',
133
+ ''
134
+ ]
135
+
136
+ // Group tasks by section
137
+ const active = data.tasks.filter(t => t.section === 'active')
138
+ const previouslyActive = data.tasks.filter(t => t.section === 'previously_active')
139
+ const backlog = data.tasks.filter(t => t.section === 'backlog')
140
+
141
+ // Active Tasks
142
+ if (active.length > 0) {
143
+ lines.push('## Active Tasks', '')
144
+ active.forEach((task, i) => {
145
+ lines.push(formatTask(task, i + 1, true))
146
+ })
147
+ lines.push('')
148
+ }
149
+
150
+ // Previously Active
151
+ if (previouslyActive.length > 0) {
152
+ lines.push('## Previously Active', '')
153
+ previouslyActive.forEach(task => {
154
+ lines.push(formatTask(task, 0, false))
155
+ })
156
+ lines.push('')
157
+ }
158
+
159
+ // Backlog
160
+ lines.push('---', '', '## Backlog', '')
161
+ if (backlog.length > 0) {
162
+ backlog.forEach(task => {
163
+ lines.push(formatTask(task, 0, false))
164
+ })
165
+ } else {
166
+ lines.push('_No backlog items_')
167
+ }
168
+
169
+ lines.push('')
170
+ lines.push('---', '')
171
+ lines.push(`_Updated: ${data.lastUpdated || new Date().toISOString().split('T')[0]}_`)
172
+
173
+ return lines.join('\n')
174
+ }
175
+
176
+ /**
177
+ * Format a single task as markdown
178
+ */
179
+ function formatTask(task: QueueTask, num: number, numbered: boolean): string {
180
+ const checkbox = task.completed ? '[x]' : '[ ]'
181
+ const prefix = numbered && num > 0 ? `${num}.` : '-'
182
+
183
+ let text = task.description
184
+
185
+ // Add emoji for type
186
+ if (task.type === 'bug') text = `🐛 ${text}`
187
+
188
+ // Add priority tag for high/critical
189
+ if (task.priority === 'high' || task.priority === 'critical') {
190
+ text = `[${task.priority.toUpperCase()}] ${text}`
191
+ }
192
+
193
+ // Add agent
194
+ if (task.agent) text = `${text} @${task.agent}`
195
+
196
+ // Add origin feature
197
+ if (task.originFeature) text = `${text} (from: ${task.originFeature})`
198
+
199
+ // Add checkmark for completed
200
+ if (task.completed) text = `${text} ✅`
201
+
202
+ return `${prefix} ${checkbox} ${text}`
203
+ }
204
+
205
+ /**
206
+ * Quick helpers
207
+ */
208
+ export function createEmptyQueueMd(): string {
209
+ return serializeQueue({ tasks: [], lastUpdated: new Date().toISOString().split('T')[0] })
210
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * State Serializer
3
+ *
4
+ * Parses and serializes now.md for current task state.
5
+ *
6
+ * MD Format (now.md):
7
+ * ```
8
+ * # NOW
9
+ *
10
+ * **Task description here**
11
+ *
12
+ * Started: 2025-12-10T10:00:00.000Z
13
+ * Session: sess_abc123
14
+ * Feature: feat_xyz789
15
+ * Agent: fe
16
+ * ```
17
+ */
18
+
19
+ import type { StateJson, CurrentTask, PreviousTask } from '../schemas/state'
20
+
21
+ /**
22
+ * Parse now.md content to StateJson
23
+ */
24
+ export function parseState(content: string): StateJson {
25
+ if (!content || !content.trim()) {
26
+ return { currentTask: null, lastUpdated: '' }
27
+ }
28
+
29
+ const lines = content.split('\n')
30
+ let currentTask: CurrentTask | null = null
31
+ let previousTask: PreviousTask | null = null
32
+
33
+ // Find task description (bold line after # NOW)
34
+ let description = ''
35
+ for (const line of lines) {
36
+ const boldMatch = line.match(/^\*\*(.+)\*\*$/)
37
+ if (boldMatch) {
38
+ description = boldMatch[1].trim()
39
+ break
40
+ }
41
+ }
42
+
43
+ if (!description || description.toLowerCase().includes('no active task')) {
44
+ return { currentTask: null, lastUpdated: '' }
45
+ }
46
+
47
+ // Extract metadata
48
+ const startedMatch = content.match(/Started:\s*(.+)/i)
49
+ const sessionMatch = content.match(/Session:\s*(.+)/i)
50
+ const featureMatch = content.match(/Feature:\s*(.+)/i)
51
+ const idMatch = content.match(/ID:\s*(.+)/i)
52
+ const agentMatch = content.match(/Agent:\s*(.+)/i)
53
+ const pausedMatch = content.match(/Paused:\s*(.+)/i)
54
+
55
+ const id = idMatch ? idMatch[1].trim() : `task_${Date.now()}`
56
+ const startedAt = startedMatch ? startedMatch[1].trim() : new Date().toISOString()
57
+ const sessionId = sessionMatch ? sessionMatch[1].trim() : `sess_${Date.now()}`
58
+
59
+ if (pausedMatch) {
60
+ // This is a paused task
61
+ previousTask = {
62
+ id,
63
+ description,
64
+ status: 'paused',
65
+ startedAt,
66
+ pausedAt: pausedMatch[1].trim()
67
+ }
68
+ } else {
69
+ // Active task
70
+ currentTask = {
71
+ id,
72
+ description,
73
+ startedAt,
74
+ sessionId
75
+ }
76
+
77
+ if (featureMatch) {
78
+ currentTask.featureId = featureMatch[1].trim()
79
+ }
80
+ }
81
+
82
+ return {
83
+ currentTask,
84
+ previousTask,
85
+ lastUpdated: startedAt
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Serialize StateJson to now.md format
91
+ */
92
+ export function serializeState(data: StateJson): string {
93
+ const lines: string[] = ['# NOW', '']
94
+
95
+ if (!data.currentTask && !data.previousTask) {
96
+ lines.push('_No active task_', '')
97
+ lines.push('Use `/p:now` to start working on something.')
98
+ return lines.join('\n')
99
+ }
100
+
101
+ const task = data.currentTask || data.previousTask
102
+
103
+ if (task) {
104
+ lines.push(`**${task.description}**`, '')
105
+
106
+ if ('pausedAt' in task && task.pausedAt) {
107
+ lines.push(`Started: ${task.startedAt}`)
108
+ lines.push(`Paused: ${task.pausedAt}`)
109
+ } else if (data.currentTask) {
110
+ lines.push(`Started: ${data.currentTask.startedAt}`)
111
+ lines.push(`Session: ${data.currentTask.sessionId}`)
112
+ if (data.currentTask.featureId) {
113
+ lines.push(`Feature: ${data.currentTask.featureId}`)
114
+ }
115
+ }
116
+ }
117
+
118
+ return lines.join('\n')
119
+ }
120
+
121
+ /**
122
+ * Quick helpers for common operations
123
+ */
124
+ export function createCurrentTaskMd(task: CurrentTask): string {
125
+ return serializeState({
126
+ currentTask: task,
127
+ lastUpdated: task.startedAt
128
+ })
129
+ }
130
+
131
+ export function createEmptyStateMd(): string {
132
+ return serializeState({
133
+ currentTask: null,
134
+ lastUpdated: ''
135
+ })
136
+ }
@@ -66,6 +66,17 @@ export async function writeFile(filePath: string, content: string): Promise<void
66
66
  await fs.writeFile(filePath, content, 'utf-8')
67
67
  }
68
68
 
69
+ /**
70
+ * Atomic write - writes to temp file then renames (prevents partial writes)
71
+ */
72
+ export async function atomicWrite(filePath: string, content: string): Promise<void> {
73
+ const dir = path.dirname(filePath)
74
+ await fs.mkdir(dir, { recursive: true })
75
+ const tempPath = `${filePath}.${Date.now()}.tmp`
76
+ await fs.writeFile(tempPath, content, 'utf-8')
77
+ await fs.rename(tempPath, filePath)
78
+ }
79
+
69
80
  /**
70
81
  * Append to text file
71
82
  */
@@ -245,6 +256,7 @@ export default {
245
256
  writeJson,
246
257
  readFile,
247
258
  writeFile,
259
+ atomicWrite,
248
260
  fileExists,
249
261
  ensureDir,
250
262
  deleteFile,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prjct-cli",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Built for Claude - Ship fast, track progress, stay focused. Developer momentum tool for indie hackers.",
5
5
  "main": "core/index.ts",
6
6
  "bin": {
@@ -1,7 +1,11 @@
1
1
  import { NextRequest, NextResponse } from 'next/server'
2
- import { loadUnifiedJsonData, hasJsonState } from '@/lib/json-loader'
3
2
  import { getProjectStats, getRawProjectFiles } from '@/lib/parse-prjct-files'
4
3
 
4
+ /**
5
+ * GET /api/projects/[id]/stats
6
+ *
7
+ * MD-First Architecture: Returns stats parsed from MD files.
8
+ */
5
9
  export async function GET(
6
10
  request: NextRequest,
7
11
  { params }: { params: Promise<{ id: string }> }
@@ -16,33 +20,6 @@ export async function GET(
16
20
  }
17
21
 
18
22
  try {
19
- // Check if JSON files exist (new format)
20
- const hasJson = await hasJsonState(projectId)
21
-
22
- if (hasJson) {
23
- // Use new JSON loader (fast path)
24
- const jsonData = await loadUnifiedJsonData(projectId)
25
-
26
- // Convert to stats-compatible format using new architecture
27
- return NextResponse.json({
28
- success: true,
29
- version: 'v2',
30
- data: {
31
- currentTask: jsonData.state?.currentTask || null,
32
- queue: jsonData.queue?.tasks?.filter(t => !t.completed) || [],
33
- metrics: jsonData.metrics || null,
34
- agents: jsonData.agents,
35
- ideas: jsonData.ideas?.ideas || [],
36
- roadmap: jsonData.roadmap?.features || [],
37
- shipped: jsonData.shipped?.items || [],
38
- analysis: jsonData.analysis,
39
- outcomes: jsonData.outcomes,
40
- insights: jsonData.insights
41
- }
42
- })
43
- }
44
-
45
- // Fallback to legacy markdown parsing
46
23
  const [stats, raw] = await Promise.all([
47
24
  getProjectStats(projectId),
48
25
  getRawProjectFiles(projectId)
@@ -50,7 +27,7 @@ export async function GET(
50
27
 
51
28
  return NextResponse.json({
52
29
  success: true,
53
- version: 'v1-legacy',
30
+ version: 'md-first',
54
31
  data: stats,
55
32
  raw
56
33
  })
@@ -44,7 +44,8 @@ import {
44
44
  Undo2,
45
45
  Redo2,
46
46
  Command,
47
- X
47
+ X,
48
+ RefreshCw
48
49
  } from 'lucide-react'
49
50
  import { cn } from '@/lib/utils'
50
51
 
@@ -111,6 +112,15 @@ function CommandSidebarContent({
111
112
  onCommandClick?.()
112
113
  }}
113
114
  />
115
+ {/* Sync button - prominent, always visible */}
116
+ <CommandButton
117
+ cmd="p. sync"
118
+ icon={RefreshCw}
119
+ tip="Sync"
120
+ disabled={!isActiveConnected}
121
+ onClick={() => handleCommand('p. sync')}
122
+ variant="primary"
123
+ />
114
124
  <div className="border-b border-border w-8 my-2 mx-auto" />
115
125
 
116
126
  {COMMAND_GROUPS.map((group, groupIndex) => (
@@ -268,6 +278,29 @@ function ProjectPageContent({ projectId, project }: { projectId: string; project
268
278
  <span className="text-xs text-muted-foreground">Stats</span>
269
279
  </button>
270
280
 
281
+ {/* Sync button - prominent */}
282
+ <button
283
+ onClick={() => {
284
+ sendCommandToActive('p. sync')
285
+ setCommandSheetOpen(false)
286
+ }}
287
+ disabled={!isActiveConnected}
288
+ className={cn(
289
+ "flex flex-col items-center gap-1.5 p-3 rounded-lg transition-colors",
290
+ isActiveConnected
291
+ ? "hover:bg-primary/10"
292
+ : "opacity-50 cursor-not-allowed"
293
+ )}
294
+ >
295
+ <div className={cn(
296
+ "h-10 w-10 rounded-full flex items-center justify-center",
297
+ isActiveConnected ? "bg-primary text-primary-foreground" : "bg-muted"
298
+ )}>
299
+ <RefreshCw className="h-5 w-5" />
300
+ </div>
301
+ <span className="text-xs text-primary font-medium">Sync</span>
302
+ </button>
303
+
271
304
  {WORKFLOW_COMMANDS.map(({ cmd, icon: Icon, tip }) => (
272
305
  <button
273
306
  key={cmd}