@allpepper/task-orchestrator-tui 1.3.0 → 2.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/package.json +2 -2
- package/src/tui/components/status-actions.tsx +59 -21
- package/src/tui/screens/dashboard.tsx +2 -69
- package/src/tui/screens/feature-detail.tsx +77 -49
- package/src/tui/screens/project-detail.tsx +8 -65
- package/src/tui/screens/project-view.tsx +76 -80
- package/src/tui/screens/task-detail.tsx +46 -19
- package/src/ui/adapters/direct.ts +323 -94
- package/src/ui/adapters/types.ts +75 -72
- package/src/ui/hooks/use-data.ts +71 -199
- package/src/ui/hooks/use-feature-kanban.ts +45 -30
- package/src/ui/hooks/use-kanban.ts +35 -27
- package/src/ui/index.ts +1 -0
- package/src/ui/lib/colors.ts +27 -24
- package/src/ui/lib/types.ts +0 -1
- package/src/ui/themes/dark.ts +10 -28
- package/src/ui/themes/light.ts +16 -34
- package/src/ui/themes/types.ts +14 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allpepper/task-orchestrator-tui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Terminal UI for task orchestration - Kanban boards, tree views, and task management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"prepublishOnly": "bun test"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@allpepper/task-orchestrator": "^
|
|
48
|
+
"@allpepper/task-orchestrator": "^2.0.0",
|
|
49
49
|
"@inkjs/ui": "^2.0.0",
|
|
50
50
|
"ink": "^6.6.0",
|
|
51
51
|
"react": "^19.2.4"
|
|
@@ -3,77 +3,115 @@ import { Box, Text, useInput } from 'ink';
|
|
|
3
3
|
import { StatusBadge } from './status-badge';
|
|
4
4
|
import { useTheme } from '../../ui/context/theme-context';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* v2 pipeline actions instead of free-form status picker
|
|
8
|
+
*/
|
|
9
|
+
interface PipelineAction {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
description: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
6
15
|
export interface StatusActionsProps {
|
|
7
16
|
currentStatus: string;
|
|
8
|
-
|
|
9
|
-
|
|
17
|
+
/** Next status in the pipeline (null if at end) */
|
|
18
|
+
nextStatus: string | null;
|
|
19
|
+
/** Previous status in the pipeline (null if at start) */
|
|
20
|
+
prevStatus: string | null;
|
|
21
|
+
/** Whether the entity is blocked */
|
|
22
|
+
isBlocked?: boolean;
|
|
23
|
+
/** Whether the entity is in a terminal state */
|
|
24
|
+
isTerminal?: boolean;
|
|
25
|
+
onAdvance: () => void;
|
|
26
|
+
onRevert: () => void;
|
|
27
|
+
onTerminate: () => void;
|
|
10
28
|
isActive?: boolean;
|
|
11
29
|
loading?: boolean;
|
|
12
30
|
}
|
|
13
31
|
|
|
14
32
|
export function StatusActions({
|
|
15
33
|
currentStatus,
|
|
16
|
-
|
|
17
|
-
|
|
34
|
+
nextStatus,
|
|
35
|
+
prevStatus,
|
|
36
|
+
isBlocked = false,
|
|
37
|
+
isTerminal = false,
|
|
38
|
+
onAdvance,
|
|
39
|
+
onRevert,
|
|
40
|
+
onTerminate,
|
|
18
41
|
isActive = true,
|
|
19
42
|
loading = false,
|
|
20
43
|
}: StatusActionsProps) {
|
|
21
44
|
const { theme } = useTheme();
|
|
45
|
+
|
|
46
|
+
// Build available actions
|
|
47
|
+
const actions: PipelineAction[] = [];
|
|
48
|
+
if (nextStatus && !isBlocked && !isTerminal) {
|
|
49
|
+
actions.push({ id: 'advance', label: `Advance to ${nextStatus}`, description: 'Move forward in pipeline' });
|
|
50
|
+
}
|
|
51
|
+
if (prevStatus && !isTerminal) {
|
|
52
|
+
actions.push({ id: 'revert', label: `Revert to ${prevStatus}`, description: 'Move backward in pipeline' });
|
|
53
|
+
}
|
|
54
|
+
if (!isTerminal) {
|
|
55
|
+
actions.push({ id: 'terminate', label: 'Will Not Implement', description: 'Close without completing' });
|
|
56
|
+
}
|
|
57
|
+
|
|
22
58
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
23
59
|
|
|
24
60
|
useInput((input, key) => {
|
|
25
|
-
if (!isActive || loading ||
|
|
61
|
+
if (!isActive || loading || actions.length === 0) return;
|
|
26
62
|
|
|
27
63
|
if (input === 'j' || key.downArrow) {
|
|
28
|
-
|
|
29
|
-
setSelectedIndex(nextIndex);
|
|
64
|
+
setSelectedIndex((selectedIndex + 1) % actions.length);
|
|
30
65
|
} else if (input === 'k' || key.upArrow) {
|
|
31
|
-
|
|
32
|
-
setSelectedIndex(prevIndex);
|
|
66
|
+
setSelectedIndex((selectedIndex - 1 + actions.length) % actions.length);
|
|
33
67
|
} else if (key.return) {
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
|
|
68
|
+
const action = actions[selectedIndex];
|
|
69
|
+
if (action) {
|
|
70
|
+
switch (action.id) {
|
|
71
|
+
case 'advance': onAdvance(); break;
|
|
72
|
+
case 'revert': onRevert(); break;
|
|
73
|
+
case 'terminate': onTerminate(); break;
|
|
74
|
+
}
|
|
37
75
|
}
|
|
38
76
|
}
|
|
39
77
|
});
|
|
40
78
|
|
|
41
79
|
return (
|
|
42
80
|
<Box flexDirection="column">
|
|
43
|
-
{/* Current Status */}
|
|
44
81
|
<Box marginBottom={1}>
|
|
45
82
|
<Text>Status: </Text>
|
|
46
83
|
<StatusBadge status={currentStatus} />
|
|
84
|
+
{isBlocked && (
|
|
85
|
+
<Text color={theme.colors.blocked}> [BLOCKED]</Text>
|
|
86
|
+
)}
|
|
47
87
|
</Box>
|
|
48
88
|
|
|
49
|
-
{/* Loading Indicator */}
|
|
50
89
|
{loading && (
|
|
51
90
|
<Box>
|
|
52
91
|
<Text dimColor>Loading...</Text>
|
|
53
92
|
</Box>
|
|
54
93
|
)}
|
|
55
94
|
|
|
56
|
-
{/* Transitions Section */}
|
|
57
95
|
{!loading && (
|
|
58
96
|
<>
|
|
59
|
-
{
|
|
97
|
+
{actions.length === 0 ? (
|
|
60
98
|
<Box>
|
|
61
|
-
<Text dimColor>No
|
|
99
|
+
<Text dimColor>No actions available (terminal state)</Text>
|
|
62
100
|
</Box>
|
|
63
101
|
) : (
|
|
64
102
|
<Box flexDirection="column">
|
|
65
103
|
<Box marginBottom={1}>
|
|
66
|
-
<Text>
|
|
104
|
+
<Text>Actions:</Text>
|
|
67
105
|
</Box>
|
|
68
|
-
{
|
|
106
|
+
{actions.map((action, index) => {
|
|
69
107
|
const isSelected = index === selectedIndex;
|
|
70
108
|
return (
|
|
71
|
-
<Box key={
|
|
109
|
+
<Box key={action.id} marginLeft={2}>
|
|
72
110
|
<Text color={isSelected ? theme.colors.highlight : undefined}>
|
|
73
111
|
{isSelected ? '▎' : ' '}
|
|
74
112
|
</Text>
|
|
75
113
|
<Text bold={isSelected}>
|
|
76
|
-
{' '}{
|
|
114
|
+
{' '}{action.label}
|
|
77
115
|
</Text>
|
|
78
116
|
</Box>
|
|
79
117
|
);
|
|
@@ -1,17 +1,15 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import { useProjects } from '../../ui/hooks/use-data';
|
|
4
4
|
import { useAdapter } from '../../ui/context/adapter-context';
|
|
5
5
|
import { useTheme } from '../../ui/context/theme-context';
|
|
6
6
|
import { EntityTable } from '../components/entity-table';
|
|
7
|
-
import { StatusBadge } from '../components/status-badge';
|
|
8
7
|
import { timeAgo } from '../../ui/lib/format';
|
|
9
8
|
import type { ProjectWithCounts } from '../../ui/hooks/use-data';
|
|
10
9
|
import { ConfirmDialog } from '../components/confirm-dialog';
|
|
11
10
|
import { ErrorMessage } from '../components/error-message';
|
|
12
11
|
import { EmptyState } from '../components/empty-state';
|
|
13
12
|
import { FormDialog } from '../components/form-dialog';
|
|
14
|
-
import type { ProjectStatus } from '@allpepper/task-orchestrator';
|
|
15
13
|
|
|
16
14
|
interface DashboardProps {
|
|
17
15
|
selectedIndex: number;
|
|
@@ -25,10 +23,8 @@ export function Dashboard({ selectedIndex, onSelectedIndexChange, onSelectProjec
|
|
|
25
23
|
const { adapter } = useAdapter();
|
|
26
24
|
const { theme } = useTheme();
|
|
27
25
|
const { projects, loading, error, refresh } = useProjects();
|
|
28
|
-
const [mode, setMode] = useState<'idle' | 'create' | 'edit' | 'delete'
|
|
26
|
+
const [mode, setMode] = useState<'idle' | 'create' | 'edit' | 'delete'>('idle');
|
|
29
27
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
30
|
-
const [allowedTransitions, setAllowedTransitions] = useState<string[]>([]);
|
|
31
|
-
const [statusIndex, setStatusIndex] = useState(0);
|
|
32
28
|
|
|
33
29
|
const columns = [
|
|
34
30
|
{
|
|
@@ -36,14 +32,6 @@ export function Dashboard({ selectedIndex, onSelectedIndexChange, onSelectProjec
|
|
|
36
32
|
label: 'Name',
|
|
37
33
|
width: 45,
|
|
38
34
|
},
|
|
39
|
-
{
|
|
40
|
-
key: 'status',
|
|
41
|
-
label: 'Status',
|
|
42
|
-
width: 18,
|
|
43
|
-
render: (_value: unknown, row: ProjectWithCounts, context?: { isSelected: boolean }) => (
|
|
44
|
-
<StatusBadge status={row.status} isSelected={context?.isSelected} />
|
|
45
|
-
),
|
|
46
|
-
},
|
|
47
35
|
{
|
|
48
36
|
key: 'features',
|
|
49
37
|
label: 'Features',
|
|
@@ -71,37 +59,7 @@ export function Dashboard({ selectedIndex, onSelectedIndexChange, onSelectProjec
|
|
|
71
59
|
const effectiveSelectedIndex = projects.length > 0 ? clampedSelectedIndex : 0;
|
|
72
60
|
const selectedProject = projects[effectiveSelectedIndex];
|
|
73
61
|
|
|
74
|
-
const statusTargets = useMemo(() => allowedTransitions as ProjectStatus[], [allowedTransitions]);
|
|
75
|
-
|
|
76
62
|
useInput((input, key) => {
|
|
77
|
-
// Handle status mode
|
|
78
|
-
if (mode === 'status') {
|
|
79
|
-
if (input === 'j' || key.downArrow) {
|
|
80
|
-
setStatusIndex((prev) => (prev + 1) % Math.max(1, statusTargets.length));
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (input === 'k' || key.upArrow) {
|
|
84
|
-
setStatusIndex((prev) => (prev - 1 + Math.max(1, statusTargets.length)) % Math.max(1, statusTargets.length));
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
if (key.escape) {
|
|
88
|
-
setMode('idle');
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
if (key.return && selectedProject && statusTargets[statusIndex]) {
|
|
92
|
-
const next = statusTargets[statusIndex];
|
|
93
|
-
adapter.setProjectStatus(selectedProject.id, next, selectedProject.version).then((result) => {
|
|
94
|
-
if (!result.success) {
|
|
95
|
-
setLocalError(result.error);
|
|
96
|
-
}
|
|
97
|
-
refresh();
|
|
98
|
-
setMode('idle');
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Handle idle mode
|
|
105
63
|
if (mode === 'idle') {
|
|
106
64
|
if (input === 'n') {
|
|
107
65
|
setMode('create');
|
|
@@ -119,15 +77,6 @@ export function Dashboard({ selectedIndex, onSelectedIndexChange, onSelectProjec
|
|
|
119
77
|
setMode('delete');
|
|
120
78
|
return;
|
|
121
79
|
}
|
|
122
|
-
if (input === 's' && selectedProject) {
|
|
123
|
-
adapter.getAllowedTransitions('PROJECT', selectedProject.status).then((result) => {
|
|
124
|
-
if (result.success) {
|
|
125
|
-
setAllowedTransitions(result.data);
|
|
126
|
-
setMode('status');
|
|
127
|
-
}
|
|
128
|
-
});
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
80
|
if (input === 'r') {
|
|
132
81
|
refresh();
|
|
133
82
|
}
|
|
@@ -234,22 +183,6 @@ export function Dashboard({ selectedIndex, onSelectedIndexChange, onSelectProjec
|
|
|
234
183
|
/>
|
|
235
184
|
) : null}
|
|
236
185
|
|
|
237
|
-
{mode === 'status' && selectedProject ? (
|
|
238
|
-
<Box flexDirection="column" borderStyle="round" borderColor={theme.colors.highlight} paddingX={1} marginTop={1}>
|
|
239
|
-
<Text bold>Set Project Status</Text>
|
|
240
|
-
{statusTargets.length === 0 ? (
|
|
241
|
-
<Text dimColor>No transitions available</Text>
|
|
242
|
-
) : (
|
|
243
|
-
statusTargets.map((status, idx) => (
|
|
244
|
-
<Text key={status} inverse={idx === statusIndex}>
|
|
245
|
-
{idx === statusIndex ? '>' : ' '} {status}
|
|
246
|
-
</Text>
|
|
247
|
-
))
|
|
248
|
-
)}
|
|
249
|
-
<Text dimColor>Enter apply • Esc cancel</Text>
|
|
250
|
-
</Box>
|
|
251
|
-
) : null}
|
|
252
|
-
|
|
253
186
|
</Box>
|
|
254
187
|
);
|
|
255
188
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import React, { useState } from 'react';
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import { useAdapter } from '../../ui/context/adapter-context';
|
|
4
4
|
import { useFeature } from '../../ui/hooks/use-data';
|
|
5
|
-
import type {
|
|
5
|
+
import type { Priority } from '@allpepper/task-orchestrator';
|
|
6
|
+
import type { WorkflowState } from '../../ui/adapters/types';
|
|
6
7
|
import { StatusBadge } from '../components/status-badge';
|
|
7
8
|
import { PriorityBadge } from '../components/priority-badge';
|
|
8
9
|
import { SectionList } from '../components/section-list';
|
|
@@ -11,6 +12,7 @@ import { FormDialog } from '../components/form-dialog';
|
|
|
11
12
|
import { ErrorMessage } from '../components/error-message';
|
|
12
13
|
import { EmptyState } from '../components/empty-state';
|
|
13
14
|
import { useTheme } from '../../ui/context/theme-context';
|
|
15
|
+
import { StatusActions } from '../components/status-actions';
|
|
14
16
|
|
|
15
17
|
interface FeatureDetailProps {
|
|
16
18
|
featureId: string;
|
|
@@ -26,8 +28,19 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
26
28
|
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
|
|
27
29
|
const [mode, setMode] = useState<'idle' | 'edit-feature' | 'create-task' | 'feature-status'>('idle');
|
|
28
30
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
29
|
-
const [
|
|
30
|
-
const [
|
|
31
|
+
const [workflowState, setWorkflowState] = useState<WorkflowState | null>(null);
|
|
32
|
+
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
|
|
33
|
+
|
|
34
|
+
// Fetch workflow state when feature loads
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (feature) {
|
|
37
|
+
adapter.getWorkflowState('feature', feature.id).then((result) => {
|
|
38
|
+
if (result.success) {
|
|
39
|
+
setWorkflowState(result.data);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}, [adapter, feature]);
|
|
31
44
|
|
|
32
45
|
// Handle keyboard navigation
|
|
33
46
|
useInput((input, key) => {
|
|
@@ -45,13 +58,7 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
45
58
|
setMode('create-task');
|
|
46
59
|
}
|
|
47
60
|
if (input === 's' && feature) {
|
|
48
|
-
|
|
49
|
-
if (result.success) {
|
|
50
|
-
setTransitions(result.data);
|
|
51
|
-
setTransitionIndex(0);
|
|
52
|
-
setMode('feature-status');
|
|
53
|
-
}
|
|
54
|
-
});
|
|
61
|
+
setMode('feature-status');
|
|
55
62
|
}
|
|
56
63
|
if (tasks.length > 0) {
|
|
57
64
|
if (input === 'j' || key.downArrow) {
|
|
@@ -69,31 +76,48 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
69
76
|
}
|
|
70
77
|
});
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
75
|
-
setTransitionIndex((prev) => (prev + 1) % Math.max(1, transitions.length));
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (input === 'k' || key.upArrow) {
|
|
79
|
-
setTransitionIndex((prev) => (prev - 1 + Math.max(1, transitions.length)) % Math.max(1, transitions.length));
|
|
80
|
-
return;
|
|
81
|
-
}
|
|
79
|
+
// Handle status panel (when in feature-status mode, Esc goes back to idle)
|
|
80
|
+
useInput((_input, key) => {
|
|
81
|
+
if (mode !== 'feature-status') return;
|
|
82
82
|
if (key.escape) {
|
|
83
83
|
setMode('idle');
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
if (key.return) {
|
|
87
|
-
const nextStatus = transitions[transitionIndex] as FeatureStatus | undefined;
|
|
88
|
-
if (!nextStatus) return;
|
|
89
|
-
adapter.setFeatureStatus(feature.id, nextStatus, feature.version).then((result) => {
|
|
90
|
-
if (!result.success) setLocalError(result.error);
|
|
91
|
-
refresh();
|
|
92
|
-
setMode('idle');
|
|
93
|
-
});
|
|
94
84
|
}
|
|
95
85
|
}, { isActive: mode === 'feature-status' });
|
|
96
86
|
|
|
87
|
+
// Pipeline operations
|
|
88
|
+
const handleAdvance = async () => {
|
|
89
|
+
if (!feature) return;
|
|
90
|
+
setIsUpdatingStatus(true);
|
|
91
|
+
setLocalError(null);
|
|
92
|
+
const result = await adapter.advance('feature', feature.id, feature.version);
|
|
93
|
+
if (!result.success) setLocalError(result.error);
|
|
94
|
+
await refresh();
|
|
95
|
+
setIsUpdatingStatus(false);
|
|
96
|
+
setMode('idle');
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const handleRevert = async () => {
|
|
100
|
+
if (!feature) return;
|
|
101
|
+
setIsUpdatingStatus(true);
|
|
102
|
+
setLocalError(null);
|
|
103
|
+
const result = await adapter.revert('feature', feature.id, feature.version);
|
|
104
|
+
if (!result.success) setLocalError(result.error);
|
|
105
|
+
await refresh();
|
|
106
|
+
setIsUpdatingStatus(false);
|
|
107
|
+
setMode('idle');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleTerminate = async () => {
|
|
111
|
+
if (!feature) return;
|
|
112
|
+
setIsUpdatingStatus(true);
|
|
113
|
+
setLocalError(null);
|
|
114
|
+
const result = await adapter.terminate('feature', feature.id, feature.version);
|
|
115
|
+
if (!result.success) setLocalError(result.error);
|
|
116
|
+
await refresh();
|
|
117
|
+
setIsUpdatingStatus(false);
|
|
118
|
+
setMode('idle');
|
|
119
|
+
};
|
|
120
|
+
|
|
97
121
|
if (loading) {
|
|
98
122
|
return (
|
|
99
123
|
<Box padding={1}>
|
|
@@ -125,6 +149,9 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
125
149
|
<Text bold>{feature.name}</Text>
|
|
126
150
|
<Text> </Text>
|
|
127
151
|
<StatusBadge status={feature.status} />
|
|
152
|
+
{feature.blockedBy.length > 0 && (
|
|
153
|
+
<Text color={theme.colors.blocked}> [BLOCKED]</Text>
|
|
154
|
+
)}
|
|
128
155
|
</Box>
|
|
129
156
|
|
|
130
157
|
{/* Divider */}
|
|
@@ -138,6 +165,8 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
138
165
|
<PriorityBadge priority={feature.priority} />
|
|
139
166
|
<Text> Modified: </Text>
|
|
140
167
|
<Text dimColor>{timeAgo(new Date(feature.modifiedAt))}</Text>
|
|
168
|
+
<Text> ID: </Text>
|
|
169
|
+
<Text dimColor>{feature.id}</Text>
|
|
141
170
|
</Box>
|
|
142
171
|
|
|
143
172
|
{/* Divider */}
|
|
@@ -195,6 +224,9 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
195
224
|
<Text bold={isSelected}>
|
|
196
225
|
{task.title}
|
|
197
226
|
</Text>
|
|
227
|
+
{task.blockedBy.length > 0 && (
|
|
228
|
+
<Text color={theme.colors.blocked}> [B]</Text>
|
|
229
|
+
)}
|
|
198
230
|
</Box>
|
|
199
231
|
);
|
|
200
232
|
})}
|
|
@@ -209,7 +241,7 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
209
241
|
</Box>
|
|
210
242
|
)}
|
|
211
243
|
|
|
212
|
-
{/* Sections Panel
|
|
244
|
+
{/* Sections Panel */}
|
|
213
245
|
{sections.length > 0 && (
|
|
214
246
|
<Box flexDirection="column" marginBottom={1}>
|
|
215
247
|
<Text bold>Sections</Text>
|
|
@@ -287,23 +319,19 @@ export function FeatureDetail({ featureId, onSelectTask, onBack }: FeatureDetail
|
|
|
287
319
|
|
|
288
320
|
{mode === 'feature-status' ? (
|
|
289
321
|
<Box flexDirection="column" borderStyle="round" borderColor={theme.colors.accent} paddingX={1} marginTop={1}>
|
|
290
|
-
<
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
);
|
|
304
|
-
})
|
|
305
|
-
)}
|
|
306
|
-
<Text dimColor>Enter apply • Esc cancel</Text>
|
|
322
|
+
<StatusActions
|
|
323
|
+
currentStatus={feature.status}
|
|
324
|
+
nextStatus={workflowState?.nextStatus ?? null}
|
|
325
|
+
prevStatus={workflowState?.prevStatus ?? null}
|
|
326
|
+
isBlocked={feature.blockedBy.length > 0}
|
|
327
|
+
isTerminal={workflowState?.isTerminal ?? false}
|
|
328
|
+
isActive={true}
|
|
329
|
+
loading={isUpdatingStatus}
|
|
330
|
+
onAdvance={handleAdvance}
|
|
331
|
+
onRevert={handleRevert}
|
|
332
|
+
onTerminate={handleTerminate}
|
|
333
|
+
/>
|
|
334
|
+
<Text dimColor>Esc: cancel</Text>
|
|
307
335
|
</Box>
|
|
308
336
|
) : null}
|
|
309
337
|
</Box>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
2
|
import { Box, Text, useInput } from 'ink';
|
|
3
3
|
import { useAdapter } from '../../ui/context/adapter-context';
|
|
4
|
-
import type { Project, Feature,
|
|
4
|
+
import type { Project, Feature, Section, EntityType } from '@allpepper/task-orchestrator';
|
|
5
5
|
import { StatusBadge } from '../components/status-badge';
|
|
6
6
|
import { timeAgo } from '../../ui/lib/format';
|
|
7
7
|
import { FormDialog } from '../components/form-dialog';
|
|
@@ -26,10 +26,8 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
26
26
|
const [error, setError] = useState<string | null>(null);
|
|
27
27
|
const [selectedFeatureIndex, setSelectedFeatureIndex] = useState(0);
|
|
28
28
|
const [selectedSectionIndex, setSelectedSectionIndex] = useState(0);
|
|
29
|
-
const [mode, setMode] = useState<'idle' | 'edit-project'
|
|
29
|
+
const [mode, setMode] = useState<'idle' | 'edit-project'>('idle');
|
|
30
30
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
31
|
-
const [transitions, setTransitions] = useState<string[]>([]);
|
|
32
|
-
const [transitionIndex, setTransitionIndex] = useState(0);
|
|
33
31
|
|
|
34
32
|
const load = async () => {
|
|
35
33
|
setLoading(true);
|
|
@@ -70,15 +68,6 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
70
68
|
if (input === 'e' && project) {
|
|
71
69
|
setMode('edit-project');
|
|
72
70
|
}
|
|
73
|
-
if (input === 's' && project) {
|
|
74
|
-
adapter.getAllowedTransitions('PROJECT', project.status).then((result) => {
|
|
75
|
-
if (result.success) {
|
|
76
|
-
setTransitions(result.data);
|
|
77
|
-
setTransitionIndex(0);
|
|
78
|
-
setMode('project-status');
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
}
|
|
82
71
|
if (features.length > 0) {
|
|
83
72
|
if (input === 'j' || key.downArrow) {
|
|
84
73
|
setSelectedFeatureIndex((prev) => Math.min(prev + 1, features.length - 1));
|
|
@@ -95,31 +84,6 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
95
84
|
}
|
|
96
85
|
});
|
|
97
86
|
|
|
98
|
-
useInput((input, key) => {
|
|
99
|
-
if (mode !== 'project-status' || !project) return;
|
|
100
|
-
if (input === 'j' || key.downArrow) {
|
|
101
|
-
setTransitionIndex((prev) => (prev + 1) % Math.max(1, transitions.length));
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
if (input === 'k' || key.upArrow) {
|
|
105
|
-
setTransitionIndex((prev) => (prev - 1 + Math.max(1, transitions.length)) % Math.max(1, transitions.length));
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
if (key.escape) {
|
|
109
|
-
setMode('idle');
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
if (key.return) {
|
|
113
|
-
const nextStatus = transitions[transitionIndex] as ProjectStatus | undefined;
|
|
114
|
-
if (!nextStatus) return;
|
|
115
|
-
adapter.setProjectStatus(project.id, nextStatus, project.version).then((result) => {
|
|
116
|
-
if (!result.success) setLocalError(result.error);
|
|
117
|
-
load();
|
|
118
|
-
setMode('idle');
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
}, { isActive: mode === 'project-status' });
|
|
122
|
-
|
|
123
87
|
if (loading) {
|
|
124
88
|
return (
|
|
125
89
|
<Box padding={1}>
|
|
@@ -146,11 +110,9 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
146
110
|
|
|
147
111
|
return (
|
|
148
112
|
<Box flexDirection="column" padding={1}>
|
|
149
|
-
{/* Project Header */}
|
|
113
|
+
{/* Project Header (no status - projects are stateless in v2) */}
|
|
150
114
|
<Box marginBottom={1}>
|
|
151
115
|
<Text bold>{project.name}</Text>
|
|
152
|
-
<Text> </Text>
|
|
153
|
-
<StatusBadge status={project.status} />
|
|
154
116
|
</Box>
|
|
155
117
|
|
|
156
118
|
{/* Divider */}
|
|
@@ -219,6 +181,9 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
219
181
|
<Text bold={isSelected}>
|
|
220
182
|
{feature.name}
|
|
221
183
|
</Text>
|
|
184
|
+
{feature.blockedBy.length > 0 && (
|
|
185
|
+
<Text color={theme.colors.blocked}> [B]</Text>
|
|
186
|
+
)}
|
|
222
187
|
</Box>
|
|
223
188
|
);
|
|
224
189
|
})}
|
|
@@ -233,7 +198,7 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
233
198
|
</Box>
|
|
234
199
|
)}
|
|
235
200
|
|
|
236
|
-
{/* Sections Panel
|
|
201
|
+
{/* Sections Panel */}
|
|
237
202
|
{sections.length > 0 && (
|
|
238
203
|
<Box flexDirection="column" marginBottom={1}>
|
|
239
204
|
<Text bold>Sections</Text>
|
|
@@ -249,7 +214,7 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
249
214
|
{/* Help Footer */}
|
|
250
215
|
<Box marginTop={1}>
|
|
251
216
|
<Text dimColor>
|
|
252
|
-
ESC/h: Back | r: Refresh | e: Edit
|
|
217
|
+
ESC/h: Back | r: Refresh | e: Edit{features.length > 0 ? ' | j/k: Navigate | Enter: Select Feature' : ''}
|
|
253
218
|
</Text>
|
|
254
219
|
</Box>
|
|
255
220
|
|
|
@@ -278,28 +243,6 @@ export function ProjectDetail({ projectId, onSelectFeature, onBack }: ProjectDet
|
|
|
278
243
|
}}
|
|
279
244
|
/>
|
|
280
245
|
) : null}
|
|
281
|
-
|
|
282
|
-
{mode === 'project-status' ? (
|
|
283
|
-
<Box flexDirection="column" borderStyle="round" borderColor={theme.colors.accent} paddingX={1} marginTop={1}>
|
|
284
|
-
<Text bold>Set Project Status</Text>
|
|
285
|
-
{transitions.length === 0 ? (
|
|
286
|
-
<Text dimColor>No transitions available</Text>
|
|
287
|
-
) : (
|
|
288
|
-
transitions.map((status, idx) => {
|
|
289
|
-
const isSelected = idx === transitionIndex;
|
|
290
|
-
return (
|
|
291
|
-
<Box key={status}>
|
|
292
|
-
<Text color={isSelected ? theme.colors.highlight : undefined}>
|
|
293
|
-
{isSelected ? '▎' : ' '}
|
|
294
|
-
</Text>
|
|
295
|
-
<Text bold={isSelected}> {status}</Text>
|
|
296
|
-
</Box>
|
|
297
|
-
);
|
|
298
|
-
})
|
|
299
|
-
)}
|
|
300
|
-
<Text dimColor>Enter apply • Esc cancel</Text>
|
|
301
|
-
</Box>
|
|
302
|
-
) : null}
|
|
303
246
|
</Box>
|
|
304
247
|
);
|
|
305
248
|
}
|